Clean Architecture with FastAPI: a pragmatic approach
How to structure a FastAPI project using Clean Architecture principles without over-engineering it.
Why Clean Architecture?
Most FastAPI tutorials show everything in a single file: routes, database calls, business logic. That works for a demo. It doesn’t work when the codebase grows or a second developer joins.
Clean Architecture is not about complexity — it’s about boundaries. Where does your business logic live? Is it testable without spinning up a database? Can you swap PostgreSQL for SQLite in tests?
If the answer to any of those is “hard”, this post is for you.
The layers
┌──────────────────────────────────────────┐
│ API (FastAPI routes) │ ← Delivery mechanism
├──────────────────────────────────────────┤
│ Application (Use Cases) │ ← Orchestrates business logic
├──────────────────────────────────────────┤
│ Domain (Entities) │ ← Pure Python, no dependencies
├──────────────────────────────────────────┤
│ Infrastructure (DB, Services) │ ← Implements interfaces
└──────────────────────────────────────────┘
Rule: dependencies point inward only. Domain knows nothing about the database. Application knows nothing about HTTP. Infrastructure implements interfaces defined in Domain.
Project structure
src/
├── api/
│ ├── v1/
│ │ └── users.py # FastAPI routers
│ └── deps.py # Dependency injection
│
├── application/
│ └── users/
│ ├── commands.py # Create, update, delete use cases
│ └── queries.py # Read use cases
│
├── domain/
│ └── users/
│ ├── entity.py # User domain model
│ ├── repository.py # Abstract interface
│ └── exceptions.py # Domain exceptions
│
└── infrastructure/
└── persistence/
├── sqlalchemy/
│ └── user_repo.py # SQLAlchemy implementation
└── models.py # ORM models
Domain layer: pure Python
# domain/users/entity.py
from dataclasses import dataclass
from datetime import datetime
@dataclass
class User:
id: str
email: str
name: str
created_at: datetime
is_active: bool = True
def deactivate(self) -> None:
"""Business rule: deactivated users cannot log in."""
self.is_active = False
# domain/users/repository.py
from abc import ABC, abstractmethod
from .entity import User
class UserRepository(ABC):
@abstractmethod
async def find_by_id(self, user_id: str) -> User | None: ...
@abstractmethod
async def find_by_email(self, email: str) -> User | None: ...
@abstractmethod
async def save(self, user: User) -> User: ...
@abstractmethod
async def delete(self, user_id: str) -> None: ...
Application layer: use cases
# application/users/commands.py
from dataclasses import dataclass
from domain.users.entity import User
from domain.users.repository import UserRepository
from domain.users.exceptions import UserAlreadyExistsError
import uuid
from datetime import datetime
@dataclass
class CreateUserCommand:
email: str
name: str
class CreateUserUseCase:
def __init__(self, repo: UserRepository) -> None:
self._repo = repo
async def execute(self, cmd: CreateUserCommand) -> User:
existing = await self._repo.find_by_email(cmd.email)
if existing:
raise UserAlreadyExistsError(cmd.email)
user = User(
id=str(uuid.uuid4()),
email=cmd.email,
name=cmd.name,
created_at=datetime.utcnow(),
)
return await self._repo.save(user)
Infrastructure layer: SQLAlchemy
# infrastructure/persistence/sqlalchemy/user_repo.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from domain.users.entity import User
from domain.users.repository import UserRepository
from ..models import UserORM
class SQLAlchemyUserRepository(UserRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def find_by_id(self, user_id: str) -> User | None:
result = await self._session.get(UserORM, user_id)
return result.to_domain() if result else None
async def find_by_email(self, email: str) -> User | None:
stmt = select(UserORM).where(UserORM.email == email)
result = await self._session.execute(stmt)
orm = result.scalar_one_or_none()
return orm.to_domain() if orm else None
async def save(self, user: User) -> User:
orm = UserORM.from_domain(user)
self._session.add(orm)
await self._session.flush()
return user
async def delete(self, user_id: str) -> None:
orm = await self._session.get(UserORM, user_id)
if orm:
await self._session.delete(orm)
API layer: thin and clean
# api/v1/users.py
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from application.users.commands import CreateUserCommand, CreateUserUseCase
from domain.users.exceptions import UserAlreadyExistsError
from api.deps import get_create_user_use_case
router = APIRouter(prefix="/users", tags=["users"])
class CreateUserRequest(BaseModel):
email: str
name: str
class UserResponse(BaseModel):
id: str
email: str
name: str
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
body: CreateUserRequest,
use_case: CreateUserUseCase = Depends(get_create_user_use_case),
):
try:
user = await use_case.execute(CreateUserCommand(email=body.email, name=body.name))
return UserResponse(id=user.id, email=user.email, name=user.name)
except UserAlreadyExistsError as e:
raise HTTPException(status_code=409, detail=str(e))
Testing without a database
This is where the payoff becomes obvious. The domain and application layers are plain Python — you can test them with a simple in-memory repository:
# tests/application/test_create_user.py
import pytest
from application.users.commands import CreateUserCommand, CreateUserUseCase
from domain.users.repository import UserRepository
from domain.users.entity import User
from domain.users.exceptions import UserAlreadyExistsError
class InMemoryUserRepository(UserRepository):
def __init__(self):
self._users: dict[str, User] = {}
async def find_by_id(self, user_id: str) -> User | None:
return self._users.get(user_id)
async def find_by_email(self, email: str) -> User | None:
return next((u for u in self._users.values() if u.email == email), None)
async def save(self, user: User) -> User:
self._users[user.id] = user
return user
async def delete(self, user_id: str) -> None:
self._users.pop(user_id, None)
@pytest.mark.asyncio
async def test_create_user_success():
repo = InMemoryUserRepository()
use_case = CreateUserUseCase(repo)
user = await use_case.execute(CreateUserCommand(email="a@b.com", name="Alice"))
assert user.email == "a@b.com"
assert user.is_active is True
@pytest.mark.asyncio
async def test_create_user_duplicate_raises():
repo = InMemoryUserRepository()
use_case = CreateUserUseCase(repo)
cmd = CreateUserCommand(email="a@b.com", name="Alice")
await use_case.execute(cmd)
with pytest.raises(UserAlreadyExistsError):
await use_case.execute(cmd)
Fast, minimal, no database, no HTTP server. That’s the point.
Pragmatic notes
- Don’t apply this to every endpoint. CRUD with no business logic doesn’t need all these layers.
- Start with a simple structure and extract layers when the logic grows.
- The test quality is the real metric, not the folder structure.
Clean Architecture is a tool. Use it where it creates clarity, not everywhere.