App architecture & best practices¶
Just as tempest-fastapi-sdk enforces a strict
router → controller → service → repository slicing on the backend, a real
tempestweb app is organized into layers with clear owners. The runtime gives
you a cycle; the layers give you where to put each thing so the app doesn't
rot into garbage code. 🚀
Prerequisites
Read the Tutorial first — here we assume you already
know view(), state and set_state. This page is about how to
structure a real app on top of that cycle.
Two truths: the cycle and the layers¶
The cycle is the runtime contract — immutable, holds in all three modes:
state → view → handlers → state
The view only reads state; handlers only write via set_state; the
reconciler redraws. The layers are how you organize the code that fills
that cycle as the app grows.
flowchart TD
subgraph Render["Render axis (downward)"]
Routes["🧭 routes/\n(NavStack + Route)"] --> Pages["📄 pages/\n(page(app) -> Widget)"]
Pages --> Components["🧩 components/\n(reusable builders)"]
Components --> Styles["🎨 styles/\n(Style tokens)"]
end
subgraph Behavior["Behavior axis (event)"]
Ctl["🎼 controllers/\n(intent -> set_state)"] --> Svc["📐 services/\n(domain rules)"]
Svc --> Sto["🗄️ storages/\n(http + native.storage)"]
Svc --> Sch["📦 schemas/\n(Pydantic DTOs)"]
end
Core["⚙️ core/ (state + settings + constants + exceptions)"]
Utils["🔧 utils/ (stateless helpers)"]
Pages -->|"on_click ="| Ctl
Ctl -->|"set_state(fn)"| Core
Core -->|"app.state"| Pages
Sto -.->|"app.native.*"| Native["🌐 native capabilities"]
Svc -.-> Utils
The render axis goes down: the route picks the page, the page composes
components, components use style tokens. The behavior axis fires on an event:
the page delegates to the controller, which orchestrates services, which talk to
storages and schemas — and finally the controller calls set_state, closing the
cycle.
What lives where¶
Responsibilities of each layer
| Layer | Folder | Responsible for | NEVER touches |
|---|---|---|---|
| Routes | routes/ |
Map route → page; NavStack/Route; navigation guards |
Widgets, business rules |
| Pages | pages/ |
One screen = page(app) -> Widget; compose components; wire event → controller |
I/O, business rules, raw inline style |
| Components | components/ |
Reusable, presentational widget builders (card(...), controlled fields value+on_change) |
State mutation, I/O, navigation |
| Styles | styles/ |
Shared Style tokens — colors, spacing, typography, theme |
Logic, widgets |
| Controllers | controllers/ |
Turn user intent into set_state; orchestrate multiple services |
Direct HTTP/storage, building widgets |
| Services | services/ |
Domain rules; combine storages + native; return schemas | Raw HTTP/DOM, set_state, widgets |
| Storages | storages/ |
Data access: backend HTTP client, app.native.storage (IndexedDB), cache |
Business rules, widgets |
| Schemas | schemas/ |
Pydantic v2 DTOs — API responses, forms, domain shapes | Logic, I/O |
| Utils | utils/ |
Stateless helpers — formatters, validators, parsers | State, I/O, widgets |
| Core | core/ |
state (dataclasses + make_state), settings, constants, exceptions |
— |
The golden rule of the slicing (same as the backend): don't skip layers. A page does not call a storage directly; it calls the controller, which calls the service, which calls the storage. Every diagonal jump is technical debt.
File layout¶
my_app/
├── app.py # make_state + view: resolve route → page (lean)
├── core/
│ ├── settings.py # typed config (BaseAppSettings)
│ ├── constants.py # app constants
│ ├── exceptions.py # domain exceptions
│ └── state.py # state dataclasses + make_state
├── routes/
│ └── __init__.py # route table + resolve(app) -> Widget
├── pages/
│ ├── login.py # login_page(app) -> Widget
│ └── home.py
├── components/
│ ├── card.py # card(...) -> Widget
│ └── fields.py # reusable controlled fields
├── styles/
│ └── tokens.py # COLORS, SPACING, Style presets
├── controllers/
│ └── auth.py # AuthController.login(app, ...)
├── services/
│ └── auth.py # AuthService.authenticate(...)
├── storages/
│ ├── http.py # backend client (app.native.http)
│ └── prefs.py # device storage (app.native.storage)
├── schemas/
│ └── user.py # UserSchema(BaseModel)
└── utils/
└── format.py
A small app doesn't need all of it
A single-concern example (counter, stopwatch) fits in one app.py. The
layers emerge as the app grows — exactly like on the backend you omit
queue/ and tasks/ when you don't use them. Start simple; promote to a
folder when the file passes ~300 lines or the responsibility repeats.
A flow crossing the layers (login)¶
Watch login descend through the layers — each file with a single responsibility.
schemas/user.py — the shape of the data¶
from pydantic import BaseModel
class UserSchema(BaseModel):
"""Authenticated user returned by the backend.
Attributes:
id: The user's unique identifier.
name: The display name.
token: The bearer token for subsequent calls.
"""
id: str
name: str
token: str
storages/http.py — data access (the only I/O seam)¶
from tempest_core import App
from my_app.schemas import UserSchema
class AuthStorage:
"""Talks to the backend auth endpoints via the native HTTP capability."""
async def login(self, app: App, email: str, password: str) -> UserSchema:
"""Authenticate against the backend.
Args:
app: The application handle (exposes ``native.http``).
email: The user-supplied email.
password: The user-supplied password.
Returns:
The authenticated user payload.
Raises:
HTTPError: If the backend rejects the credentials.
"""
data = await app.native.http.post_json(
"/api/auth/login", {"email": email, "password": password}
)
return UserSchema(**data)
services/auth.py — domain rules¶
from tempest_core import App
from my_app.schemas import UserSchema
from my_app.storages import AuthStorage
class AuthService:
"""Business rules around authentication."""
def __init__(self, storage: AuthStorage) -> None:
"""Initialize the service.
Args:
storage: The data-access layer for auth.
"""
self._storage = storage
async def authenticate(self, app: App, email: str, password: str) -> UserSchema:
"""Validate input and authenticate the user.
Args:
app: The application handle.
email: The user email.
password: The user password.
Returns:
The authenticated user.
Raises:
ValueError: If email or password is empty.
"""
if not email or not password:
raise ValueError("Email and password are required.")
return await self._storage.login(app, email, password)
controllers/auth.py — intent → set_state¶
from tempest_core import App, Route
from my_app.core.state import AppState
from my_app.services import AuthService
class AuthController:
"""Turns user intent into state transitions."""
def __init__(self, service: AuthService) -> None:
"""Initialize the controller.
Args:
service: The auth domain service.
"""
self._service = service
async def login(self, app: App[AppState], email: str, password: str) -> None:
"""Handle a login attempt and update the state.
Args:
app: The application handle.
email: The submitted email.
password: The submitted password.
"""
app.set_state(lambda s: setattr(s, "loading", True))
try:
user = await self._service.authenticate(app, email, password)
except Exception as exc: # noqa: BLE001 — map domain error to the UI
def fail(s: AppState) -> None:
s.loading = False
s.error = str(exc)
app.set_state(fail)
return
def done(s: AppState) -> None:
s.loading = False
s.user = user
s.error = ""
app.set_state(done)
app.replace(Route(name="/home")) # navigate only on success
pages/login.py — composes components, wires the event¶
from tempest_core import App, Column, Widget
from my_app.components import EmailField, PasswordField, PrimaryButton
from my_app.controllers import AuthController
from my_app.core.state import AppState
from my_app.styles import SCREEN
def login_page(app: App[AppState], controller: AuthController) -> Widget:
"""Render the login screen.
Args:
app: The application handle.
controller: The auth controller wired by ``app.py``.
Returns:
The login screen widget tree.
"""
def set_email(v: str) -> None:
app.set_state(lambda s: setattr(s, "email", v))
def set_password(v: str) -> None:
app.set_state(lambda s: setattr(s, "password", v))
async def submit() -> None:
await controller.login(app, app.state.email, app.state.password)
return Column(
style=SCREEN,
children=[
EmailField(value=app.state.email, on_change=set_email, key="email"),
PasswordField(value=app.state.password, on_change=set_password, key="pw"),
PrimaryButton(label="Sign in", on_click=submit, key="submit"),
],
)
A field's key becomes the control's name
The renderer derives the name attribute of an <input>/<textarea>/
<select> from the widget's key. A control with neither name nor id is
an accessibility and autofill dead end — the browser cannot label it or offer
a saved value. So give every field a key, and prefer one that describes the
data (key="email", not key="input-3"): it shows up in the accessibility
tree.
A field's keyboard also becomes real behaviour:
KeyboardType.EMAIL/PHONE/URL set the control's type and its autofill
hint, NUMBER sets inputmode="numeric". Need a specific autocomplete
(new-password on a signup)? Pass it through
attrs={"autocomplete": "new-password"} — what the app declares beats the
derived value.
styles/tokens.py — style is a token, not an inline string¶
from tempest_core import Style
from tempest_core import Color, Edge
PRIMARY: Color = Color(r=63, g=81, b=181, a=1.0)
SCREEN: Style = Style(gap=12.0, padding=Edge.all(24))
app.py — the route picks the page, the wiring lives here¶
from tempest_core import App, Route, Widget
from my_app.controllers import AuthController
from my_app.core.state import AppState, make_state
from my_app.pages import home_page, login_page
from my_app.services import AuthService
from my_app.storages import AuthStorage
_auth = AuthController(AuthService(AuthStorage())) # composition root
def view(app: App[AppState]) -> Widget:
"""Resolve the current route to a page.
Args:
app: The application handle.
Returns:
The widget tree of the active route.
"""
if app.nav.top.name == "/home":
return home_page(app)
return login_page(app, _auth)
Where the wiring lives
The app.py is the composition root — the only place that builds
controllers, services and storages and wires them together. The lower layers
receive their dependencies through the constructor
(AuthController(AuthService(AuthStorage()))), analogous to FastAPI's
dependencies/ + Depends(). Nothing down there instantiates what's below
it inline.
Long work: dispatch is serial¶
One slow handler freezes that user's whole application
A session reads one event, waits for its handler, and only then reads the next. That is what keeps two quick edits of the same field in the order you typed them — and it is also what makes a slow handler stop everything: no other button responds, no field takes text, and even a "Cancel" is useless (its click queues behind the work it is supposed to interrupt).
It holds in both modes: Mode A reads events in series just like the server.
Long work is anything past a few seconds: model inference, report generation, a
slow upstream API, a large import. Move it out of the handler with spawn:
from tempest_core import App
from tempestweb.runtime import spawn
async def analyse(app: App[State]) -> None:
app.set_state(lambda s: setattr(s, "status", "reading the document…"))
async def work() -> None:
summary = await model.read(app.state.document) # minutes
app.set_state(lambda s: setattr(s, "summary", summary))
app.set_state(lambda s: setattr(s, "status", "done"))
spawn(work()) # the handler returns now; the session keeps serving
spawn is not asyncio.create_task: the task is held by the session (a
loose reference can be garbage-collected mid-flight) and cancelled when the
connection ends, so nothing outlives the user who closed the tab. Every
set_state from inside the work schedules the usual repaint, so you can show
progress as it runs.
Need handlers for different widgets to overlap?
create_app(..., concurrent_dispatch=True) dispatches each event as its own
task. Events for the same key keep their arrival order (a per-widget
lock), so typing never scrambles; handlers for different widgets overlap. It
is off by default because it lets two handlers mutate the state at once — the
app has to be written for that. A handler that raises in this mode is logged
and dropped instead of ending the connection.
Anti-patterns: how NOT to write garbage code¶
❌ Skipping layers
A page calling HTTP directly, or a component reading app.native.storage:
❌ Mutating the DOM / mutating app.state directly
app.state.value += 1 # ❌ no rebuild fires
document.getElementById("x")... # ❌ doesn't exist in Mode B
app.set_state(fn). The reconciler figures out the patch.
❌ I/O or await inside view()/page()
The view runs on every rebuild and must be synchronous and pure. I/O
lives in the service (called by an async controller), and the result comes
back in through set_state.
❌ Business logic in the page
Total computation, validation, flow decisions buried in the widget tree. That's a service. The page only composes and delegates.
❌ Loose inline style instead of a token
A repeated Style(...) scattered across pages becomes inconsistency.
Centralize it in styles/ and import the token. Style is a typed object,
not a CSS string.
❌ NotFoundError for an empty collection / list | None
Empty collections return [], never an exception; use
field(default_factory=list) in state. 404/error is for single resources
only. (Inherited from CLAUDE.md.)
✅ Use the ready-made components
tempestweb.components already ships EmailField, PasswordField,
LoginForm, SignupForm and validators. See
Ready-made components.
Typing and style (inherited from CLAUDE.md)¶
- Type everything —
view(app: App[State]) -> Widget, handlers/controllersasync def ... -> None, services returning schemas. mypy--strict. - Double quotes everywhere; Google-style docstrings in English.
- Re-export in each layer's
__init__.py(from my_app.services import AuthService), never import from a submodule directly. - Empty collections =
[];field(default_factory=list)in state.
Recap¶
- The cycle (
state → view → handlers → state) is the runtime contract; the layers organize the code that fills it. - Two axes: render (
routes → pages → components → styles) and behavior (controllers → services → storages/schemas), closing onset_stateover the state incore/. - Don't skip layers — a page doesn't talk to a storage; it goes through controller → service.
app.pyis the composition root (does the wiring); everything else receives dependencies through the constructor.- A small app fits in one file; the layers emerge as it grows.
Now see the patterns in practice in the Example gallery. 🚀