ADR-019: Domain event integration between backend domains
Context
The backend is a modular monolith (ADR-003) built for eventual service extraction via the interface+adapter pattern. Domains must react to each other's state changes — e.g. a Stripe payment outcome must activate a subscription and grant entitlement credits.
Two anti-patterns surfaced while wiring Payment → Subscription/Entitlement:
- Producer calls consumers directly (
Paymentimports and callsSubscriptionApi/EntitlementApi). This inverts the dependency — a generic capability (Payment) depends on its specific consumers — couples them, and creates aPayment ↔ Subscriptionbuild cycle once initiation (Subscription → Payment) also exists. - Consumer implements a producer-defined port (port declared inside Payment, implemented by Subscription). This is an inverted DIP: the generic capability owns the abstraction the policy implements, and every consumer must depend on the producer's module.
We need one consistent way for domains to integrate on state changes: acyclic, decoupled, reliable, and extraction-ready, reusing the existing contract mechanism (the by-domain api-spec/<domain>/… tiers, Hard Rule 16) rather than hand-rolled DTO modules.
Decision
Domains integrate via producer-owned domain events, never via direct producer→consumer calls.
- Event payload contracts are proto messages under
api-spec/<producer>/events/v<n>/(Hard Rule 16) — theeventstier sits beside the domain'srest/grpctiers. The producer owns and versions them. - Event port interfaces (Kotlin listeners over the generated types) live in
backend/shared/api-contracts/<producer>/events/— one cohesive Bazel target per producer, owned by that producer, alongside its other contract tiers (grpc/internal). - Delivery (now): in-process, synchronous, via the port. The producer depends only on its event port + proto types and invokes registered listeners (
getAll<…Listener>()). Consumers implement the port.appwires impls into the producer via Koin. The producer has no compile dependency on any consumer → acyclic. - Reliability: for events originating from an external system (e.g. Stripe webhooks), correctness rests on the source's delivery retry + idempotent consumers + a reconciliation poller owned by the integrating domain. A durable transactional outbox is adopted at service extraction, not before (see Evolution).
- Initiation is separate and unaffected. A domain calling a capability it needs (e.g.
Subscription → Payment.createPaymentSubscription) is a normal consumer→capability dependency. This ADR governs the notification/return path (producer → consumers), which must be events.
Rules (enforced in review; Bazel enforces the structural ones)
events/is a namespace, not a dependency. Consumers depend on the specific per-producer target//backend/shared/api-contracts/<producer>/events— never an aggregate events module. No aggregate target may exist.- One target per producer; the producer owns it (and the matching
api-spec/<producer>/events/v<n>contract). Cohesion and change-authority stay per-producer. - Producers MUST NOT depend on consumers. A producer never imports or calls a consumer domain to notify it; it depends only on its event port + proto contract.
- Consumers MUST NOT implement an interface defined inside a producer. Both sides bind to the shared port + shared proto contract; neither depends on the other's module.
- Event payloads are proto messages in
api-spec/<producer>/events/v<n>/. Never hand-write duplicate event DTOs (Hard Rule 16). - Consumers MUST be idempotent — dedup on a business key or event id. At-least-once delivery is assumed.
- The domain that owns an external integration is its sole boundary (anti-corruption). It alone translates external events (e.g. Stripe) into domain events; other domains never call the external system.
- Reliability for externally-triggered flows = source retry + idempotency + reconciliation. A durable outbox is required before any event crosses a process boundary.
- Adding an event = add a proto message under the producer + a method on the producer's port + wire consumers via Koin. No new modules, no aggregate targets.
Alternatives considered
| Option | Pros | Cons |
|---|---|---|
| Producer-owned events + per-producer shared port (chosen) | Acyclic; decoupled; producer-owned; extraction-ready (port → outbox/broker swap); reuses api-spec | A small per-producer port lib + Koin wiring per event |
| Producer calls consumers directly | Simplest to write today | Inverts dependency; couples capability to consumers; Payment↔Subscription cycle; not extractable |
| Consumer implements producer's port (port inside producer) | Breaks the cycle | Inverted DIP smell; consumer depends on producer module; producer becomes a consumer hub |
Single aggregate shared/events (or webhook/workflows) module | One place to look | Mechanism-named grab-bag/god-module; depends on every domain; coupling magnet |
| gRPC service / transactional outbox now | Reliable; distributed-ready | Premature distribution — network failure semantics, ops, auth-context plumbing, serialization, harder debugging/tests — with zero benefit while co-located. Adopt at extraction. |
Consequences
What becomes easier
- Domains evolve independently; no producer→consumer coupling and no cycles.
- Extraction is a transport swap (Local in-process adapter → outbox+broker or proto gRPC service); the proto contract and consumer handlers stay unchanged.
- Violations are cheap to catch: Bazel rejects an aggregate-dependency/cycle; review checks the rules.
What becomes harder
- Each event costs a per-producer port method + Koin wiring.
- Every consumer must be idempotent, and externally-triggered flows must have a reconciliation poller.
Risks
- In-process synchronous delivery has no durability of its own — correctness depends on source retry + idempotency + reconciliation until the outbox lands. Do not treat the in-process bus as a durable queue.
- A slow consumer blocks the producer's dispatch call (acceptable in-process at MVP; removed by the broker at extraction).
References
- ADR-003 (monolith-first), ADR-017 (application-layer enforcement), ADR-018 (OpenAPI-first contracts)
- Hard Rule 16 (contracts from
api-spec/); interface+adapter pattern (Local/Remote) - Evolution path: in-process port → transactional outbox + broker (or proto
service+ gRPC) at service extraction