A controller is a FastAPI router that only handles HTTP concerns: parsing the request, calling a service, and returning a response. It contains zero business logic. Think of it as the front door that accepts packages and hands them to the right department.
# ✅ Controller: only HTTP + delegation — no SQL, no business logic from fastapi import APIRouter, Depends, HTTPException, status from app.schemas import UserCreate, UserOut from app.services.user_service import UserService from app.dependencies import get_user_service router = APIRouter(prefix="/users", tags=["users"]) @router.post("/", response_model=UserOut, status_code=201) async def create_user( body: UserCreate, svc: UserService = Depends(get_user_service), ): # 1. Validate input (Pydantic already did this) # 2. Call service (business logic lives there) # 3. Return response user = await svc.create(body) return user # FastAPI serialises via response_model @router.get("/{user_id}", response_model=UserOut) async def get_user(user_id: int, svc: UserService = Depends(get_user_service)): user = await svc.get_by_id(user_id) if not user: raise HTTPException(status_code=404, detail="User not found") return user
The service layer is where all business logic lives. Services are plain Python classes that know about business rules — like "a user must have a unique email" or "an order can only be cancelled if it is still pending." They don't know about HTTP or SQL directly; they speak to repositories.
from app.repositories.user_repo import UserRepository from app.schemas import UserCreate from app.models import User from app.core.security import hash_password from app.core.exceptions import DuplicateEmailError class UserService: def __init__(self, repo: UserRepository): self.repo = repo async def create(self, data: UserCreate) -> User: # Business rule: email must be unique existing = await self.repo.find_by_email(data.email) if existing: raise DuplicateEmailError(data.email) # Business rule: password must be hashed before storage hashed = hash_password(data.password) return await self.repo.create(email=data.email, hashed_password=hashed) async def get_by_id(self, user_id: int) -> User | None: return await self.repo.find_by_id(user_id)
AsyncSession directly. This makes them trivial to unit-test: just pass in a mock repository.A repository is the only layer that talks to the database. It wraps raw SQLAlchemy calls behind a clean interface. If you swap PostgreSQL for MongoDB tomorrow, only the repository changes — services and controllers are untouched.
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.models import User class UserRepository: def __init__(self, session: AsyncSession): self.session = session async def find_by_id(self, user_id: int) -> User | None: result = await self.session.execute( select(User).where(User.id == user_id) ) return result.scalar_one_or_none() async def find_by_email(self, email: str) -> User | None: result = await self.session.execute( select(User).where(User.email == email) ) return result.scalar_one_or_none() async def create(self, *, email: str, hashed_password: str) -> User: user = User(email=email, hashed_password=hashed_password) self.session.add(user) await self.session.commit() await self.session.refresh(user) return user
Now wire everything together with a dependency:
from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_session from app.repositories.user_repo import UserRepository from app.services.user_service import UserService async def get_user_service(session: AsyncSession = Depends(get_session)) -> UserService: repo = UserRepository(session) return UserService(repo) # FastAPI's DI chain: Request → get_session → UserRepository → UserService → Controller
In DDD, an Entity is an object defined by its identity, not its attributes. A User with id=42 is the same user even if their name or email changes. Entities have a lifecycle, can change state, and are tracked by a unique ID.
from dataclasses import dataclass, field from datetime import datetime from uuid import UUID, uuid4 @dataclass class User: """Domain Entity — has identity (id), can change over time.""" email: str hashed_password: str id: UUID = field(default_factory=uuid4) is_active: bool = True created_at: datetime = field(default_factory=datetime.utcnow) def deactivate(self) -> None: """Business rule: users can be deactivated but not deleted.""" self.is_active = False def change_email(self, new_email: str) -> None: """Business rule: email must be non-empty.""" if not new_email.strip(): raise ValueError("Email cannot be empty") self.email = new_email
User is a plain Python dataclass — no SQLAlchemy imports, no FastAPI. This is what DDD calls a rich domain model: the domain object enforces its own rules.A Value Object has no identity. Two Money(100, "USD") objects are equal regardless of being different instances in memory. Value objects are always immutable. They carry meaning through their values, not an ID.
from dataclasses import dataclass from decimal import Decimal @dataclass(frozen=True) # frozen=True makes it immutable + hashable class Money: amount: Decimal currency: str def __post_init__(self): if self.amount < 0: raise ValueError("Money cannot be negative") def add(self, other: "Money") -> "Money": if self.currency != other.currency: raise ValueError("Cannot add different currencies") return Money(self.amount + other.amount, self.currency) @dataclass(frozen=True) class Email: value: str def __post_init__(self): if "@" not in self.value: raise ValueError("Invalid email") # Usage price = Money(Decimal("9.99"), "USD") tax = Money(Decimal("1.00"), "USD") total = price.add(tax) # Money(10.99, "USD") — new object, originals unchanged
An Aggregate is a cluster of related entities and value objects that are treated as a single unit for data changes. The Aggregate Root is the only entry point — you never modify child objects directly from outside the aggregate.
order.items[0].quantity = 5 directly. Always go through the aggregate root: order.update_item_quantity(item_id, 5). This keeps all business rules centralised.from dataclasses import dataclass, field from uuid import UUID, uuid4 from enum import Enum from .value_objects import Money class OrderStatus(Enum): PENDING = "pending" CONFIRMED = "confirmed" CANCELLED = "cancelled" @dataclass class OrderItem: # Child entity (only accessible via Order) product_id: UUID quantity: int unit_price: Money id: UUID = field(default_factory=uuid4) @dataclass class Order: # Aggregate Root customer_id: UUID id: UUID = field(default_factory=uuid4) status: OrderStatus = OrderStatus.PENDING items: list[OrderItem] = field(default_factory=list) def add_item(self, product_id: UUID, qty: int, price: Money) -> None: """Business rule: can only add items to a PENDING order.""" if self.status != OrderStatus.PENDING: raise ValueError("Cannot modify a non-pending order") self.items.append(OrderItem(product_id, qty, price)) def confirm(self) -> None: """Business rule: must have items to confirm.""" if not self.items: raise ValueError("Cannot confirm an empty order") self.status = OrderStatus.CONFIRMED def cancel(self) -> None: """Business rule: only PENDING or CONFIRMED orders can be cancelled.""" if self.status == OrderStatus.CANCELLED: raise ValueError("Order already cancelled") self.status = OrderStatus.CANCELLED @property def total(self) -> Money: if not self.items: return Money(0, "USD") result = self.items[0].unit_price for item in self.items[1:]: result = result.add(item.unit_price) return result
The DDD folder structure organises code by domain first, then by type:
Clean Architecture was introduced by Robert C. Martin (Uncle Bob). The core rule: dependencies always point inward. The Domain Layer (centre) has zero dependencies on any other layer, framework, or library.
The Domain Layer contains:
from abc import ABC, abstractmethod from uuid import UUID # ⬆ NO FastAPI, NO SQLAlchemy, NO third-party lib imports class UserRepositoryInterface(ABC): """Domain-defined contract. Infrastructure must implement this.""" @abstractmethod async def find_by_id(self, user_id: UUID) -> "User" | None: ... @abstractmethod async def save(self, user: "User") -> None: ... class EmailServiceInterface(ABC): @abstractmethod async def send_welcome(self, to: str) -> None: ...
The Application Layer orchestrates use cases. It knows about domain objects and domain interfaces. It does not know about HTTP, SQL, or any specific framework. Each use case is typically one class or function.
from dataclasses import dataclass from app.domain.user import User from app.domain.interfaces import UserRepositoryInterface, EmailServiceInterface from app.domain.value_objects import Email from app.domain.exceptions import DuplicateEmailError @dataclass class RegisterUserCommand: # Input DTO email: str password: str @dataclass class RegisterUserResult: # Output DTO user_id: str email: str class RegisterUserUseCase: def __init__(self, user_repo: UserRepositoryInterface, email_svc: EmailServiceInterface): self.user_repo = user_repo self.email_svc = email_svc async def execute(self, cmd: RegisterUserCommand) -> RegisterUserResult: # 1. Validate (domain rule) email = Email(cmd.email) # raises ValueError if invalid # 2. Check uniqueness existing = await self.user_repo.find_by_email(email.value) if existing: raise DuplicateEmailError(email.value) # 3. Create domain entity user = User(email=email.value, hashed_password=hash_pw(cmd.password)) # 4. Persist + notify await self.user_repo.save(user) await self.email_svc.send_welcome(user.email) return RegisterUserResult(user_id=str(user.id), email=user.email)
The Infrastructure Layer is where all the messy real-world stuff lives: SQLAlchemy, email clients, Redis, S3, etc. It implements the interfaces defined by the Domain Layer.
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.domain.user import User as DomainUser from app.domain.interfaces import UserRepositoryInterface from app.infrastructure.models import UserORM # SQLAlchemy model class SQLUserRepository(UserRepositoryInterface): """Concrete implementation — knows about SQLAlchemy.""" def __init__(self, session: AsyncSession): self.session = session async def find_by_email(self, email: str) -> DomainUser | None: row = (await self.session.execute( select(UserORM).where(UserORM.email == email) )).scalar_one_or_none() return self._to_domain(row) if row else None async def save(self, user: DomainUser) -> None: orm = UserORM(id=user.id, email=user.email, hashed_password=user.hashed_password) self.session.add(orm) await self.session.commit() def _to_domain(self, row: UserORM) -> DomainUser: # Convert ORM model → pure domain entity return DomainUser(id=row.id, email=row.email, hashed_password=row.hashed_password)
The FastAPI router sits in the infrastructure layer too — it calls the application use case:
from fastapi import APIRouter, Depends from app.application.use_cases.register_user import RegisterUserUseCase, RegisterUserCommand from app.api.dependencies import get_register_use_case from app.api.schemas import RegisterRequest, RegisterResponse router = APIRouter() @router.post("/register", response_model=RegisterResponse, status_code=201) async def register( req: RegisterRequest, use_case: RegisterUserUseCase = Depends(get_register_use_case), ): result = await use_case.execute(RegisterUserCommand(email=req.email, password=req.password)) return RegisterResponse(user_id=result.user_id, email=result.email)
Complete Clean Architecture folder structure:
When to use which pattern?
| Pattern | Best For | Complexity | Team Size |
|---|---|---|---|
| Layered (MVC) | CRUD APIs, microservices, simple domains | Low | Solo – small team |
| DDD | Complex business rules, multi-aggregate domains | Medium | Small – medium team |
| Clean Architecture | Long-lived systems, framework swaps, high testability | High | Medium – large team |