Structuring larger applications

This how-to is about where files go when a Stario app outgrows a single module. Stario does not enforce a layout; the goal here is a pragmatic default so routes stay grep-friendly, bootstrap stays the single composition root, and you avoid a flat directory of twenty unrelated handlers.

Use the diagrams below as templates. For a working multi-file app, open examples/chat-room in the stario repo — it is the canonical larger-app layout. The Chat room tutorial walks through the same tree file by file.

It is not a guide to dependency injection or middleware. For passing shared clients into handlers, see Injecting dependencies. For sessions, cookies, and auth-shaped middleware, see Authentication with cookie sessions.

Before you lean on this page: you should already be comfortable with bootstrap, registering routes on App, and the per-request handler shape from Hello world. If those are still fuzzy, read that tutorial first—this page stays opinionated about layout, not about Stario basics.

What stays stable as you grow

  1. bootstrap — One composition root: create long-lived clients, register static assets, call each feature’s register_*, attach scoped middleware with app.use before routes on that prefix. Feature modules should stay importable without side effects; bootstrap imports features, not the other way around, so you avoid circular imports.

  2. Full paths — Register complete URL patterns on App (app.get(ROOM, handler)). Group related paths in a feature urls.py as UrlPath constants; use app.use("/billing", mw) when a whole prefix shares middleware—not Router.mount.

  3. Handlers stay thin — Parse or delegate input, call something that returns data, pick a response helper. Heavy logic belongs in modules that do not need Writer (easier tests).

  4. Views stay data → html — Build stario.markup trees from plain values. Resolve UrlPath.href() and AssetManifest.href() before you call the view (see Static assets and fingerprinting).

Split by feature

Group routes, handlers, and templates by feature or bounded context so grepping stays predictable. Two common shapes:

  • Flat packages under app/ — e.g. app/billing/, app/about/ beside shared modules (bootstrap.py, database.py, assets.py). Fine for many teams; paths stay short.

  • app/features/… when the tree gets wide — e.g. app/features/billing/, app/features/about/, with the same files inside each feature folder. Keeps cross-cutting code obvious at app/ (bootstrap, DB client, static assets, shared inputs.py) and feature-only code under one branch.

Pick the layout that matches how large the repo feels; the rules below are the same either way.

General feature layout

text
app/
  __init__.py
  main.py               # async def bootstrap(app, span) — composition root
  assets.py             # AssetManifest + href constants
  database.py           # pool / engine — shared; used from bootstrap + handlers
  relay.py              # example: outbound client — same idea
  inputs.py             # shared parsing (see below)
  static/
    css/
    js/
  billing/              # or: features/billing/
    urls.py             # UrlPath constants for this feature
    handlers.py         # handler factories + register_billing(app, …)
    views.py
  about/                # or: features/about/
    handlers.py
    views.py

Chat-room layout (multi-feature reference)

From examples/chat-room — shared infrastructure at app/ root, bounded areas under app/features/:

text
app/
  main.py           # bootstrap — composition root; start here
  config.py         # env-first Config, read once in bootstrap
  assets.py         # AssetManifest + fingerprinted STYLE_CSS, DATASTAR_JS
  db.py             # thin SQLite core (connection + transactions)
  common/           # page shell, demo identity — cross-feature, no owner
  features/
    lobby/          # GET / — room picker; POST/DELETE reuse room urls
    room/           # owns room domain: models, data, chat, SSE
  static/           # CSS, vendored datastar.js
tests/
  conftest.py
  test_lobby.py
  test_room.py
pyproject.toml

Per feature, the same optional files appear in every folder (lobby omits models.py / data.py because it consumes the room domain):

FileRole
urls.pyUrlPath constants for this area
models.pyDomain dataclasses this feature owns
data.pySCHEMA DDL + query functions against the shared database
subjects.pyRelay subject helpers for this feature’s events
signals.pyDatastar signal shape + read_*_signals
views.pyPure HTML trees (common.shell.page wraps layout)
handlers.pyHandler factories + register_* at the bottom

Domain imports flow one way: lobby → room, never the reverse. app/main.py reads config, opens shared deps once, registers static assets, then calls each register_*.

Smaller apps (single module)

Tutorial-scale apps can stay in one file until the tree hurts — see examples/tiles (main.py, routes, views, and handlers together). Split into the layouts above when grep and imports get noisy.

text
project-root/
  pyproject.toml
  main.py               # bootstrap + UrlPath constants + handlers + views

Name modules after what they hold, not after a pattern name. If only the database needs a module, database.py (or db.py) beats a generic deps.py full of unrelated build_* functions. Keep bootstrap readable: import concrete modules and pass instances into factories or register_* functions.

main.py wires features without owning their internals:

python
from app.assets import ASSETS
from app.billing.handlers import register_billing
from app.database import connect_pool
from stario import App, Span, StaticAssets
 
 
async def bootstrap(app: App, span: Span):
    pool = await connect_pool()
 
    with span.step("static_assets") as s:
        static = StaticAssets(ASSETS)
        s.attrs(static.stats)
    static.register(app)
 
    register_billing(app, pool)
    yield
    await pool.close()

(Shape of bootstrap follows Runtime — Application lifecycle; startup before yield, teardown after.)

UrlPath constants and register_*

Each feature owns its paths and registration. Paths compose with /:

python
# app/billing/urls.py
from stario.routing import UrlPath
 
INVOICES = UrlPath("/billing/invoices")
INVOICE = INVOICES / "{invoice_id}"
python
# app/billing/handlers.py
from stario import App
 
from .urls import INVOICE, INVOICES
 
 
def list_invoices(db):
    async def handler(c, w):
        ...
    return handler
 
 
def get_invoice(db):
    async def handler(c, w):
        ...
    return handler
 
 
def register_billing(app: App, db) -> None:
    app.get(INVOICES, list_invoices(db))
    app.get(INVOICE, get_invoice(db))

In views and redirects, use INVOICES.href() or INVOICE.href(invoice_id="…")—no url_for.

Handler factories and shared clients

Features need the same database or HTTP client the rest of the app uses. Two styles both work; use whichever fits that feature—closures for a small surface, a class when many routes and shared helpers pay for the type.

  • Closures / factories — show_room(db) returns a handler that closes over db. Small, explicit, easy to follow.

  • Class + methods — A type instantiated in bootstrap with pool on self; pass bound methods to app.get. Good when the feature has many routes and shared private helpers.

Avoid reaching for a DI container until import wiring actually hurts. If it does, the patterns in Injecting dependencies still apply inside a larger tree.

Scoped middleware

Register prefix middleware before routes on that prefix:

python
from stario import App, UrlPath
 
APP_PREFIX = UrlPath("/app")
DASHBOARD = APP_PREFIX / "dashboard"
 
app.use(APP_PREFIX, auth_session.attach_user())
app.use(APP_PREFIX, auth_session.require_user())
app.get(DASHBOARD, dashboard(auth_session))

Per-route middleware=[…] on app.get still works and composes with app.use. See Routing — Middleware.

inputs.py and shared parsing

Put reused request parsing in one module, e.g. inputs.py, and import it from handlers—generic helpers, not one-off copies per route.

Align with Datastar: Reading and writing signals: thin wrappers around read_signals, helpers that validate into Pydantic (or msgspec / attrs + cattrs) models you define, shared JSON-body readers, file-signal handling, and anything else you repeat across resources.

Static assets

Build AssetManifest at module level in assets.py; register StaticAssets in bootstrap. Pass ASSETS.href("css/app.css") into views as plain strings. Full recipe: Static assets and fingerprinting.

Tests

Colocate tests/ next to the package or mirror app/ under tests/. Prefer TestClient against the same bootstrap as production whenever you can: identical wiring catches integration bugs and keeps “how the app starts” in one place.

When that is not practical—in-memory fakes, test databases, or stubbed HTTP clients instead of real ones—extract shared setup into small functions both production and test bootstrap call, but keep the async-generator shape (yield, teardown) so behavior stays comparable.

Where to look in the real world