Skip to main content

ADR-011: Cross-domain communication is event-only — no direct imports between domain packages

  • Status: Accepted
  • Date: 2026-05-23
  • Deciders: CTO / Founding Engineering
  • Relates to: ADR-001, ADR-003, ADR-008

Context

In a modular monolith we still want the bounded-context benefits of microservices: a domain's internals are private to it; downstream domains depend only on its published events, not its tables or services. Otherwise a payroll change ripples through EWA and lending in a coupling pattern that prevents any future carve-out.

The tax for this discipline is small in a monolith. The cost of not paying it is large later — every "let me just import Payroll's repository from EWA" becomes a permanent dependency that the eventual carve-out has to undo by hand.

Decision

  • A package packages/<domain-A>/** MUST NOT import from packages/<domain-B>/**.
  • Cross-domain communication is via the transactional outbox (packages/shared/events). Domain A publishes an event in the same tx as its state change (ADR-008); domain B's consumer subscribes to that event.
  • Apps (apps/api, apps/*-web) can compose multiple domains. Composition lives in app code, not in domain code.
  • packages/shared/* cannot reach into a domain either (that would smuggle coupling through shared).

Enforcement

  • Nx tag rules (see eslint.config.mjs) make a cross-domain import a lint error.
  • Code review rejects any PR that introduces one without an ADR amending this one.
  • pnpm nx graph makes the dependency graph visible — drift is auditable.

Alternatives considered

  • Allow cross-domain function calls if "they make sense." Rejected — there is no objective test for "makes sense"; in 6 months the graph is a hairball.
  • Allow imports of types only, not implementations. Marginal benefit; rejected because the type itself often pulls in implementation transitively, and "just types" is hard to enforce.
  • Service-mesh-style HTTP between domains in the monolith. Rejected — the whole point of the monolith is avoiding HTTP between in-process modules. Events do the job.

Consequences

Positive

  • A future carve-out of a domain into a Go service is a folder move plus a network publisher swap (packages/shared/events -> Kafka producer) — not a rewrite.
  • The dependency graph is a forest of (apps/... → domain) edges plus (domain → shared/...) edges, with no domain-to-domain edges. Easy to reason about.
  • Each domain can pick its own internal idioms (use cases, query objects, …) without affecting siblings.

Negative

  • Some interactions that would be a function call become an event publish + subscribe. Slight latency on cross-domain reactions. Acceptable.
  • Engineers must learn the pattern. The pnpm gen:domain scaffolds include an example outbox publish.
  • Read-path queries that cross domains require an app-layer query that calls each domain's read API and stitches the result — i.e. composition lives in apps, not in domain code.

Follow-ups

  • pnpm gen:domain <name> scaffolds a sample outbox event publisher.
  • The outbox event catalog (docs/architecture/event-catalog.md, to be written) lists every event each domain publishes and which domains subscribe. Updated with every event-publishing PR.

Amendment — 2026-06-18 (synchronous regulatory gates + read projections)

An audit found the packages/A → packages/B ban holds (no domain-to-domain imports exist), but the app-layer cross-domain reads in apps/api/src/* needed an explicit classification, because "everything via events" was being read as stricter than intended. The hard rule is unchanged; this clarifies the read-path patterns.

There are three legitimate cross-domain shapes:

  1. Reaction (async) — domain B reacts to domain A's state change. ALWAYS via the outbox event + a consumer (the original decision). Example: payroll deductions_taken → EWA/lending repayment.

  2. Synchronous regulatory gate (REQUIRED synchronous — do NOT convert to events). A money-moving action must be blocked in-band on a compliance check whose answer must be current at decision time: KYC verified + sanctions clear at disburse/payout. These are app-layer calls into the KYC/sanctions read APIs and they MUST stay synchronous + fail-closed — converting them to eventual-consistency would let money move on stale/again-missing compliance state, which is a regulatory defect, not a coupling win. Canonical adapters: apps/api/src/compliance/kyc/kyc-read.adapter.ts, kyc/kyc-sanctions-screen.adapter.ts, equb/equb-kyc.adapter.ts, equb/equb-sanctions.adapter.ts. These are correct as-is.

  3. Cross-domain calculation read (PREFER an event-fed projection). A domain needs another domain's data to compute something at read time but does NOT need a regulatory in-band guarantee: lending reading payroll income for affordability, EWA reading accrued earnings, lending reading an equb behaviour signal. App-layer composition (calling the other domain's read API) is permitted by the consequences above, but for these the preferred target is an event-fed projection: the consuming domain keeps a local read-model table populated from the source domain's outbox events, and reads its own projection. This decouples read-time, removes the live dependency, and makes the eventual carve-out cleaner. Candidate adapters to migrate: apps/api/src/products/ewa/payroll-accrued-earnings.adapter.ts, lending/income.adapter.ts, lending/equb-behavior-signal.adapter.ts.

Migration guidance for case 3 (staged, per-edge): (a) add a projection table owned by the consuming domain; (b) add a consumer that upserts it from the source domain's events (same outbox/poller machinery); (c) repoint the adapter to read the projection; (d) accept the projection may lag — only use it where that's tolerable (never for case 2). Do these one edge at a time; each is independently shippable. None is required for correctness today — they are decoupling improvements this ADR now blesses and prioritises.

Net: the event-only rule for reactions stands; synchronous regulatory gates are explicitly endorsed and must not be eventualised; cross-domain calculation reads should migrate to projections over time.