Guide 11
Designing an event-driven system (with FastAPI)
Audience: Teams moving beyond a single API + DB into async reactions. Pairs with: RabbitMQ for commands, Kafka for domain events (this checklist).
TL;DR
- Name bounded contexts
- Separate commands (do work) from events (something happened)
- Commit state → outbox → bus
- Consumers own local models + inbox
- Keep a living event catalog
Contents
- Message kinds
- Design steps
- Topology
- Event catalog
- Naming and versioning
- Delivery guarantees
- Pseudocode
- Process managers
- Anti-patterns
- Rollout plan
---
1. Message kinds
| Kind | Meaning | Bus | Example |
|---|---|---|---|
| Command | Please do X | RabbitMQ | GenerateExport |
| Domain event | X happened in our model | Kafka (or RMQ topic) | InvoicePaid |
| Integration event | Cross-service contract | Kafka | billing.invoice_paid.v1 |
Do not treat them as interchangeable.
2. Design steps
- Bounded contexts : who owns language and data?
- Trigger path : API writes state, then emits command/event
- Name events : past tense, versioned
- Pick guarantees : outbox, inbox, keys for order
- Catalog : producers, consumers, failure modes
- Observe : lag, DLQ, publish rate
3. Topology
text
Client ──HTTP──► FastAPI (context)
│
├── commands ──► RabbitMQ ──► job workers
│
└── outbox ──► Kafka ──► consumer A (own DB)
└─► consumer B (own DB)4. Event catalog
For every event:
| Field | Example |
|---|---|
| Name | invoice.paid.v1 |
| Producer | Billing API / worker |
| Bus / topic | Kafka billing.events |
| Key | customer_id |
| Schema | JSON Schema / Pydantic |
| Consumers | Notifications, Ledger |
| Failure | retry → error topic |
Without a catalog, event-driven systems become tribal knowledge.
5. Naming and versioning
- Past tense for facts:
OrderPlaced, notPlaceOrder - Include version:
.v1 - Payload = contract (not ORM dump)
- Stable
event_idfor inbox
6. Delivery guarantees
| Need | Pattern |
|---|---|
| User job status | Job row + RMQ command |
| Multi-service reaction | Domain event on Kafka |
| No lost event after commit | Transactional outbox |
| At-least-once | Idempotent consumers + inbox |
| Order per entity | Kafka key = aggregate id |
7. Pseudocode
Publish after state change
python
# PSEUDOCODE : Billing
async def mark_invoice_paid(invoice_id, db, outbox):
async with db.transaction():
invoice = await db.lock_invoice(invoice_id)
if invoice.status == "paid":
return invoice
invoice.status = "paid"
await db.save(invoice)
await outbox.add(
topic="billing.events",
key=invoice.customer_id,
payload={
"event_id": new_uuid(),
"type": "invoice.paid.v1",
"invoice_id": invoice.id,
"customer_id": invoice.customer_id,
"amount_cents": invoice.amount_cents,
"occurred_at": utcnow_iso(),
},
)
return invoiceConsumer
python
# PSEUDOCODE : Notifications
async def on_invoice_paid(event, db):
if await db.inbox_seen(event["event_id"]):
return
await send_receipt_email(event["customer_id"], event["invoice_id"])
await db.inbox_insert(event["event_id"])Combined with a job
text
POST export → job command (RMQ) → worker finishes
→ domain event export.completed.v1 (Kafka)
→ analytics consumer updates dashboard projection8. Process managers
Long workflows spanning contexts:
text
OrderPlaced → reserve inventory command → InventoryReserved
→ charge payment command → PaymentCaptured
→ ship commandImplement as a policy service that listens to events and sends commands. Keep state in its own table; do not share DBs.
9. Anti-patterns
| Avoid | Prefer |
|---|---|
| God event with full aggregate graph | Small purpose-built payloads |
| Shared DB as integration | Events + local read models |
| Sync HTTP chains for every side effect | Commands/events + retries |
| One undifferentiated topic forever | Clear topics + catalog |
| Consumer querying producer DB | Consumer owns projection |
10. Rollout plan
- One context, one command queue, one event type
- Outbox for that event
- One consumer with inbox
- Metrics: publish rate, lag, DLQ
- Expand catalog before the fifth event type
Related: 12 DDD · 04 Outbox · 08 FastStream