Accepted
Modules in GrinSystem need to communicate while maintaining loose coupling. Key requirements:
- Production order completion triggers warehouse stock updates
- Stock changes affect production availability
- Batch creation requires production completion event
- Future requirement: audit trail of all events
- Possible future extraction to microservices
The system is a modular monolith deployed as a single process. While the requirements above are real, they are all satisfiable in-process today: every module runs in the same FastAPI application, so a domain event published by one module can be delivered to another module's handler without a network hop or an external broker. A message broker only becomes necessary once a module is extracted into its own service.
Modules communicate via domain events delivered through an in-process dispatcher backed by the transactional outbox.
Principles:
- No direct module-to-module imports; events are the only cross-module communication, and modules never share repositories.
- A command handler writes its events into the outbox table inside the same SQL transaction as the aggregate change (via OutboxEventPublisher / EventPublisherDep) and issues NOTIFY outbox_new.
- After commit, the OutboxPoller claims pending rows (FOR UPDATE SKIP LOCKED) and hands each event to the EventDispatcher, which runs every registered @on_event handler concurrently (asyncio.gather), each in its own fresh AsyncSession.
- A module subscribes by registering @on_event(EventType) handlers in src/modules/<m>/application/event_handlers.py; load_all_handlers() imports them at startup so the decorators fire.
- Events are immutable, versioned, and include tenant_id.
- Delivery is at-least-once, so handlers are idempotent (processed_events dedupe).
ZPCompleted (published to outbox)
└─► OutboxPoller ─► EventDispatcher ─┬─► Warehouse: consume materials
├─► Traceability: create genealogy link
└─► CRM: advance sales order status
Kafka is deferred until a module is extracted to its own service. At that point the transport — and only the transport — changes: the dispatcher target is swapped for a Kafka producer/consumer, per-module topics (grinsystem.{context}.events) reappear, and the outbox becomes the source for a Kafka relay. The @on_event handler signatures and the published events stay identical, so the extraction is a transport swap, not a domain rewrite.
- Loose coupling between modules (events only, no shared repositories)
- Audit trail of all events (the committed outbox rows)
- Reliable publishing: events commit atomically with the aggregate change, so no event is lost on crash
- Easy to add new consumers without touching producers
- Natural fit for eventual consistency
- No operational broker to run, secure, or monitor while the system is a single deployable
- Migration path to a broker is mechanical (transport swap) when extraction is actually needed
- In-process delivery couples consumer availability to the producing process (acceptable for a monolith)
- Eventual consistency (not immediate)
- Debugging event flows still requires tracing across handlers
- Need for handler idempotency
- Event schema versioning required for evolution
- A retry/back-off strategy for failed rows is a future milestone (failed rows are currently marked and left for investigation)
| Option |
Pros |
Cons |
Why not chosen |
| Direct imports |
Simple, synchronous |
Tight coupling, hard to extract |
Violates module boundaries |
| In-process dispatcher + transactional outbox |
Simple ops, reliable publish, extraction-ready transport swap |
In-process delivery, no cross-process replay yet |
Chosen |
| Apache Kafka now |
Audit trail, replay, extraction-ready |
Operational complexity for a single-process app; premature until a module is split out |
Deferred until module extraction |
| RabbitMQ |
Simpler than Kafka |
Still an external broker the monolith doesn't yet need |
Same reason as Kafka — premature |
Manufacturing handler outbox + OutboxPoller Warehouse handler
│ │ │
│ publish ZPCompleted │ │
│ (same txn as aggregate) │ │
│ ──────────────────────────►│ │
│ commit + NOTIFY │ dispatch (fresh session) │
│ │ ──────────────────────────►│
│ │ │ Process:
│ │ │ - Deduct stock
│ │ │ - publish StockAdjusted
│ │ mark row completed │