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.
1---2name: architecture-improvement3description: 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).4license: MIT5---67# Architecture Improvement (Modular Monolith Default)89## Purpose10Get 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.1112**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.1314## Procedure15161. **Default to a modular monolith**17 - One deployable, but internally divided into modules with explicit boundaries18 - Stripe ran a monolith to ~$1B; Shopify runs a modular monolith at massive scale19 - You get modularity's benefits without N deploy pipelines, inter-service latency, and multi-service refactors20212. **Enforce module boundaries inside the monolith**22 - Each module owns its data (no cross-module DB table access); communicate via explicit interfaces23 - Enforce with tooling: package-boundary checks, import linting, or module-system encapsulation (specific tools in Implementation)24 - A boundary that isn't enforced is a boundary that erodes25263. **Decouple modules via events where appropriate**27 - In-process events / Outbox (see `async-messaging`) let modules react without tight coupling28 - This is the seam along which you could later extract a service — if needed29304. **Resist premature microservices — they pay off past a threshold most teams never hit**31 - Costs: infra per service, network latency + failure modes, distributed transactions/tracing, multi-repo refactors, deploy orchestration32 - 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 viable33345. **Extract a service ONLY when the trigger is real**35 - Independent scaling need (one module's load dwarfs others), independent team ownership at scale, or a different runtime/compliance boundary36 - Extract along an ALREADY-STABLE module boundary — never split a boundary that's still moving37386. **Validate (validation loop)**39 - Check for cross-module data access / circular module deps (the monolith's failure mode); if found, fix the boundary before considering any split40 - Before extracting a service, confirm the boundary hasn't changed in N months (stable) — if it's still churning, don't extract4142## Anti-patterns4344| ❌ Anti-pattern | ✅ Correct |45|---|---|46| Microservices from day one "for scale" | Modular monolith; split only on a real driver |47| Modules sharing DB tables directly | Each module owns its data; communicate via interfaces |48| Boundaries by convention only | Enforced (package-boundary check / import lint / module system) |49| Extracting a service from a churning boundary | Extract only stable boundaries |50| Distributed monolith (services that must deploy together) | Either truly independent services or one monolith |5152## Severity tiers5354| Tier | Examples | Action SLA |55|---|---|---|56| **Critical** | Distributed monolith (microservices that can't deploy independently — worst of both worlds); cross-service synchronous chains causing cascading failures | Re-architect; high priority |57| **Major** | Unenforced module boundaries eroding; modules sharing DB tables | Fix this sprint |58| **Minor** | Module naming inconsistency; an event that could decouple a still-acceptable direct call | Schedule within 2 sprints |5960## Completion Criteria61- [ ] Default architecture is a modular monolith (unless a documented driver justifies otherwise)62- [ ] Module boundaries enforced by tooling (not convention)63- [ ] No cross-module direct DB access64- [ ] Any service extraction justified by a real driver + a stable boundary (ADR)6566## Output67- **Architecture ADR**: `docs/adr/ADR-NNN-modular-monolith.md` — boundaries + enforcement + split criteria68- **Boundary enforcement config**: module/import linting69- **Commit format**: `refactor(arch): enforce <module> boundary` / `docs(adr): modular monolith decision`7071## Implementation7273### TypeScript + NestJS (default)74- NestJS modules with explicit `imports`/`exports` (encapsulation by default)75- Enforce boundaries: `eslint-plugin-boundaries` / `eslint-plugin-import` `no-restricted-paths`; or Nx module boundaries if in an Nx monorepo76- Module-owned data: each module's services own its tables; cross-module via service interfaces or events (Outbox)77- Extraction seam: a module already communicating via events can become a service with minimal change7879### Other stacks80- **Python**: package boundaries + `import-linter`; Django apps as modules81- **Ruby**: Packwerk (Shopify's tool — the reference implementation)82- **Go**: internal packages + `internal/` enforcement; clear package APIs83- **Universal**: modular-monolith-first, enforced boundaries, and extract-stable-boundaries-only are architecture principles, not framework features8485## Related skills86- `async-messaging` — events decouple modules within the monolith before any split87- `schema-design` — module boundaries often align with schema ownership88- `migration-strategy` — extracting a service means splitting schema ownership safely8990## Reference91- **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.