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.
Establish a baseline and inspect the feature
Commit the working application, including its current openapi.json, before
editing. For a fresh generated application with no Git repository:
git init
git add .
git commit -m "Baseline todo application"If the application already uses Git, use a commit containing the working application and its snapshots. Keep that commit for the final comparison:
BASE_REF=$(git rev-parse HEAD)
uv run tenchi map --feature todosRead the contracts, use cases, ports, and adapters the map identifies. The following steps make the boundary change explicit before implementing it.
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.
Preview the use-case boundary
Ask Tenchi to derive the use-case signature and planned files from the contract:
uv run tenchi make use-case todos complete_todo \
--from-contract app.features.todos.contracts:complete_todo_contract \
--dry-run --jsonThe preview validates the contract and lists the use-case file, test file, and wiring steps without writing them. The implementation below uses that boundary. Use the code below to create the files yourself, or generate them with the optional command before replacing their contents.
To record the intended structure and planned test, use this generation command before creating either file:
uv run tenchi make use-case todos complete_todo \
--from-contract app.features.todos.contracts:complete_todo_contract \
--plan .tenchi/changes/complete-todo.json \
--base-ref "$BASE_REF" --jsonThis creates the use-case file, failing test, and plan together. Replace the generated placeholders with the implementation and test below. See verify a generated change for what the plan verifies.
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, or replace its generated
contents:
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.
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
Secure the todos API continues this example through both
adapters, authentication wiring, and cross-user tests.
Test the use case directly
Create app/features/todos/tests/test_complete_todo.py, or replace its generated
contents:
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 checkFinish with a receipt against the commit recorded before editing. If you
opened a new terminal, set BASE_REF to that same full commit hash first:
uv run tenchi verify --base-ref "$BASE_REF" --jsonIf you generated the change plan, use this command instead:
uv run tenchi verify --base-ref "$BASE_REF" \
--change-plan .tenchi/changes/complete-todo.json --jsonThe receipt records project checks, architecture, and compatibility with the historical API, even after the current snapshot has been updated. Optional boundaries the application has not adopted are reported as not configured. Review the implementation and assertions too: these checks do not establish that every product requirement has been met.
Run uv run tenchi dev, create a todo, and call
PATCH /todos/{todo_id}/complete to see the result yourself.
Continue with Secure the todos API when the operation must act for a verified user, or databases and transactions when adapting this pattern to another datastore.