Skip to content

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        │

References