| name | architecture-improvement |
| description | Default to a modular monolith with enforced internal boundaries; treat microservices as a destination after boundaries prove stable, not a starting point. Use when structuring a backend, when tempted to split into services, or when module boundaries blur. Not for the actual schema-split / service-extraction migrations (use migration-strategy + schema-design). |
| license | MIT |
Architecture Improvement (Modular Monolith Default)
Purpose
Get the benefits of modularity — clear boundaries, independent reasoning — WITHOUT prematurely paying the microservices tax (network latency, deploy multiplication, distributed debugging). Default to a modular monolith; split only when a boundary has proven stable and a real scale/team driver exists.
Universal — the modular-monolith-first decision, enforced internal boundaries, and "extract a service only after the boundary is stable" are architecture principles independent of language.
Procedure
-
Default to a modular monolith
- One deployable, but internally divided into modules with explicit boundaries
- Stripe ran a monolith to ~$1B; Shopify runs a modular monolith at massive scale
- You get modularity's benefits without N deploy pipelines, inter-service latency, and multi-service refactors
-
Enforce module boundaries inside the monolith
- Each module owns its data (no cross-module DB table access); communicate via explicit interfaces
- Enforce with tooling: package-boundary checks, import linting, or module-system encapsulation (specific tools in Implementation)
- A boundary that isn't enforced is a boundary that erodes
-
Decouple modules via events where appropriate
- In-process events / Outbox (see
async-messaging) let modules react without tight coupling
- This is the seam along which you could later extract a service — if needed
-
Resist premature microservices — they pay off past a threshold most teams never hit
- Costs: infra per service, network latency + failure modes, distributed transactions/tracing, multi-repo refactors, deploy orchestration
- Fowler's MonolithFirst: most successful microservice systems started as a monolith that grew too big and was then split — and most of those monoliths were not cleanly modularized, which is exactly why he prescribes starting monolithic AND building it modular so a later split is viable
-
Extract a service ONLY when the trigger is real
- Independent scaling need (one module's load dwarfs others), independent team ownership at scale, or a different runtime/compliance boundary
- Extract along an ALREADY-STABLE module boundary — never split a boundary that's still moving
-
Validate (validation loop)
- Check for cross-module data access / circular module deps (the monolith's failure mode); if found, fix the boundary before considering any split
- Before extracting a service, confirm the boundary hasn't changed in N months (stable) — if it's still churning, don't extract
Anti-patterns
| ❌ Anti-pattern | ✅ Correct |
|---|
| Microservices from day one "for scale" | Modular monolith; split only on a real driver |
| Modules sharing DB tables directly | Each module owns its data; communicate via interfaces |
| Boundaries by convention only | Enforced (package-boundary check / import lint / module system) |
| Extracting a service from a churning boundary | Extract only stable boundaries |
| Distributed monolith (services that must deploy together) | Either truly independent services or one monolith |
Severity tiers
| Tier | Examples | Action SLA |
|---|
| Critical | Distributed monolith (microservices that can't deploy independently — worst of both worlds); cross-service synchronous chains causing cascading failures | Re-architect; high priority |
| Major | Unenforced module boundaries eroding; modules sharing DB tables | Fix this sprint |
| Minor | Module naming inconsistency; an event that could decouple a still-acceptable direct call | Schedule within 2 sprints |
Completion Criteria
Output
- Architecture ADR:
docs/adr/ADR-NNN-modular-monolith.md — boundaries + enforcement + split criteria
- Boundary enforcement config: module/import linting
- Commit format:
refactor(arch): enforce <module> boundary / docs(adr): modular monolith decision
Implementation
TypeScript + NestJS (default)
- NestJS modules with explicit
imports/exports (encapsulation by default)
- Enforce boundaries:
eslint-plugin-boundaries / eslint-plugin-import no-restricted-paths; or Nx module boundaries if in an Nx monorepo
- Module-owned data: each module's services own its tables; cross-module via service interfaces or events (Outbox)
- Extraction seam: a module already communicating via events can become a service with minimal change
Other stacks
- Python: package boundaries +
import-linter; Django apps as modules
- Ruby: Packwerk (Shopify's tool — the reference implementation)
- Go: internal packages +
internal/ enforcement; clear package APIs
- Universal: modular-monolith-first, enforced boundaries, and extract-stable-boundaries-only are architecture principles, not framework features
Related skills
async-messaging — events decouple modules within the monolith before any split
schema-design — module boundaries often align with schema ownership
migration-strategy — extracting a service means splitting schema ownership safely
Reference
- Key insight encoded: Default to a modular monolith with enforced internal boundaries; microservices' costs (infra per service, inter-service latency, multi-service refactors) only pay off past a scale threshold most teams never hit. Per Fowler's MonolithFirst, successful microservice systems almost always started as a monolith that outgrew itself — and since most such monoliths were not cleanly modularized, the prescription is to start monolithic AND build modular boundaries so a later split is feasible.