Skip to main content

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:

  1. Producer calls consumers directly (Payment imports and calls SubscriptionApi/EntitlementApi). This inverts the dependency — a generic capability (Payment) depends on its specific consumers — couples them, and creates a Payment ↔ Subscription build cycle once initiation (Subscription → Payment) also exists.
  2. 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) — the events tier sits beside the domain's rest/grpc tiers. 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. app wires 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)

  1. events/ is a namespace, not a dependency. Consumers depend on the specific per-producer target //backend/shared/api-contracts/<producer>/eventsnever an aggregate events module. No aggregate target may exist.
  2. 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.
  3. 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.
  4. 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.
  5. Event payloads are proto messages in api-spec/<producer>/events/v<n>/. Never hand-write duplicate event DTOs (Hard Rule 16).
  6. Consumers MUST be idempotent — dedup on a business key or event id. At-least-once delivery is assumed.
  7. 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.
  8. Reliability for externally-triggered flows = source retry + idempotency + reconciliation. A durable outbox is required before any event crosses a process boundary.
  9. 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

OptionProsCons
Producer-owned events + per-producer shared port (chosen)Acyclic; decoupled; producer-owned; extraction-ready (port → outbox/broker swap); reuses api-specA small per-producer port lib + Koin wiring per event
Producer calls consumers directlySimplest to write todayInverts dependency; couples capability to consumers; Payment↔Subscription cycle; not extractable
Consumer implements producer's port (port inside producer)Breaks the cycleInverted DIP smell; consumer depends on producer module; producer becomes a consumer hub
Single aggregate shared/events (or webhook/workflows) moduleOne place to lookMechanism-named grab-bag/god-module; depends on every domain; coupling magnet
gRPC service / transactional outbox nowReliable; distributed-readyPremature 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