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
hooks=run after route matching and context creation, before input validation. They may return an enriched context and are the right boundary for authentication.webhooks=binds exact-body verifiers to contracts markedwebhook=True. Verifiers run after ordinary hooks and body size/media checks, but before request parsing.middleware=accepts ordinary StarletteMiddlewarevalues for CORS, sessions, trusted hosts, compression, or app-specific ASGI behavior.observers=receive immutableRequestOutcomevalues after a matched request finalizes. Each outcome records its UTCcompleted_atbefore observer delivery. Observer failures are logged and cannot change the response.use_case_observers=receive immutableUseCaseOutcomevalues after the request scope closes, but only when the matched use case ran. The same observer contract works withexecute();completed_atrecords when the use-case call returned or raised, before later context cleanup.
Configure Starlette middleware and your ASGI server directly at the composition root. See deployment for process and proxy settings.
Constraints
create_app()rejects duplicate or equivalent route templates and invalid context-factory shapes during composition.- Operations on one path shape must use the same path-parameter names.
- Matching is independent of declaration order. Literal and constrained paths take precedence over broader parameterized paths.
- A
GETcontract handlesHEADautomatically unless an explicitHEADcontract matches. - Contract paths are exact. If
/todosis declared,/todos/reaches the structured framework 404 response instead of redirecting. Declare both spellings only when the API intends to support both. - Observer failures are logged and cannot change the response or the use-case result.