Structuring apps

One folder per screen. Bootstrap is the only file that knows everything.

A Stario app with one screen fits in one file. With two or more, split it by screen: one folder per feature, the same three files in each, and a bootstrap that wires them together. This page states the rules. Chat room is the example that follows them.

The tree#

app/
  main.py               bootstrap: config, shared objects, features
  config.py             Config.from_env()
  db.py                 the database handle
  assets.py             ASSETS = Assets(...)
  common/               shared shell, identity, helpers
  features/
    billing/
      urls.py           Route values
      views.py          render(...) and smaller views
      handlers.py       handler factories and register_billing()
      models.py         dataclasses this feature owns (optional)
      data.py           SCHEMA and queries (optional)
tests/
  conftest.py           client fixture over TestClient(bootstrap)
  test_billing.py

A feature is a screen or a resource with its own URL. It owns its routes, its HTML, and its handlers. It owns tables only if no other feature does; the chat-room lobby has no data.py because it reads the room's queries.

Name modules after what they hold. db.py and config.py say what is inside; deps.py and utils.py do not.

Routes in urls.py#

Routes are values, so they live in a module that imports only stario. Views import it to build links. Handlers import it to register. Neither imports the other.

# app/features/billing/urls.py
from stario import Route
 
INVOICES_PATH = "/billing/invoices"
INVOICES = Route("GET", INVOICES_PATH)
INVOICE = Route("GET", INVOICES_PATH + "/{invoice_id}")
LIVE = Route("GET", INVOICES_PATH + "/live")
PAY = Route("POST", INVOICES_PATH + "/{invoice_id}/pay")

A screen with no action is one GET. A screen with any action has three kinds of route: the page, a /live stream that pushes HTML, and POST commands that reply 204 and publish. Live pages explains why.

Views in views.py#

A view is a function from state to HTML. The main one is render, and it returns the whole document. Smaller views are plain functions it calls. Views import urls.py for href() and nothing from handlers.py.

# app/features/billing/views.py
def render(invoices: list[Invoice], *, user: User) -> HtmlElement:
    return shell(
        h.Main(
            data.init(at.get(LIVE.href(), retry="always")),
            h.H1("Invoices"),
            invoice_table(invoices),
        ),
        user=user,
    )

Handlers in handlers.py#

One shape for every feature: a factory per handler, and one register_<feature> that adds every route. The factory takes the shared objects the handler needs and returns the handler; register takes them all.

# app/features/billing/handlers.py
def show_billing(db: Database):
    async def handler(c: Context, w: Writer) -> None:
        w.html(render(data.list_invoices(db), user=current_user(c)))
 
    return handler
 
 
def billing_live(db: Database, relay: Relay[str]):
    async def handler(c: Context, w: Writer) -> None:
        async with relay.subscribe("billing.*") as events:
            sse = SSE(w)
            sse.patch_elements(render(data.list_invoices(db), user=current_user(c)))
            async for _ in c.alive(events):
                sse.patch_elements(render(data.list_invoices(db), user=current_user(c)))
 
    return handler
 
 
def pay_invoice(db: Database, relay: Relay[str]):
    async def handler(c: Context, w: Writer) -> None:
        invoice_id = c.match.params["invoice_id"]
        w.empty()
        data.mark_paid(db, invoice_id)
        relay.publish("billing.paid", invoice_id)
 
    return handler
 
 
def register_billing(app: App, db: Database, relay: Relay[str]) -> None:
    app.add(INVOICES, show_billing(db))
    app.add(LIVE, billing_live(db, relay))
    app.add(PAY, pay_invoice(db, relay))

A static screen's factory may take one config value; a live screen's factories take db and relay. Do not wrap handlers in a class — a factory closes over the same objects with less machinery.

Bootstrap in main.py#

bootstrap is the composition root. It reads config, opens shared objects once, attaches assets, and registers each feature. Nothing under app/features/ imports it.

# app/main.py
async def bootstrap(app: App, span: Span):
    config = Config.from_env()
    db = Database(config.db_path)
    db.apply_schema(billing_data.SCHEMA)
    relay: Relay[str] = Relay()
 
    await ASSETS.attach(app)
    register_billing(app, db, relay)
    yield
    db.close()

Code before yield runs at startup. Code after it runs at shutdown. Runtime has the details.

Middleware#

Register prefix middleware with app.use in bootstrap, in any order, or pass middleware=[...] to app.add for one route.

app.use("/app", session.attach_user())
app.use("/app", session.require_user())
register_billing(app, db, relay)

Routing states the composition rules.

Tests#

Put tests/ next to app/. The client fixture opens TestClient(bootstrap), the same bootstrap production runs, so tests use the real routes and schema. One test per screen opens the stream, sends a command, and reads the repaint. Chat room shows that test.

By the way#

  • Keep request parsing that several features share in app/common/. A feature-specific signals.py is fine when only one feature reads that shape.

  • Assets is built at import time in assets.py and attached in bootstrap. Views receive ASSETS.href("css/app.css") as a string. Static assets has the recipe.

  • The complete example is examples/chat-room.