Skip to content

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:

async def login_page(app):
    data = await app.native.http.post_json(...)   # ❌ I/O in the page
The page fires intent → controller → service → storage. Every skipped jump is debt.

❌ Mutating the DOM / mutating app.state directly

app.state.value += 1                # ❌ no rebuild fires
document.getElementById("x")...     # ❌ doesn't exist in Mode B
Always 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 everythingview(app: App[State]) -> Widget, handlers/controllers async 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 on set_state over the state in core/.
  • Don't skip layers — a page doesn't talk to a storage; it goes through controller → service.
  • app.py is 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. 🚀