Guide 12
Using DDD with FastAPI (and workers)
Audience: Teams whose FastAPI apps outgrew “fat routes + shared models.” DDD is: language, boundaries, and ownership : not a folder religion and not “event sourcing required.”
TL;DR
| Idea | In this stack |
|---|---|
| Bounded context | Package (or service) with own model |
| Aggregate | Transaction boundary + invariants |
| Application service | Use case called by HTTP and workers |
| Domain event | Outbox → Kafka after commit |
| Infrastructure | SQLAlchemy, RMQ, SMTP as adapters |
Contents
- Why DDD helps
- Core ideas
- Suggested layout
- Aggregates
- Application use cases
- Mapping to jobs and events
- Contexts vs packages
- Tactical patterns
- Testing
- Anti-patterns
- Adoption path
---
1. Why DDD helps
Without boundaries, FastAPI codebases often grow:
- Fat routers with business rules
- One
models.pyfor the company - Jobs that UPDATE five “domains” in one transaction
DDD pushes invariants next to the data, explicit integration, and shared language.
2. Core ideas
| Idea | Meaning |
|---|---|
| Ubiquitous language | Same words in code, API, events, tickets |
| Bounded context | Model boundary; words can differ across contexts |
| Aggregate | Cluster with one root; change via root; one TX |
| Domain event | Fact from a meaningful state change |
| Application service | Orchestrates load → domain → save → outbox |
| Infrastructure | Frameworks and I/O adapters |
3. Suggested layout
text
app/
contexts/
billing/
domain/ # entities, value objects, domain events, errors
application/ # use cases
adapters/
api/ # FastAPI routers + Pydantic
persistence/ # ORM, repositories
messaging/ # publish commands/events
notifications/
domain/
application/
adapters/
workers/ # process entrypoints → application use cases
main.pyStart as a modular monolith. Split deployables when scale or team ownership demands it.
4. Aggregates
python
# PSEUDOCODE : domain (no FastAPI imports)
@dataclass
class Invoice:
id: str
status: str # draft | issued | paid | void
amount_cents: int
def pay(self, at: datetime) -> list[DomainEvent]:
if self.status == "paid":
return []
if self.status != "issued":
raise DomainError("only issued invoices can be paid")
self.status = "paid"
return [InvoicePaid(self.id, self.amount_cents, at)]Rules live on the aggregate, not only in route validators.
5. Application use cases
python
# PSEUDOCODE
async def pay_invoice(cmd: PayInvoice, repo: InvoiceRepo, outbox: Outbox) -> None:
async with unit_of_work():
invoice = await repo.get(cmd.invoice_id)
events = invoice.pay(utcnow())
await repo.save(invoice)
for e in events:
await outbox.add(e.to_integration_event())Thin route:
python
@router.post("/invoices/{invoice_id}/pay", status_code=204)
async def pay(invoice_id: str, svc=Depends(get_billing_app)):
await svc.pay_invoice(PayInvoice(invoice_id=invoice_id))Workers call the same use case (or a command variant) with a messaging adapter.
6. Mapping to jobs and events
| DDD | Implementation |
|---|---|
| User command | HTTP POST or RabbitMQ job message |
| Domain event | Aggregate returns events → outbox → Kafka |
| Policy / process manager | Worker listening to events, sending commands |
| Read model | Projection table updated by consumer |
| Anti-corruption layer | Translate external payloads into your language |
text
POST /invoices/{id}/pay
→ Billing application service
→ Invoice.pay()
→ outbox: invoice.paid.v1
→ Kafka
→ Notifications policy → command SendReceiptEmail → RabbitMQ
→ email worker7. Contexts vs packages
- Do not import
billing.domainfromnotifications.domain - Notifications may depend on integration event schemas, not Billing ORM
- Shared kernel only for true shared primitives (
Money,TenantId) if agreed
8. Tactical patterns
| Pattern | Use when |
|---|---|
| Modular monolith | Default |
| Light CQRS | Heavy/different read models |
| Event sourcing | Hard requirement to rebuild from events (costly) |
| Sagas / process managers | Multi-step cross-context workflows |
| Ports & adapters | Test without DB/broker |
You can practice DDD without event sourcing.
9. Testing
- Domain tests : pure aggregate rules
- Application tests : fake repo + fake outbox
- Adapter tests : HTTP → command; message → command
- Contract tests : event payload schema
10. Anti-patterns
| Smell | Fix |
|---|---|
| Anemic models + all logic in routes | Move invariants to aggregates |
| One company-wide models module | Split by context |
| Job SQL-updates five contexts | Events + local updates |
| Shared session across contexts “for convenience” | Explicit messaging |
| Language only in docs | Rename code and events |
11. Adoption path
- Name 2-3 contexts on one page
- One aggregate with real invariants
- Thin the route; rules on the aggregate
- One domain event via outbox
- One consumer + inbox in another context
- Only then split deployables
Related: 11 Event-driven · 04 Outbox