FastAPI Mastery
Topic 20 / 22
Topic 20 · FastAPI Mastery
FastAPI Project Architecture
How you organise your code matters as much as how you write it. This topic covers the three most important patterns used in real FastAPI projects — Layered Architecture, Domain-Driven Design, and Clean Architecture — with concrete folder structures and working code examples for each.
20.1
Layered Architecture
🎮
Controllers

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 Layer (routers/)
users.py
orders.py
products.py
↓ calls
Service Layer (services/)
UserService
OrderService
ProductService
↓ calls
Repository Layer (repositories/)
UserRepo
OrderRepo
ProductRepo
↓ queries
Database
PostgreSQL / SQLite
📌
Golden Rule: If a function needs a DB session, it belongs in a repository. If it contains business rules, it belongs in a service. If it parses HTTP, it belongs in a controller.
python — routers/users.py (Controller)
# ✅ 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
⚙️
Services

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.

python — services/user_service.py
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)
💡
Services get injected with a repository — they never import AsyncSession directly. This makes them trivial to unit-test: just pass in a mock repository.
🗃️
Repositories

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.

python — repositories/user_repo.py
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:

python — dependencies.py
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
app/ ├── routers/ ← Controllers (HTTP layer) │ ├── users.py │ └── orders.py ├── services/ ← Business logic │ ├── user_service.py │ └── order_service.py ├── repositories/ ← DB queries only │ ├── user_repo.py │ └── order_repo.py ├── models/ ← SQLAlchemy ORM models ├── schemas/ ← Pydantic models (request/response) ├── dependencies.py ← Wires everything together └── main.py
20.2
Domain Driven Design (DDD)
🪪
Entities

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.

Entity
Has a unique ID. Same object across time even if data changes. Example: User, Order, Product.
Value Object
No ID. Defined purely by its values. Immutable. Example: Money(100, "USD"), Address, Email.
Aggregate
Cluster of entities/VOs treated as a unit. Has a root entity. Example: Order + OrderItems.
Repository
Retrieves/stores aggregates. Domain doesn't know about SQL.
python — domain/user.py (Entity)
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
💡
Notice that 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.
💎
Value Objects

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.

python — domain/value_objects.py
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
🧩
Aggregates

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.

⚠️
The most common DDD mistake: modifying 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.
python — domain/order.py (Aggregate)
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:

app/ ├── domain/ ← Pure domain (no framework deps) │ ├── user.py ← Entity │ ├── order.py ← Aggregate │ └── value_objects.py← Value Objects (Money, Email…) ├── application/ ← Use-cases / services │ ├── create_order.py │ └── cancel_order.py ├── infrastructure/ ← DB, external APIs │ ├── order_repo_sql.py │ └── email_sender.py └── api/ ← FastAPI routers └── orders.py
20.3
Clean Architecture
🫀
Domain Layer

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.

INFRASTRUCTURE
APPLICATION
DOMAIN

The Domain Layer contains:

python — domain/interfaces.py (Abstract boundaries)
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 domain defines the shape of what it needs (interfaces), but doesn't care who implements them. This is called Dependency Inversion — the "D" in SOLID.
📋
Application Layer

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.

python — application/use_cases/register_user.py
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)
🧪
Because the use case only depends on interfaces, testing is trivial: pass in mock implementations with no DB or email server needed.
🏗️
Infrastructure Layer

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.

python — infrastructure/sql_user_repo.py
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:

python — api/users.py (Infrastructure → FastAPI)
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:

app/ ├── domain/ ← No deps (innermost ring) │ ├── user.py ← Entity │ ├── order.py ← Aggregate │ ├── value_objects.py │ ├── interfaces.py ← Abstract repo/service contracts │ └── exceptions.py ← Domain exceptions │ ├── application/ ← Use cases (depends on domain) │ └── use_cases/ │ ├── register_user.py │ ├── create_order.py │ └── cancel_order.py │ └── infrastructure/ ← All external (depends on everything) ├── db/ │ ├── models.py ← SQLAlchemy ORM │ └── sql_user_repo.py ← Implements domain interface ├── email/ │ └── smtp_email_svc.py └── api/ ← FastAPI routers (HTTP) ├── users.py ├── schemas.py └── dependencies.py

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
🏆
Real world advice: Start with Layered Architecture. Add DDD concepts (Value Objects, Aggregates) when business rules get complex. Graduate to Clean Architecture only if you anticipate framework/DB migrations or have multiple teams working in the same codebase.
Topic 20 Complete! You've learned all three major architectural patterns for FastAPI: Layered Architecture (Controllers → Services → Repositories), Domain-Driven Design (Entities, Value Objects, Aggregates with rich domain models), and Clean Architecture (Domain → Application → Infrastructure with dependency inversion). Reply "next" to continue to Topic 21: Production FastAPI.