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
bootstrap— One composition root: create long-lived clients, register static assets, call each feature’sregister_*, attach scoped middleware withapp.usebefore routes on that prefix. Feature modules should stay importable without side effects;bootstrapimports features, not the other way around, so you avoid circular imports.Full paths — Register complete URL patterns on
App(app.get(ROOM, handler)). Group related paths in a featureurls.pyasUrlPathconstants; useapp.use("/billing", mw)when a whole prefix shares middleware—notRouter.mount.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).Views stay
data → html— Buildstario.markuptrees from plain values. ResolveUrlPath.href()andAssetManifest.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 atapp/(bootstrap, DB client, static assets, sharedinputs.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
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.pyChat-room layout (multi-feature reference)
From examples/chat-room — shared infrastructure at app/ root, bounded areas under app/features/:
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.jstests/ conftest.py test_lobby.py test_room.pypyproject.tomlPer feature, the same optional files appear in every folder (lobby omits models.py / data.py because it consumes the room domain):
| File | Role |
|---|---|
urls.py | UrlPath constants for this area |
models.py | Domain dataclasses this feature owns |
data.py | SCHEMA DDL + query functions against the shared database |
subjects.py | Relay subject helpers for this feature’s events |
signals.py | Datastar signal shape + read_*_signals |
views.py | Pure HTML trees (common.shell.page wraps layout) |
handlers.py | Handler 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.
project-root/ pyproject.toml main.py # bootstrap + UrlPath constants + handlers + viewsName 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:
from app.assets import ASSETSfrom app.billing.handlers import register_billingfrom app.database import connect_poolfrom 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 /:
# app/billing/urls.pyfrom stario.routing import UrlPath INVOICES = UrlPath("/billing/invoices")INVOICE = INVOICES / "{invoice_id}"# app/billing/handlers.pyfrom 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 overdb. Small, explicit, easy to follow.Class + methods — A type instantiated in
bootstrapwithpoolonself; pass bound methods toapp.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:
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
Chat room tutorial — narrative tour of the tree above.
examples/chat-room— runnable source (uv run stario watch app.main:bootstrap).Realtime tiles — single-file tutorial to split using the general layout.
Related
Static assets and fingerprinting —
AssetManifest,StaticAssetsInjecting dependencies — closures,
Services, context managers inbootstrapAuthentication with cookie sessions — middleware and session state
Reading and writing Datastar signals — shared parsing and signal helpers
Realtime tiles — a single-file tutorial you can split using the layout above
Routing —
UrlPath,app.use, middlewareRuntime —
bootstrap,StaticAssets,App