Skip to content

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: str

Replace 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, Todo

Add 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 completed

The 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 None

The 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 todo

The 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.

Generate the use case instead

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.

No owner scope yet

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 True

Run it:

uv run pytest app/features/todos/tests/test_complete_todo.py

The 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_todo

Then 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, CreateTodo

Then 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.json

The 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 check

Run 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.