Skip to main content

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:

  1. Pure monolith — one big NestJS app, one DB.
  2. Microservices from day 1 — ~15 services split by bounded context.
  3. 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-boundaries rules (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.