🚀
3. Layered Architecture
++++
Engineering
Apr 2026×13 min read

A concrete walkthrough of how Clean Architecture plays out in this FastAPI template — what each layer owns, what it forbids, and why those boundaries matter.

3. Layered Architecture

Driptanil Datta
Driptanil DattaSoftware Developer

The Central Rule

Every layer in this template has exactly one job, and violations of that rule are treated as bugs — not style preferences. The flow is fixed:

Router → Service → Repository → Persistence

A router never touches the database. A service never imports SQLAlchemy. A repository never raises HTTP exceptions. The moment you blur those lines, the thing you gave up is testability and the ability to change one layer without touching the others.

The Router: Only HTTP

The router's job is to translate HTTP into function calls and function calls back into HTTP. That means: validate the incoming shape, call a service method, map the result to a response schema, and set the correct status code. Nothing else.

Here is the list endpoint in full:

# server/api/src/routers/todo_router.py
@router.get("/", response_model=list[TodoResponse])
async def list_todos(service: TodoServiceDep):
    todos = await service.list_todos()
    return [TodoResponse.from_domain(t) for t in todos]

Four lines. It doesn't know how todos are stored, what SQL was run, or whether the data came from a real database or a test fixture. It just calls the service and maps the result.

The Service: Business Logic Lives Here

The service layer is where domain transitions happen. When you mark a todo as completed, the service is responsible for fetching it, applying the state change, updating the timestamp, and persisting the result. The router just delegates:

# server/api/src/services/todo_service.py
async def mark_completed(self, todo_id: UUID) -> Todo | None:
    todo = await self.repository.get_by_id(todo_id)
    if not todo:
        return None
 
    updated_todo = Todo(
        id=todo.id,
        title=todo.title,
        description=todo.description,
        status=TodoStatus.COMPLETED,
        created_at=todo.created_at,
        updated_at=datetime.now(UTC),
    )
    await self.repository.update(updated_todo)
    return updated_todo

Notice that TodoService never imports AsyncSession, never runs SQL, and never raises HTTPException. It returns a domain object or None — the router decides what HTTP response that means.

What Happens When You Break the Boundary

This is a realistic version of the mistake. It creeps in slowly — a deadline, a "quick fix", an "it's just one line":

# What not to do: router reaching through the service into the DB
@router.patch("/{todo_id}/complete")
async def bad_complete(todo_id: UUID, request: Request):
    repo = request.app.state.todo_service.repository
    entity = await repo.session.get(TodoEntity, str(todo_id))
    entity.status = TodoStatus.COMPLETED
    await repo.session.commit()
    return entity  # leaking ORM entity to the client

This has several problems. The router now knows about the repository structure, the ORM entity type, and the commit pattern. If the repository changes — say, the session management moves — this route breaks silently. And return entity leaks an SQLAlchemy model to FastAPI's serializer, which either crashes or exposes internal fields you didn't intend to expose.

The Correct Complete Endpoint

The router gets a service via dependency injection, calls a named method with a clear contract, and translates the result to HTTP:

# server/api/src/routers/todo_router.py
@router.patch("/{todo_id}/complete", response_model=TodoResponse)
async def complete_todo(todo_id: UUID, service: TodoServiceDep):
    todo = await service.mark_completed(todo_id)
    if not todo:
        raise HTTPException(status_code=404, detail="Todo not found")
    return TodoResponse.from_domain(todo)

The TodoServiceDep alias in server/api/src/dependencies.py abstracts the injection. In tests, you can substitute a mock service. The router doesn't change. The TodoResponse.from_domain(todo) call ensures only the fields defined in the response schema make it to the client.

Why This Separation Pays Off

When each layer has a single responsibility, you can swap, test, and extend them independently. Want to move from SQLite to Postgres? Change the repository layer only. Want to add caching? Add it in the service, invisible to the router. Want to test business logic without spinning up a database? Mock the repository and test the service in isolation.

The architecture in this template is not academic. It's the exact structure that makes this codebase testable, refactorable, and safe to grow. The server/api/src/routers, services, and repositories directories each enforce their own boundary, and reading any file in them should confirm that.

Drip

Driptanil Datta

Software Developer

Building full-stack systems, one commit at a time. This blog is a centralized learning archive for developers.

Legal Notes
Disclaimer

The content provided on this blog is for educational and informational purposes only. While I strive for accuracy, all information is provided "as is" without any warranties of completeness, reliability, or accuracy. Any action you take upon the information found on this website is strictly at your own risk.

Copyright & IP

Certain technical content, interview questions, and datasets are curated from external educational sources to provide a centralized learning resource. Respect for original authorship is maintained; no copyright infringement is intended. All trademarks, logos, and brand names are the property of their respective owners.

System Operational

© 2026 Driptanil Datta. All rights reserved.