A production-grade implementation of Clean Architecture, Domain-Driven Design (DDD), and CQRS principles in Python. This project demonstrates enterprise patterns for building scalable, maintainable, and resilient backend applications.
Load tested with production-like traffic distribution (93% reads, 7% writes):
| Concurrent Users | Requests | Error Rate | P50 | P95 | P99 | RPS |
|---|---|---|---|---|---|---|
| 2,000 | 44,000+ | 0% | 4ms | 49ms | - | 295 |
| 3,000 | 183,593 | 0% | 8ms | 66ms | 560ms | 358 |
System Capacity:
- Comfortable load: 2k users (~295 RPS, sub-50ms P95)
- Maximum stable load: 3k users (~358 RPS, 0% errors)
- Bottleneck: Elasticsearch GC at higher loads
Infrastructure: 8 API instances, role-bounded application pools, PgBouncer, Redis cache, and a single-node Elasticsearch read model
# Clone the repository
git clone https://github.com/MartinKalema/clean-architecture-ddd-python.git
cd clean-architecture-ddd-python
# Start the API and all correctness-critical infrastructure
docker compose up --build
# API available at http://localhost:8000
# API docs at http://localhost:8000/docsThe default stack runs Alembic migrations before starting the API, then starts the transactional-outbox pipeline (Debezium, Kafka, durable workflow workers, and independently scaled notification workers) plus the reservation reaper, connector monitor, and bounded outbox/durable-state cleaners. These are part of the operating topology, not optional observability services. Each API instance refuses to start unless the database revision exactly matches the repository's Alembic head.
# Adds table CDC, Elasticsearch, Kibana, and the ES projection workers.
# The domain-event pipeline already runs in the default stack.
ELASTICSEARCH_ENABLED=true docker compose --profile cdc up --build
# Kibana available at http://localhost:5601
# Elasticsearch at http://localhost:9200# Start with load testing profile
docker compose --profile loadtest up --build --scale locust-worker=4
# Open Locust UI at http://localhost:8089βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Presentation Layer β
β (FastAPI, CLI, Background Workers) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Application Layer β
β (Command Handlers, Query Handlers, Event Handlers) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Domain Layer β
β (Entities, Value Objects, Domain Events, Interfaces) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Infrastructure Layer β
β (Repositories, Message Brokers, Cache, Circuit Breakers) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Client β Nginx (LB) β API (x8) β PgBouncer β PostgreSQL
β β
Redis (Cache) Transactional Outbox
β β
etcd (Config) Debezium β Kafka β Event Workers
β
Optional table CDC β Elasticsearch
| Component | Purpose |
|---|---|
| Nginx | Load balancer across 8 API instances |
| PgBouncer | Transaction pooling with a bounded 200-connection backend pool |
| Redis | Cache layer with TTL-based expiry (120s) |
| etcd | Centralized configuration |
| PostgreSQL | Primary database (write model) |
| Debezium | Publishes the transactional outbox; optionally publishes table CDC |
| Kafka | Delivers domain events to application event workers |
| Event workers | Continue cross-context workflows from durable domain events |
| Connector monitor | Continuously reports outbox connector/task failure |
| Reservation reaper | Releases expired semantic locks when a borrow cannot complete |
| Outbox cleaner | Prunes delivered WAL-backed outbox history in bounded batches |
| Durable-state cleaner | Bounds idempotency, terminal operation, inbox, and quarantine history |
| Elasticsearch | Optional read-optimized search projection (CQRS read model) |
Pure business logic with no external dependencies:
- Entities:
Book,Loan,Patronaggregates - Value Objects:
BookId,Title,EmailAddress - Domain Events: correlated reservation, borrow, loan, cancellation, and return facts
- Domain Ports: aggregate repositories expressed only in domain terms
- Bounded Contexts: Catalog, Lending, Patron
CQRS handlers for business operations:
- Command Handlers: public catalog/patron/loan use cases plus internal saga transitions
- Query Handlers:
ListBooks,GetBook,ListPatrons(with caching) - Event Handlers: Async reactions to domain events
- Application Ports: clock, cache, logging, email, event delivery, command receipts, and upstream anti-corruption contracts
External integrations and technical concerns:
- Adapters: Repository implementations, messaging, email, caching
- Resilience: Circuit breakers for external services
- Outbox: Transactional event delivery guarantee
- External Clients: PostgreSQL, Redis, Kafka, Elasticsearch, SendGrid, etcd
API and user interfaces:
- FastAPI Routes: REST endpoints for books, loans, patrons
- Health Checks:
/health,/health/ready,/health/circuit-breakers
class ListBooksHandler:
def __init__(self, repository: BookQueryRepository, cache: ICache, logger: ILogger):
self.repository = repository
self.cache = cache
async def handle(self, query: ListBooksQuery) -> List[BookReadModel]:
cache_key = self.cache.build_list_key("book", **query.__dict__)
cached = await self.cache.get(cache_key)
if cached:
return cached
books = await self.repository.find_all(**query.__dict__)
await self.cache.set(cache_key, books)
return bookscircuit_breaker = CircuitBreaker(
name="sendgrid",
failure_threshold=3,
success_threshold=2,
timeout=60.0
)
@circuit_breaker
async def send_email(to, subject, content):
await sendgrid.send(to, subject, content)Two database constraints close the message-redelivery and concurrency races:
CREATE UNIQUE INDEX ix_loans_reservation_id_unique
ON loans (reservation_id);
CREATE UNIQUE INDEX ix_loans_outstanding_book_unique
ON loans (catalog_book_id)
WHERE status NOT IN ('returned', 'cancelled');Every reservation has a UUID identity and a book-local generation fence. Catalog stores the exact current loan, so delayed confirmation, compensation, or return events cannot mutate a newer patron's workflow. The final Lending decision reloads authoritative Patron facts under a shared transaction-scoped patron fence. Lending then applies its own tier-to-capacity and loan-duration policy; Patron does not dictate Lending rules through the ACL.
src/
βββ domain/ # Domain Layer (Pure Python)
β βββ catalog/ # Catalog Bounded Context
β βββ lending/ # Lending Bounded Context
β βββ patron/ # Patron Bounded Context
β βββ shared_kernel/ # Minimal shared domain concepts
βββ application/ # Application Layer (CQRS)
β βββ command_handlers/ # Write operations
β βββ query_handlers/ # Read operations (cached)
β βββ event_handlers/ # Async event processing
βββ infrastructure/ # Infrastructure Layer
β βββ adapters/
β β βββ cache/ # Redis cache adapter
β β βββ events/ # Kafka event delivery
β β βββ email/ # SendGrid service
β β βββ resilience/ # Circuit breakers
β β βββ outbox/ # Transactional outbox
β βββ external/ # External service clients
βββ presentation/ # Presentation Layer
β βββ api/ # FastAPI routes
βββ container.py # Dependency Injection
tests/
βββ domain/ # Entity & value object tests
βββ application/ # Command/event handler tests
βββ infrastructure/ # Adapter and delivery tests
βββ integration/ # Repository & handler tests
βββ presentation/ # Public route contracts
βββ e2e/ # API tests
βββ load/ # Locust performance tests
βββ scenarios.py # User behavior definitions
βββ shapes/ # Load test shapes
βββ run_*.py # Test runners
Configuration is managed through etcd with environment variable overrides:
# Core services
DATABASE_URL=postgresql+asyncpg://user:pass@pgbouncer:6432/db
DATABASE_API_POOL_SIZE=20
DATABASE_API_MAX_OVERFLOW=10
DATABASE_WORKFLOW_POOL_SIZE=5
DATABASE_WORKFLOW_MAX_OVERFLOW=5
REDIS_URL=redis://redis:6379/0
REDIS_ENABLED=true
KAFKA_BOOTSTRAP_SERVERS=kafka:29092
# Circuit breakers
CB_SENDGRID_FAILURE_THRESHOLD=3
CB_SENDGRID_TIMEOUT=60.0Configuration is validated as one immutable startup snapshot before any database, Kafka, Redis, Elasticsearch, or SendGrid client is constructed. Unknown or missing keys fail startup without logging secret values. Updating etcd requires a process restart; running clients are never mutated in place.
Alembic is the only schema owner. The application never calls
metadata.create_all() and never upgrades the database during startup:
# Apply this release's schema before starting application processes
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/db alembic upgrade headStartup fails fast if alembic_version is absent, behind, ahead, or on a
different migration branch. In Docker Compose, the one-shot migrator service
runs first and every API instance verifies its result.
The project is pre-release, so revision baseline_20260712 is the only
supported schema and must be applied to an empty database. Drop and recreate any development
database built from an earlier migration; no old-schema conversion is kept.
# All tests
pytest
# By category
pytest tests/domain # Domain logic
pytest tests/application # Application orchestration
pytest tests/infrastructure # Adapters and messaging
pytest tests/integration # Repository & handler tests
pytest tests/presentation # Public HTTP command surface
pytest tests/e2e # API tests
# With coverage
pytest --cov=src --cov-report=htmlDatabase-backed tests intentionally use a migrated PostgreSQL schema; they do not synthesize one with SQLAlchemy metadata. Migrate a test database first and point both variables at it:
DATABASE_URL=postgresql+asyncpg://library:library_secret@localhost:5432/library_test alembic upgrade head
TEST_DATABASE_URL=postgresql+asyncpg://library:library_secret@localhost:5432/library_test pytest tests/integrationCI always uses this migrated PostgreSQL path, so PostgreSQL-specific and migration-only constraints are part of the merge gate.
| Method | Endpoint | Description |
|---|---|---|
| GET | /books |
List all books |
| POST | /books |
Add a new book |
| GET | /books/{id} |
Get book details |
| POST | /books/{id}/borrow |
Start a borrow (202 with reservation identity) |
| GET | /borrow-operations/{id} |
Poll the accepted borrow workflow |
| GET | /patrons |
List all patrons |
| POST | /patrons |
Register a patron |
| GET | /loans/{id} |
Get one loan |
| GET | /loans/patron/{patron_id} |
List a patron's loans |
| POST | /loans/{id}/extend |
Extend an active loan |
| POST | /loans/{id}/return |
Return a loan and reconcile its catalog book |
| GET | /health |
Liveness check |
| GET | /health/ready |
Readiness check |
Every public POST command requires an Idempotency-Key header containing
8β128 URL/log-safe characters. Reusing the key with identical command facts
returns the committed response; reusing it for different facts returns 409.
The borrow response includes Location: /borrow-operations/{id}. List routes
return continuation metadata in X-Next-Cursor (and exact totals, when
available, in X-Total-Count).
- Engineering Design System - End-to-end method from outcomes and requirements to evidence and operations
- Design to Requirements - Validating, specifying, testing, and tracing engineering requirements
- Invariant-Driven Architecture - Deriving the minimum architecture from truths that must survive concurrency and failure
- Formal Methods and Property-Based Testing - Choosing and applying generated, stateful, concurrency, and formal verification techniques
- Delivery Assurance Gaps and Extension Plan - Extending sound design into safe delivery, operation, and production learning
- Engineering Planning and Estimation - Discovering complete work and creating evidence-based forecasts
- Engineering Execution Management - Delivering client outcomes on time while controlling quality, scope, dependencies, and variance
- Human-Centered Systems and Execution - Protecting dignity, agency, health, and sustainable capacity inside delivery systems
- Deployment Guide - Docker, Kubernetes, Cloud Run
- Strategic DDD Guide - Domain modeling approach
- Context Map - Bounded context relationships
- Event Storming - Domain event flows
- Ubiquitous Language - Domain terminology
- Sagas & Consistency - Sagas, compensation, semantic locks, and apologies in simple English
- Control Planes - Who creates the topics: from auto-create to self-service platforms, in simple English
All interfaces use I prefix:
class ILogger(Protocol):
def info(self, message: str) -> None: ...
class IEventDispatcher(Protocol):
async def dispatch(self, event: DomainEvent) -> None: ...Interface imports go under TYPE_CHECKING to avoid runtime overhead:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.application.ports import ILogger
class MyService:
def __init__(self, logger: ILogger): ...MIT License