ADR-003: Inter-Module Communication via In-Process Dispatcher¶
Status¶
Accepted
Context¶
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.
Decision¶
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.
Consequences¶
Positive¶
- 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
Negative¶
- 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
Neutral¶
- 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)
Alternatives Considered¶
| 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 |
Event Flow Example¶
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 │