ADR-001: Modular monolith + 3 satellite services from day 1
- Status: Accepted
- Date: 2026-05-23
- Deciders: CTO / Founding Engineering
Context
We are building DemozPay, a payroll-linked fintech infrastructure platform for Ethiopia (employer payroll + employee wallet + EWA + salary loans + BNPL + Equb savings). Starting team is 8–12 engineers. The system will eventually integrate with many banks, wallets, MFIs, and KYC providers, and is expected to scale to 100k+ employees and many employers.
We considered three topologies for the backend:
- Pure monolith — one big NestJS app, one DB.
- Microservices from day 1 — ~15 services split by bounded context.
- Modular monolith with carved-out money-critical services — one monolith for product logic, with the ledger, integration gateway, and notifications carved out from day 1.
Decision
We adopt option 3: a modular monolith (apps/api) plus three satellite services (apps/ledger, apps/integration-gateway, apps/notifications).
The monolith is internally split into one Nx lib per bounded context (libs/api/identity, libs/api/payroll, libs/api/wallet, …). Nx tags + lint rules forbid cross-context imports except through public index.ts. Cross-context communication is via the outbox + Kafka.
Further service carve-outs are trigger-based (a team grows past 3 FTE on the context, a distinct scaling profile, a distinct compliance scope, or deployment bottleneck), not schedule-based.
Alternatives considered
- Pure monolith — rejected. The ledger's blast radius is too large to share a process or a database with product code. A bug in a payroll feature must never be able to corrupt the journal.
- Microservices from day 1 — rejected. With our team size, the operational overhead (~15 CI pipelines, on-call rotations per service, distributed-transaction handling, version-skew management) would consume the team. Service boundaries also take 6–9 months to stabilize; drawing them too early forces rewrites.
Consequences
Positive
- Single transactional database for the product surface — no distributed-transaction gymnastics for everyday flows.
- The Ledger Service has an isolated trust domain (different language: Go; different database; tiny API surface; dedicated review).
- The Integration Gateway centralizes circuit breakers, retries, and partner secrets in one process — the most failure-prone surface gets concentrated attention.
- Carving out a
libs/api/<context>/into a new app later is mechanical, not a rewrite, because the public boundary is already there.
Negative
- Engineers must internalize the Nx tag rules to avoid accidentally turning the monolith into a tangle.
- Cross-context calls inside the monolith still require event-based communication (outbox) instead of "just import" — small upfront cost, large maintainability win.
- We pay the cost of running a second language (Go) from day 1.
Follow-ups
- Set up Nx tags and
@nx/enforce-module-boundariesrules (initially permissive; tighten as libs are tagged). - Scaffold the three satellite services so their existence is real on disk and visible in the project graph.
- Document the carve-out triggers in
PROJECT_STRUCTURE.md(done) so future decisions are anchored to written criteria, not vibes.