6. Growing into a project

One file was perfect to learn with. Real applications deserve a structure where each module has one job. Here is the same guestbook as a project:

guestbook/
├── app.py               # the composition root: builds the app from its parts
├── settings.py          # settings from the environment
├── models/
│   ├── database.py      # the engine and the declarative base
│   └── entry.py         # the table, and the shapes that cross the API and forms
├── services/
│   └── guestbook.py     # business logic (+ the JSON API)
├── views/
│   ├── styles/          # the theme and shared styles
│   ├── components/
│   │   └── entry_card.py
│   └── pages/
│       └── home.py      # the page, declared on a Router
└── tests/
    ├── conftest.py
    └── test_guestbook.py

Pages on a router

A module can’t use @app.page without importing the app, and the app imports the pages: that’s a circular import. A Router records pages instead, and the app includes it:

@router.page("/")
class Home(Page):
    title = "Guestbook"
    entries = State(factory=list)

    async def mount(self):
        await self.reload()

    async def reload(self):
        self.entries = await self.use(GuestbookService).entries()

    async def sign(self, form: SignForm):
        await self.use(GuestbookService).sign(form.name, form.message)
        await self.reload()

    def render(self):
        return Container(
            Row(
                Heading("Guestbook", level=1),
                Button("Dark mode", variant="outline", size="sm", on_click=js.toggle_theme()),
                justify="between",
            ),
            Row(
                Card(
                    Form(
                        Input("Your name", name="name", required=True),
                        Textarea("Message", name="message", required=True, rows=4),
                        Button("Sign the guestbook", type="submit", full_width=True),
                        on_submit=self.sign,
                        key=f"form-{len(self.entries)}",
                    ),
                    style=Style(width=320, shrink=0, viewport={"mobile": Style(width="100%")}),
                ),
                Column(
                    Grid(*[EntryCard(entry=e, key=e.id) for e in self.entries], min_column_width=240),
                    Text("API: /api/guestbook/entries · interactive docs: /api/docs", muted=True, size="sm"),
                    style=Style(grow=1, min_width=0),
                ),
                align="start",
                collapse="mobile",
            ),
            tag="main",
            max_width=1000,
            style=Style(layout="vertical", gap="lg", padding_y="xl"),
        )

The composition root

app.py is the only place that knows how the pieces fit together:

app.py
"""The composition root: build the app from its parts.

Run:  python app.py --dev     # hot reload, readable errors
      python app.py --prod    # production server
"""

from rational import App

from models import create_tables, database
from services import GuestbookService
from views.pages import router

app = App(title="Guestbook", theme="soft", database=database)
app.include(router)
app.service(GuestbookService)
app.on_startup(create_tables)

if __name__ == "__main__":
    app.run()

Settings

Configuration comes from the environment, with safe defaults for development:

settings.py
"""Settings, read once from the environment."""

import os
from pathlib import Path

PROJECT_DIR = Path(__file__).resolve().parent

DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite+aiosqlite:///{(PROJECT_DIR / 'guestbook.db').as_posix()}")

The other modules

models/database.py
"""One engine for the app, and the declarative base for its tables."""

from sqlalchemy.orm import DeclarativeBase

from rational.backend import Database

import settings


class Base(DeclarativeBase):
    """Any declarative base works; ``rational.backend.Model`` is a ready-made one."""


database = Database(settings.DATABASE_URL)


async def create_tables() -> None:
    """Development convenience: use Alembic migrations in production."""
    await database.create_all(Base)
models/entry.py
"""The entry table, and the shapes that cross the API and forms."""

from datetime import UTC, datetime

from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column

from .database import Base


class EntryRow(Base):
    __tablename__ = "entries"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(40))
    message: Mapped[str] = mapped_column(String(280))
    created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))


class Entry(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str
    message: str


class SignForm(BaseModel):
    name: str = Field(min_length=1, max_length=40)
    message: str = Field(min_length=1, max_length=280)
services/guestbook.py
"""Read and sign the guestbook; the ``@remote`` methods are also a JSON API."""

from sqlalchemy import select

from rational import FieldError
from rational.backend import Service, remote

from models import Entry, EntryRow


class GuestbookService(Service):
    """Read and sign the guestbook."""

    @remote(method="GET")
    async def entries(self) -> list[Entry]:
        """Every entry, newest first."""
        async with self.db.session() as db:
            rows = await db.scalars(select(EntryRow).order_by(EntryRow.id.desc()))
            return [Entry.model_validate(row) for row in rows]

    @remote
    async def sign(self, name: str, message: str) -> Entry:
        """Add an entry."""
        if "http://" in message or "https://" in message:
            raise FieldError("message", "Links are not allowed")
        async with self.db.transaction() as db:
            row = EntryRow(name=name.strip(), message=message.strip())
            db.add(row)
            await db.flush()
            return Entry.model_validate(row)

Run it from the project folder, exactly as before:

$ cd guestbook
$ python app.py --dev

Every example in the repository follows this layout; see Examples.

Next: 7. Testing.