Architecture Overview
Start here — the four-layer architecture, ports & adapters, and why the domain layer has zero external dependencies.
The Core Idea
We structure software around the business domain, not around technical concerns. Databases, HTTP frameworks, and external services are implementation details — they live at the edges of the system. The domain model and business rules live at the centre.
This means:
- The domain layer has zero external dependencies — no ORM imports, no HTTP clients, no framework code.
- Business logic can be tested without spinning up a database or a web server.
- Infrastructure choices (which database, which message broker, which cloud provider) can change without touching business logic.
This is the central trade-off: more structural discipline up front, in exchange for a codebase that remains understandable and changeable as it grows.
The Four Layers
Every service is structured as four layers. Dependencies always point inward — outer layers depend on inner layers, never the reverse.
┌────────────────────────────────────────────┐
│ API Layer │ ← Routes, middleware, request/response schemas
├────────────────────────────────────────────┤
│ Application Layer │ ← Use cases, command handlers, orchestration
├────────────────────────────────────────────┤
│ Domain Layer │ ← Aggregates, entities, value objects, business rules
├────────────────────────────────────────────┤
│ Infrastructure Layer │ ← Repositories, adapters, external services
└────────────────────────────────────────────┘
Dependencies point inward →
Domain Layer (innermost)
Contains the business model: aggregates, entities, value objects, domain services, and domain events. This layer has no imports from any other layer and no framework dependencies. It expresses what the business does, in business terms.
Application Layer
Orchestrates the domain. Application handlers (command handlers, query handlers, event handlers) receive a request, call the domain, and coordinate infrastructure via interfaces. This layer knows what needs to happen but not how the infrastructure implements it.
Infrastructure Layer
Implements the interfaces (ports) defined by the application and domain layers. This is where SQLAlchemy repositories, SNS event publishers, S3 storage adapters, and JWT services live. Swapping one infrastructure implementation for another requires no changes to the domain or application layers.
API Layer (driving adapter)
The entry point for HTTP traffic. FastAPI routes, middleware, DI container, request validation, and exception handling live here. Routes are thin — they translate HTTP requests into application commands and application results into HTTP responses.
Ports & Adapters
The mechanism that makes the layers swappable is ports and adapters (hexagonal architecture).
A port is an interface defined in the domain or application layer — it declares what the application needs without specifying how it's provided. An adapter is a concrete implementation of that port, living in the infrastructure layer.
This pattern applies consistently across the codebase — repositories, event publishers, storage adapters, and auth services all follow the same shape. It is also the foundation of the testing strategy: in-memory adapters satisfy the same interfaces in tests, eliminating the need for mocks or a live database in unit and integration tests.
See Hexagonal Architecture for a full worked example and the adapter varieties table.
Domain-Driven Design
The domain layer is modelled using DDD building blocks:
- Aggregates — consistency boundaries with a single aggregate root controlling access
- Value objects — immutable, compared by value, not identity
- Domain events — things that happened, published after a state change
- Ubiquitous language — domain terms used consistently in code, tests, and documentation
The practical implication: class names, method names, and module names should reflect the business domain, not technical concerns. Account.create() not UserRecord.insert().
What This Means in Practice
| What you see | Why it's there |
|---|---|
domain/ has no import sqlalchemy | Domain layer has zero infrastructure dependencies |
Repository interfaces in domain/ or application/ | Ports defined where they're needed (inner layers), implemented elsewhere (outer) |
InMemoryAccountRepository in tests | In-memory adapter satisfies the port — no real DB required |
api/dependencies.py wires everything together | Single place where infrastructure is constructed and injected |
| Settings control which adapter is active | Swap database_adapter = "inmemory" ↔ "sqlalchemy" without code changes |
Read Next
- Domain-Driven Design — DDD philosophy, ubiquitous language, bounded contexts
- Hexagonal Architecture — Ports & adapters in depth
- Layered Architecture — Layer responsibilities and interaction patterns
- Testing Strategy — How ports & adapters enables the no-mocks test strategy
Framework implementations
This guide covers how our abstract architecture standards are implemented in FastAPI. It does not re-explain DDD or hexagonal architecture — see the references below for those.
See also:
How the Layers Map
| Abstract layer | FastAPI folder | Role |
|---|---|---|
| Domain | domain/ | Aggregates, entities, value objects, abstract repository interfaces. Zero external dependencies. |
| Application | application/ | Use-case handlers (commands), query handlers, abstract service interfaces (publisher, logger, monitor, UoW). |
| Infrastructure | infrastructure/ | Concrete adapter implementations — database, events, cache, storage, monitoring, logging, auth. |
| API (Driving adapter) | api/ | FastAPI routes, middleware, request/response schemas, DI container, exception handlers. |
| Configuration | config/ | Pydantic BaseSettings — environment variables, adapter selection, feature flags. |
Dependency direction: api/ → application/ → domain/. infrastructure/ implements interfaces defined in application/ and domain/. config/ is read by the infrastructure factories and the DI container only.
Key FastAPI Idioms
Dependency Injection via Depends()
FastAPI's Depends() is the mechanism for injecting services into routes. All service construction happens in api/dependencies.py. Routes declare what they need via function parameters — FastAPI resolves and injects them.
Adapter switching via settings
Each infrastructure concern (database, events, storage, monitoring) has multiple adapter implementations and a factory. The factory reads a settings field (e.g. event_adapter = "inmemory" | "sns") and returns the appropriate implementation. This allows swapping adapters without changing application code.
Middleware for cross-cutting concerns
Logging, auth, request context, and CORS are handled as ASGI middleware registered on the FastAPI app — not inside route handlers. See 04-middleware-stack.md.
Lifespan for startup/shutdown
Database connection pools, background flush tasks (monitoring), and other process-level resources are initialised and torn down in api/lifespan.py using FastAPI's lifespan context manager.
Reference Implementation
All patterns in this guide reflect a canonical FastAPI service implementing the full four-layer structure described above.