Write use cases and ports
Tenchi keeps application behavior independent of HTTP and concrete infrastructure.
Define an application port
The feature owns interfaces for the capabilities it needs:
from typing import Protocol
from .schemas import Todo
class TodoRepository(Protocol):
async def create(self, *, title: str) -> Todo: ...
async def get(self, todo_id: str) -> Todo | None: ...
async def list(self) -> list[Todo]: ...Nothing in this interface selects SQLAlchemy, PostgreSQL, an external service, or a memory dictionary. Infrastructure makes that decision later.
Carry dependencies in context
from dataclasses import dataclass
from app.features.todos.ports import TodoRepository
@dataclass(frozen=True, slots=True)
class AppContext:
todos: TodoRepositoryThe context is ordinary application data. It can also carry authenticated
identity or request-scoped ports. Frozen contexts make enrichment explicit:
an authentication hook returns dataclasses.replace(context, user=user)
instead of mutating shared state.
Write a use case
from app.server.context import AppContext
from ..schemas import CreateTodo, Todo
async def create_todo(request: CreateTodo, context: AppContext) -> Todo:
return await context.todos.create(title=request.title)Use cases are plain async functions. A route-bound function must accept
context and every input source its contract declares: request, params,
query, or headers. It may omit input sources that the contract does not
declare. The input and return annotations must match the contract; your
application supplies and type-checks the context.
Functions that accept only request and context can also bind directly to
jobs, tasks, and tools. When a function requires HTTP-specific arguments, use
an input adapter for those entrypoints.
The return value is application data. HTTP headers and status selection remain at the route and response boundary.
Implement an adapter
from uuid import uuid4
from app.features.todos.schemas import Todo
class MemoryTodoRepository:
def __init__(self) -> None:
self._todos: dict[str, Todo] = {}
async def create(self, *, title: str) -> Todo:
todo = Todo(id=uuid4().hex, title=title, completed=False)
self._todos[todo.id] = todo
return todo
async def get(self, todo_id: str) -> Todo | None:
return self._todos.get(todo_id)
async def list(self) -> list[Todo]:
return list(self._todos.values())Static typing verifies that the adapter satisfies TodoRepository when it is
returned from an explicitly annotated wiring function.
Test without HTTP
async def test_create_todo() -> None:
repository = MemoryTodoRepository()
context = AppContext(todos=repository)
todo = await create_todo(CreateTodo(title="Buy milk"), context)
assert todo.title == "Buy milk"
assert await repository.get(todo.id) == todoThis is the default testing level for business behavior. Add HTTP tests for the boundary guarantees that only the composed application can provide.