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 frompackages/<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 graphmakes 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:domainscaffolds 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:
-
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. -
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. -
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.