# ADR-0003: Modular monolith for Phase 0, extract when justified ## Status Accepted — 2026-04-13 ## Context The initial architecture called for seven independently-deployable services on day one (gateway, auth, profile, integrations, recommender, events, notifier). For a team of ~3 streams with zero users, this is premature. Each service adds CI, deploy, DB, observability, and release-coordination overhead. It also slows the walking skeleton, which is the most important thing to ship. Modularity — the thing we actually need — is a **code-boundary** property, not a **process-boundary** property. Well-bounded packages extract to services cheaply; poorly-bounded services rarely merge back. ## Decision - **Phase 0:** one Node process bundles `services/*` as internal packages behind their HTTP contracts. `ml/serving` is a separate Python process (language boundary). Postgres + NATS complete the stack. - **Directory layout** under `services/` is unchanged. Each module is a self-contained package with its own README, schema migrations, and public interface. - **Communication** between modules goes through the same HTTP or event contracts it will use post-extraction. In Phase 0 these are resolved in-process via a thin dispatcher; swapping to HTTP/NATS is a transport change, not an API change. - **Extraction criteria** (trigger a service split when any apply): 1. Language boundary (already true for `ml/serving`). 2. Scaling hotspot: the module's load curve diverges materially from the rest. 3. SLA divergence: the module needs stricter availability or latency than the monolith. 4. Team ownership: a dedicated team takes the module and wants independent releases. 5. Regulatory isolation: credentials/PII need tighter blast-radius control. - **`events/` is special:** even inside the monolith we use an event-emitter abstraction whose production implementation is NATS JetStream. The async boundary matters for ML correctness; the process boundary doesn't. ## Consequences - Faster Phase 0: one CI pipeline, one deploy, one observability config. - Cheap extraction: contracts are already HTTP/event-shaped. - Discipline required: no cross-module DB access, no reaching into another module's internals, even though it's physically possible. Enforced by lint/import rules. - Deploy story: docker-compose with two application containers (Node monolith + Python serving) until extraction begins. Compose profiles let devs bring up subsets. ## Non-consequences - We are **not** monolith-forever. We fully expect `integrations/` and `recommender/` to extract once Phase 2+ traffic patterns justify it. - Frontend / mobile unaffected.