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.

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 todos

Read 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: 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.

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

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

Generate with a change plan (optional)

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" --json

This 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 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, 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 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.

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

Finish 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" --json

If you generated the change plan, use this command instead:

uv run tenchi verify --base-ref "$BASE_REF" \
  --change-plan .tenchi/changes/complete-todo.json --json

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