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 mounts stay obvious, bootstrap stays the single place that wires the world, and you avoid a flat directory of twenty unrelated handlers.

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, Router / mount, and the per-request handler shape from the getting started walkthrough. If those are still fuzzy, read those links 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, call app.mount, register routes, attach static assets. Feature modules should stay importable without side effects; bootstrap imports features, not the other way around, so you avoid circular imports. Treat bootstrap as the only place that wires the app: everything else either imports shared types from here or is imported into bootstrap for registration—avoid a second hidden global registry.

  2. Mount by domain — Prefer app.mount("/billing", billing_router) (or a prefix that matches the feature folder) over repeating the same path prefix on every get. Alternatively, app.mount("/", feature_router) with absolute paths inside the child router is valid (the chat-room example does this)—choose URL layout and grep ergonomics, not a framework requirement.

  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.html trees from plain values. If the tree needs URLs, resolve them before you call the view (see Static assets and url_for).

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, static/). 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.

text
app/
  __init__.py
  bootstrap.py          # async def bootstrap(app, span) — imports features, mounts routers
  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/
    router.py           # builds and returns a Router, or exposes register(app) (optional style)
    handlers.py
    views.py
  about/                # or: features/about/
    handlers.py
    views.py

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. Same for a third-party API wrapper: weather_api.py, relay.py, etc. Keep bootstrap readable: import concrete modules and pass instances into factories or routers.

bootstrap.py wires features without owning their internals:

python
from collections.abc import AsyncIterator
 
from stario import App, Span
 
from app.billing.router import build_router as build_billing_router
from app.database import connect_pool
 
 
async def bootstrap(app: App, span: Span) -> AsyncIterator[None]:
    pool = await connect_pool()
    app.mount("/billing", build_billing_router(pool))
    yield
    await pool.close()

(Shape of bootstrap follows Runtime — Application lifecycle; the point is one place that imports feature routers and passes shared objects in.)

Sub-routers and shared clients

Mounted routers 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, or a mix across the codebase.

  • Closures / factories — build_billing_router(pool) returns a Router whose handlers close over pool. Small, explicit, easy to follow.

  • Class + methods — A type instantiated in bootstrap with pool on self; pass router.get(..., billing.list_invoices) etc. 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.

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. That keeps handlers short and answers “how do we read this app’s requests?” in one place.

Align with the patterns in Reading and writing Datastar 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. Name functions after what they parse (read_order_form, signals_for_dashboard) rather than scattering ad hoc json.loads and field access in every handler. Resource-specific logic that only one route needs can stay in that feature’s package; cross-cutting wire formats belong in inputs.py (or a small package if it outgrows one file).

Static assets and url_for

Mount a directory with StaticAssets and a name= (for example name="static"). Files are fingerprinted and registered for app.url_for("static:path/under/dir.css") so HTML can point at stable logical names while clients load hashed URLs. Omitting name= still serves files but gives url_for nothing to resolve.

python
from pathlib import Path
from stario import StaticAssets
 
# Path("static") is relative to the process cwd; in real apps prefer Path(__file__).parent / "static".
app.mount("/static", StaticAssets(Path("static"), name="static"))
app.url_for("static:css/app.css")  # fingerprinted path for that file

Resolve URLs early

Call url_for where you already have app — typically in the handler or in a view function invoked from the handler with plain strings for href / src / Datastar URLs:

python
import stario.html as h
from stario import App
 
 
def layout(app: App, *children):
    css_href = app.url_for("static:css/style.css")
    js_src = app.url_for("static:js/app.js")
    return h.HtmlDocument(
        {"lang": "en"},
        h.Head(
            h.Link({"rel": "stylesheet", "href": css_href}),
            h.Script({"type": "module", "src": js_src}),
        ),
        h.Body(*children),
    )

That is simpler than threading app, c.app, or a url_for callable through every template helper. If a nested helper needs a URL, pass the already-built string (or a small struct), not the whole app.

In handlers you can also use c.app.url_for(...) when you do not have app in scope (see Context).

Many template parameters

When a view needs a long list of pieces (title, breadcrumbs, flash message, pagination, etc.), bundle them in a @dataclass(frozen=True, slots=True) (or similar) and pass one object into the view. That shortens signatures, keeps HTML builders readable, and makes it obvious what each page needs.

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—consider a bootstrap used only in tests that still reuses the real setup: shared connection helpers, the same mount graph with test doubles wired in place of production clients, and the same async-generator shape (yield, teardown) so behavior stays comparable. The point is to avoid maintaining two unrelated startup stories; extract what both paths need into small functions or modules, then compose production bootstrap and test bootstrap from those pieces. No second composition root should drift silently from the first.

Where to look in the real world

The chat-room example in the Stario repo (examples/chat-room) uses feature packages (app/chat, app/about), a shared static mount, url_for("static:…"), and tests against bootstrap.