Skip to content

Bind routes and compose the server

Routes bind contracts to use cases. The server composes routes with concrete dependencies and ASGI concerns.

Bind a route

For the todos contract from Declare an HTTP contract, provide the Location header as well as the use case's response body:

# app/features/todos/routes.py
from tenchi.routes import route, route_group

from .contracts import CreatedTodoHeaders, create_todo_contract
from .schemas import Todo
from .use_cases.create_todo import create_todo


def create_todo_headers(todo: Todo) -> CreatedTodoHeaders:
    return CreatedTodoHeaders(Location=f"/todos/{todo.id}")


routes = route_group(
    route(
        create_todo_contract,
        create_todo,
        response_headers=create_todo_headers,
    ),
)

route() checks the use-case signature immediately. A missing input, wrong parameter name, incompatible annotation, invalid header projector, or invalid presenter is a composition error.

If a contract does not declare response_headers=, the binding only needs route(contract, use_case).

Compose route groups

from tenchi.routes import route_group

from app.features.notes.routes import routes as note_routes
from app.features.todos.routes import routes as todo_routes


api_routes = route_group(
    todo_routes,
    note_routes,
    prefix="/api",
)

Groups flatten to an immutable route collection. errors= declares an application error across the group. Prefixes and shared errors create composition, not hidden runtime behavior.

Create the ASGI application

import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from starlette.applications import Starlette
from tenchi.server import create_app

from app.infra.port_wiring import ensure_schema, open_todo_repository
from app.server.context import AppContext
from app.server.routes import routes

DATABASE_PATH = os.environ.get("APP_DATABASE", "app.db")


def build_app(database_path: str = DATABASE_PATH) -> Starlette:
    @asynccontextmanager
    async def lifespan() -> AsyncIterator[str]:
        await ensure_schema(database_path)
        yield database_path

    @asynccontextmanager
    async def create_context(path: str) -> AsyncIterator[AppContext]:
        async with open_todo_repository(path) as todos:
            yield AppContext(todos=todos)

    return create_app(
        routes=routes,
        context_factory=create_context,
        lifespan=lifespan,
    )


app = build_app()

This matches the generated application's resource model. The lifespan prepares the database schema once at startup. Each request enters its own repository scope, which can commit on success, roll back on failure, and close before Tenchi finalizes the response. app.server.routes:routes composes the API, OpenAPI, Swagger UI, and health routes at the composition root.

create_app() returns a Starlette application. Route templates are checked during composition and matched by shape at runtime; the rules are listed under Constraints.

Lifespan and request scopes

An async context manager can create process-scoped resources once:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager


@asynccontextmanager
async def lifespan() -> AsyncIterator[Database]:
    database = await Database.connect()
    try:
        yield database
    finally:
        await database.close()


def create_context(database: Database) -> AppContext:
    return AppContext(todos=SqlTodoRepository(database))


app = create_app(
    routes=routes,
    lifespan=lifespan,
    context_factory=create_context,
)

The context factory may return a context directly, await one, or return an async context manager. The final form creates request-scoped resources and guarantees cleanup before the response is finalized.

Hooks, middleware, and observers

Configure middleware and deployment

Configure Starlette middleware and your ASGI server directly at the composition root. See deployment for process and proxy settings.

Constraints