A learning project demonstrating how to build a maintainable REST API in Python
using FastAPI, structured according to Domain-Driven Design principles.
The domain is the same Library Catalog used in learn-python-oop, now exposed
over HTTP with full OpenAPI documentation.
A Web API (Application Programming Interface) is a server that:
- Listens for HTTP requests from clients (browsers, mobile apps, other services)
- Reads data from the request (URL path, query string, JSON body)
- Executes business logic
- Returns a structured response (JSON) with a meaningful HTTP status code
There are no HTML pages, templates, or rendered views — the response is the data. The client decides how to display it.
| Framework | Strengths | When to choose |
|---|---|---|
| FastAPI | Built-in OpenAPI, Pydantic validation, Depends() IoC, async | APIs, learning, modern projects |
| Flask | Minimal, flexible, large ecosystem | Small APIs, when you want full control |
| Django REST | Batteries included, admin UI, ORM | Large apps, SQL-heavy projects |
FastAPI was chosen here because every concept we want to teach maps directly to a framework feature — no boilerplate or glue code required.
DDD is a way of organising code so that the business problem — not the technical framework — determines the folder structure and module boundaries. Instead of grouping all controllers together, all services together, and all repositories together (layer-first), DDD groups everything that belongs to one business concept together (domain-first).
Layer-first (flat, technology-grouped):
src/library_api/
├── controllers/ ← all HTTP handlers together
├── schemas/ ← all Pydantic models together
├── services/ ← all business logic together
└── repositories/ ← all data access together
This works for small apps but breaks down as the system grows: a change to how books are borrowed requires touching four different folders. Teams working on different business features constantly collide on the same directories.
Domain-first (DDD — business-grouped):
src/library_api/
├── books/ ← everything about books in one place
├── members/ ← everything about members in one place
└── borrowing/ ← everything about borrowing in one place
Each domain owns its full stack. A team can work on borrowing/ without
touching books/ or members/. Features map 1-to-1 to folders.
| Concern | Layer-first (flat) | Domain-first (DDD) |
|---|---|---|
| Finding related code | Scattered across 4 folders | Contained in one folder |
| Adding a new feature | Touch controller + schema + service + repo | Add files inside one domain |
| Team ownership | Teams share every folder | Each team owns one domain |
| Splitting into microservices | Painful — cross-cutting everywhere | Natural — each domain is already self-contained |
| Testing in isolation | Requires mocking across layers | Domain can be tested independently |
When an application grows to dozens of business concepts, DDD keeps each one cohesive and independently evolvable. The folder structure becomes a map of the business itself.
Each domain folder in this project is a bounded context — a self-contained
slice that owns its own vocabulary, rules, and data. The borrowing context can
refer to books and members, but only through their repository interfaces, never
by importing concrete implementation details from those domains.
learn-python-webapi/
├── .env.example # Document config keys — copy to .env
├── pyproject.toml # Project metadata + pytest config
├── requirements.txt
│
├── src/library_api/
│ ├── main.py # App factory + router registration
│ ├── config.py # Settings loaded from environment / .env
│ ├── dependencies.py # IoC wiring — all Depends() providers live here
│ │
│ ├── books/ # Books bounded context
│ │ ├── api/
│ │ │ ├── controller.py # HTTP routing — no business logic
│ │ │ └── schemas.py # BookCreate, BookUpdate, BookResponse
│ │ └── domain/
│ │ ├── model.py # Book — pure domain entity (dataclass)
│ │ ├── factory.py # BookFactory — creates Book, assigns UUID
│ │ ├── service.py # BookService — business logic
│ │ └── repository/
│ │ ├── schemas.py # BookRecord — storage representation
│ │ └── repository.py # BookRepository (ABC) + InMemoryBookRepository
│ │
│ ├── members/ # Members bounded context (same structure)
│ │ ├── api/ ...
│ │ └── domain/ ...
│ │
│ └── borrowing/ # Borrowing bounded context (same structure)
│ ├── api/ ...
│ └── domain/ ...
│
└── tests/
├── conftest.py # TestClient fixture + dependency overrides
├── test_books.py
├── test_members.py
└── test_borrowing.py
One of the key DDD insights is that a single business object has different representations depending on who is looking at it. This project makes those representations explicit with three distinct schema types per domain:
┌────────────────────────────────────────────────────────┐
│ API Schema (api/schemas.py) │
│ Pydantic BaseModel — the HTTP contract │
│ BookCreate, BookUpdate, BookResponse │
│ BookResponse.from_domain(book) maps entity → response │
└────────────────────┬───────────────────────────────────┘
│ controller calls from_domain()
┌────────────────────▼───────────────────────────────────┐
│ Domain Entity (domain/model.py) │
│ Python dataclass — the business truth │
│ Book(id, title, author, year, pages, isbn, genre) │
│ This is what the service works with │
└────────────────────┬───────────────────────────────────┘
│ repository calls from_domain() / to_domain()
┌────────────────────▼───────────────────────────────────┐
│ Storage Schema (domain/repository/schemas.py) │
│ Python dataclass — the persistence representation │
│ BookRecord — in a DB repo this would be an ORM model │
│ Translates between storage and the domain entity │
└────────────────────────────────────────────────────────┘
Why three layers?
- API schemas carry HTTP concerns: validation rules, optional fields for
partial updates, which fields the client should never set (like
id). - Domain entities carry business concerns: the canonical shape of a book as the business understands it, with no HTTP or storage noise.
- Storage schemas carry persistence concerns: column mapping, ORM annotations, or any fields that only make sense in the database (timestamps, soft-delete flags). Swapping the database means only this layer changes.
Every bounded context follows the same internal structure:
domain/
├── model.py ← the entity — what the business talks about
├── factory.py ← creates new entities, assigns identity (UUID)
├── service.py ← enforces business rules, orchestrates repository calls
└── repository/
├── schemas.py ← storage representation (ORM model in a real project)
└── repository.py ← abstract interface (ABC) + in-memory implementation
Factory separates object creation from business logic. BookService.create()
delegates to BookFactory.create() which assigns the UUID and returns a Book.
The service stays focused on rules; the factory stays focused on construction.
Repository ABC means the service depends on an interface, not a
concrete class. BookService never knows whether data lives in memory, Postgres,
or a remote API. Swapping the storage backend requires only adding a new class
that implements BookRepository — zero changes to service or controller code.
Traditional MVC has three parts: Model, View, Controller. In a Web API there are no views — the JSON response replaces them. The pattern adapts like this:
| MVC Role | This project | Responsibility |
|---|---|---|
| Model | api/schemas.py (Pydantic) |
Shape of request and response data |
| View | JSON response | FastAPI serialises the model automatically |
| Controller | api/controller.py (APIRouter) |
Receives request, calls service, returns response |
| Action | Route handler function | One HTTP verb + path = one action |
# Controller = APIRouter instance, grouped by domain
router = APIRouter()
# Action = one handler per verb+path combination
@router.post("/", response_model=BookResponse, status_code=201)
def create_book(data: BookCreate, service: BookService = Depends(get_book_service)):
book = service.create(data) # returns a Book domain entity
return BookResponse.from_domain(book) # controller maps entity → response schemaThe controller's only job is HTTP: parse the request, call the service, map the result to a response schema, pick a status code.
Each layer has one responsibility and depends only on the layer below it.
┌──────────────────────────────────────────┐
│ Controller (HTTP layer) │ Knows: HTTP verbs, status codes, API schemas
│ │ Does NOT know: business rules, storage
└────────────────┬─────────────────────────┘
│ injected via Depends()
┌────────────────▼─────────────────────────┐
│ Service (Business logic) │ Knows: domain rules, domain entities
│ │ Does NOT know: HTTP, SQL, env vars
└────────────────┬─────────────────────────┘
│ constructor injection
┌────────────────▼─────────────────────────┐
│ Repository (Data access) │ Knows: how to store/retrieve domain entities
│ │ Translates: domain entity ↔ storage record
│ │ Does NOT know: business rules, HTTP
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Config (cross-cutting concern) │ Read once at startup via BaseSettings
│ │ Injected into services — never accessed
│ │ with os.environ inside service code
└──────────────────────────────────────────┘
Coupling = how much one module knows about another's internals. Low coupling means you can swap a repository implementation without touching any service.
Cohesion = how focused a module's responsibilities are. High cohesion means each file does one thing well.
Instead of a service creating its own dependencies, the caller provides them.
This is implemented with FastAPI's Depends().
# WITHOUT IoC — service controls its own dependencies (tightly coupled)
class BookService:
def __init__(self):
self._repo = InMemoryBookRepository() # hardwired — impossible to swap in tests
self._settings = Settings() # reads env directly — test pollution
# WITH IoC — dependencies are injected from outside (loosely coupled)
class BookService:
def __init__(self, repo: BookRepository, settings: Settings) -> None:
self._repo = repo # any BookRepository implementation works
self._settings = settingsdependencies.py is the single place that knows how to wire everything together:
def get_book_service(
repo: BookRepository = Depends(get_book_repository), # FastAPI resolves this
settings: Settings = Depends(get_settings),
) -> BookService:
return BookService(repo, settings) # constructed here, injected into the controllerIn-memory repositories are @lru_cache singletons so data persists across
requests. In tests, app.dependency_overrides replaces each singleton with a
fresh instance, giving every test a clean slate:
app.dependency_overrides[deps.get_book_repository] = lambda: InMemoryBookRepository()config.py defines a Settings class backed by environment variables or a .env file.
No service or repository ever calls os.environ or os.getenv directly.
class Settings(BaseSettings):
app_name: str = "Library API"
max_borrow_days: int = 14
max_items_per_member: int = 5
model_config = {"env_file": ".env"}To override a value: set the environment variable before starting the server.
$env:MAX_BORROW_DAYS = "7"
python -m uvicorn library_api.main:app --reloadPydantic validates every request body before the action runs. Bad input never reaches
the service layer. A failed validation returns 422 Unprocessable Entity automatically.
class BookCreate(BaseModel):
title: str = Field(min_length=1, max_length=200) # empty string rejected
year: int = Field(ge=1000, le=2100) # unrealistic years rejected
pages: int = Field(gt=0) # zero or negative rejected
isbn: str = Field(min_length=10, max_length=20)| Code | Meaning | When used |
|---|---|---|
| 200 OK | Success, body returned | GET, PUT |
| 201 Created | Resource was created | POST |
| 204 No Content | Success, no body | DELETE |
| 404 Not Found | Resource does not exist | GET/PUT/DELETE on missing ID |
| 409 Conflict | Request conflicts with current state | Borrowing an already-borrowed book |
| 422 Unprocessable Entity | Validation failed | Missing or invalid request fields |
FastAPI generates interactive API docs from your code. No extra work required.
| URL | Tool | Description |
|---|---|---|
http://localhost:8000/docs |
Swagger UI | Try endpoints directly in the browser |
http://localhost:8000/redoc |
ReDoc | Clean reference documentation |
http://localhost:8000/openapi.json |
Raw JSON | The OpenAPI specification |
Observability is the ability to understand what your system is doing from the outside, without modifying it. A well-instrumented API lets you answer questions like:
- Which requests are slowest, and why?
- Where does time go inside a single borrow operation?
- How many 409 conflicts happen per minute?
- Which service call failed when a user got a 500?
| Signal | What it measures | Example |
|---|---|---|
| Traces | The journey of a single request end-to-end | HTTP request → service → repository → DB |
| Metrics | Aggregated counts and timings over time | Requests per second, p99 latency |
| Logs | Discrete events with context | "Book X not found for member Y" |
OpenTelemetry is the industry standard for emitting all three signals from application code in a vendor-neutral way. You instrument once; you choose the backend later.
observability.py sets up two providers and instruments the app:
┌─────────────────────────────────────────────────────┐
│ FastAPIInstrumentor (auto-instrumentation) │
│ Adds a span for every HTTP request automatically │
│ Attributes: http.method, http.route, http.status │
└────────────────────┬────────────────────────────────┘
│ parent span
┌────────────────────▼────────────────────────────────┐
│ BorrowingService.borrow() (manual span) │
│ Adds a child span with domain-level attributes │
│ Attributes: library.book_id, library.member_id, │
│ library.borrow_id, error.reason │
└─────────────────────────────────────────────────────┘
Auto-instrumentation covers the HTTP layer with zero per-route code. Manual spans cover business operations where the domain context matters most — knowing a borrow failed because of "member_limit_reached" is more useful than knowing a POST returned 409.
# borrowing/domain/service.py — manual span, safe when OTEL is disabled
_tracer = trace.get_tracer(__name__) # no-op tracer if no provider is set
def borrow(self, request):
with _tracer.start_as_current_span("borrowing.borrow") as span:
span.set_attribute("library.book_id", request.book_id)
# ... business logic
span.set_attribute("library.borrow_id", borrowing.id)
return borrowing| Key | Default | Description |
|---|---|---|
OTEL_ENABLED |
false |
Set to true to activate. Off by default so tests produce no output. |
OTEL_SERVICE_NAME |
library-api |
Service name that appears in the backend UI. |
OTEL_EXPORTER |
console |
console prints to stdout. otlp sends to a collector. |
OTEL_OTLP_ENDPOINT |
http://localhost:4318 |
OTLP HTTP endpoint (only used when exporter is otlp). |
# .env
OTEL_ENABLED=true
OTEL_EXPORTER=console
$env:PYTHONPATH = "src"
python -m uvicorn library_api.main:app --reloadMake a request and you will see a span printed to the terminal:
{
"name": "POST /borrowing/",
"context": {"trace_id": "0x...", "span_id": "0x..."},
"attributes": {
"http.method": "POST",
"http.route": "/borrowing/",
"http.status_code": 201
}
}
{
"name": "borrowing.borrow",
"attributes": {
"library.book_id": "abc-123",
"library.member_id": "xyz-456",
"library.borrow_id": "...",
"library.due_date": "2026-05-18 ..."
}
}
Any backend that speaks OTLP can receive traces and metrics from this API. Start a backend with Docker, then point the exporter at it:
# Jaeger — all-in-one, includes UI at http://localhost:16686
docker run --rm -p 4318:4318 -p 16686:16686 jaegertracing/all-in-one
# .env
OTEL_ENABLED=true
OTEL_EXPORTER=otlp
OTEL_OTLP_ENDPOINT=http://localhost:4318Other compatible backends: Grafana Tempo, Zipkin (via adapter), Datadog, Honeycomb, New Relic, AWS X-Ray (via ADOT collector).
# Install dependencies
python -m pip install -r requirements.txt
# Copy config template
copy .env.example .env
# Run the API (hot-reload on file changes)
$env:PYTHONPATH = "src"
python -m uvicorn library_api.main:app --reload
# Run tests
python -m pytest
# Run tests with coverage
python -m pytest --cov=library_api --cov-report=term-missingOpen http://localhost:8000/docs in your browser to explore the API interactively.
| Method | Path | Action | Success | Errors |
|---|---|---|---|---|
| GET | /books/ |
List all | 200 | — |
| GET | /books/{id} |
Get one | 200 | 404 |
| POST | /books/ |
Create | 201 | 422 |
| PUT | /books/{id} |
Update | 200 | 404, 422 |
| DELETE | /books/{id} |
Delete | 204 | 404 |
| Method | Path | Action | Success | Errors |
|---|---|---|---|---|
| GET | /members/ |
List all | 200 | — |
| GET | /members/{id} |
Get one | 200 | 404 |
| POST | /members/ |
Create | 201 | 422 |
| DELETE | /members/{id} |
Delete | 204 | 404 |
| Method | Path | Action | Success | Errors |
|---|---|---|---|---|
| GET | /borrowing/ |
List active | 200 | — |
| POST | /borrowing/ |
Borrow a book | 201 | 404, 409 |
| DELETE | /borrowing/{id} |
Return a book | 204 | 404 |