Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Python Web API — Library Catalog

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.


What a Web API is

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: FastAPI

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.


Domain-Driven Design (DDD)

What it is

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 vs 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.

Why it matters for scaling

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.

Bounded Context

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.


Project Structure

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

The Three Schema Layers

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.

The Domain Stack per Bounded Context

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.


MVC in an API Context

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 schema

The controller's only job is HTTP: parse the request, call the service, map the result to a response schema, pick a status code.


Separation of Concerns

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.


IoC — Inversion of Control

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 = settings

dependencies.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 controller

In-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()

Configuration — pydantic-settings

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 --reload

Input Validation

Pydantic 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)

HTTP Status Codes

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

OpenAPI — Automatic Documentation

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 — OpenTelemetry

What is Observability?

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?

The Three Signals

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.

How it works in this project

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

Configuration

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).

Enabling the console exporter (no infrastructure)

# .env
OTEL_ENABLED=true
OTEL_EXPORTER=console

$env:PYTHONPATH = "src"
python -m uvicorn library_api.main:app --reload

Make 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 ..."
    }
}

Sending to a real backend (OTLP)

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:4318

Other compatible backends: Grafana Tempo, Zipkin (via adapter), Datadog, Honeycomb, New Relic, AWS X-Ray (via ADOT collector).


Quick Start

# 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-missing

Open http://localhost:8000/docs in your browser to explore the API interactively.


API Endpoints

Books /books

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

Members /members

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

Borrowing /borrowing

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages