Build a feature end to end
Add a PATCH /todos/{todo_id}/complete operation to the generated application.
The change crosses every layer involved in persisted behavior: the HTTP
contract, the repository port, the memory and SQLite adapters, the use case,
the route, a direct test, an HTTP test, and the OpenAPI snapshot.
Complete Build your first Tenchi app first, and run every command in this guide from the application root.
Declare the input and the HTTP operation
Add the path model to app/features/todos/schemas.py:
class CompleteTodoParams(BaseModel):
todo_id: strReplace the imports in app/features/todos/contracts.py with:
from pydantic import BaseModel, Field
from tenchi.contracts import contract
from app.shared.errors import todo_not_found
from .schemas import CompleteTodoParams, CreateTodo, TodoAdd the operation beneath the existing contracts:
complete_todo_contract = contract(
method="PATCH",
path="/todos/{todo_id}/complete",
params=CompleteTodoParams,
response=Todo,
errors=(todo_not_found,),
summary="Complete a todo",
tags=("todos",),
)The contract says that the operation accepts one validated path parameter,
returns a Todo, and may expose the existing TODO_NOT_FOUND application
error. It does not yet implement or register the operation.
Extend the repository port
Add the new capability to TodoRepository in
app/features/todos/ports.py:
class TodoRepository(Protocol):
async def create(self, *, title: str) -> Todo: ...
async def list(self) -> list[Todo]: ...
async def complete(self, todo_id: str) -> Todo | None: ...Returning None keeps storage concerns out of the public error model. The use
case will translate that result into the declared application error.
Implement both adapters
Add this method to MemoryTodoRepository in
app/infra/memory_todo_repository.py:
async def complete(self, todo_id: str) -> Todo | None:
todo = self._todos.get(todo_id)
if todo is None:
return None
completed = todo.model_copy(update={"completed": True})
self._todos[todo_id] = completed
return completedThe memory adapter gives direct tests a deterministic implementation without a database.
Add the corresponding method to SqliteTodoRepository in
app/infra/sqlite_todo_repository.py:
async def complete(self, todo_id: str) -> Todo | None:
await self._connection.execute(
"UPDATE todos SET completed = 1 WHERE id = ?",
(todo_id,),
)
cursor = await self._connection.execute(
"SELECT id, title, completed FROM todos WHERE id = ?",
(todo_id,),
)
row = await cursor.fetchone()
return _row_to_todo(row) if row is not None else NoneThe request-scoped context commits this update only after the use case and response validation succeed. A failure rolls the transaction back.
Implement the use case
Create app/features/todos/use_cases/complete_todo.py:
from tenchi.errors import AppError
from app.server.context import AppContext
from app.shared.errors import todo_not_found
from ..schemas import CompleteTodoParams, Todo
async def complete_todo(
params: CompleteTodoParams,
context: AppContext,
) -> Todo:
todo = await context.todos.complete(params.todo_id)
if todo is None:
raise AppError(todo_not_found, details={"todo_id": params.todo_id})
return todoThe use case raises the application error without constructing an HTTP
response. You can call it directly from a test or script. Tasks, tools, jobs,
and execute() supply request and context, so this function's params
argument needs a small input adapter when you
add one of those entrypoints.
uv run tenchi make use-case todos complete_todo --from-contract app.features.todos.contracts:complete_todo_contract --dry-run previews a
use case and a failing test derived from the contract. Add --plan when a
coding agent should prove the generated change was completed; see verify a
generated change.
The generated starter has no authenticated subject in AppContext, so every
caller can complete every todo. Do not infer ownership from todo_id. When
the application gains users or tenants, enrich the context in an
authentication hook and add owner-scoped repository methods. The
authentication guide shows that flow.
Test the use case directly
Create app/features/todos/tests/test_complete_todo.py:
from app.features.todos.schemas import CompleteTodoParams, CreateTodo
from app.features.todos.use_cases.complete_todo import complete_todo
from app.features.todos.use_cases.create_todo import create_todo
from app.infra.memory_todo_repository import MemoryTodoRepository
from app.server.context import AppContext
async def test_complete_todo() -> None:
context = AppContext(todos=MemoryTodoRepository())
created = await create_todo(CreateTodo(title="Buy milk"), context)
completed = await complete_todo(
CompleteTodoParams(todo_id=created.id),
context,
)
assert completed.id == created.id
assert completed.completed is TrueRun it:
uv run pytest app/features/todos/tests/test_complete_todo.pyThe test exercises the use case through the feature-owned port with the memory adapter. It does not start a server or open SQLite.
Bind the route
In app/features/todos/routes.py, add complete_todo_contract to the contract
imports and complete_todo to the use-case imports:
from .contracts import (
CreatedTodoHeaders,
complete_todo_contract,
create_todo_contract,
list_todos_contract,
)
from .use_cases.complete_todo import complete_todoThen add the binding to the existing route_group():
routes = route_group(
route(
create_todo_contract,
create_todo,
response_headers=create_todo_headers,
),
route(list_todos_contract, list_todos),
route(complete_todo_contract, complete_todo),
)Importing the module now checks that the function accepts context and that
its params and return annotations match the contract. Context types remain
application-owned and are checked by your type checker.
Exercise the HTTP boundary
In tests/test_http.py, add the new contract and parameter model to the
existing imports:
from app.features.todos.contracts import (
complete_todo_contract,
create_todo_contract,
)
from app.features.todos.schemas import CompleteTodoParams, CreateTodoThen add an integration test that uses the SQLite-backed application:
async def test_complete_todo_persists(tmp_path: Path) -> None:
database_path = str(tmp_path / "todos.db")
async with open_client(build_app(database_path)) as client:
created = await client.call(
create_todo_contract,
request=CreateTodo(title="Buy milk"),
)
completed = await client.call(
complete_todo_contract,
params=CompleteTodoParams(todo_id=created.id),
)
async with open_http(build_app(database_path)) as http:
listed = await http.get("/todos")
assert completed.id == created.id
assert completed.completed is True
assert listed.json() == [completed.model_dump()]This test crosses route dispatch, request validation, the SQLite adapter, transaction commit, result validation, and HTTP serialization. The direct test remains the faster place for behavior and failure cases.
See what the contract bought you
The contract is also the source of the published OpenAPI document, so Tenchi can tell you what this change means for existing callers. Compare the composed API with the committed snapshot:
uv run tenchi openapi --diff openapi.jsonThe report lists one additive change: a new operation that no current caller depends on. Had you renamed a field or removed a response, the same command would report a breaking change before anything shipped. Accept the additive change by writing the new snapshot, then run the complete check:
uv run tenchi openapi --write openapi.json
uv run tenchi checkRun uv run tenchi dev, create a todo, and call
PATCH /todos/{todo_id}/complete to see the result yourself.
Continue with authentication and authorization when the operation must act for a verified user, or databases and transactions when adapting this pattern to another datastore.