Modular Monolith
A modular monolith is a single deployable unit organized into well-defined modules with explicit boundaries and dependency rules. It is not a "big ball of mud." It is often the highest-leverage default for small-to-medium teams and evolving domains — delivering modularity benefits without the distributed systems tax.
Choose it when requirements favor simple operations, transactional consistency, and fast iteration — while still preparing for future extraction if team topology or scale demands it.
info
| Driver | How monolith helps |
|---|---|
| One product, few teams | Avoids distributed coordination cost |
| Evolving domain boundaries | Cheaper to move code than split networks |
| Strong consistency needs | Single DB transactions where appropriate |
| Simple ops / early stage | One pipeline, one artifact, one mental model |
| Need modularity anyway | Modules enforce design without services |
Why not a naive monolith: without module rules, the codebase becomes accidental architecture — every class depends on everything, tests are slow, and extraction becomes a rewrite.
Draw modules around business capabilities / bounded contexts (catalog, checkout, billing, identity), not around technical layers alone. Each module should own its domain logic and data access for its tables (logical ownership even if one database).
- Public API per module (functions/types) — no deep imports of internals.
- No cross-module table writes; prefer module APIs or domain events in-process.
- Shared kernel kept tiny (IDs, clock, money primitives) — not a dumping ground.
- Dependency direction enforced by tooling (dependency cruiser, ArchUnit, import linters).
| 1 | app/ |
| 2 | modules/ |
| 3 | identity/ # public: auth ports |
| 4 | catalog/ # public: product queries |
| 5 | checkout/ # depends on catalog+identity APIs only |
| 6 | billing/ # depends on checkout events/API |
| 7 | shared/ # minimal |
| 8 | adapters/ # HTTP, DB drivers composing modules |
Start with package/module boundaries
Language features: packages, projects, or workspaces. Prefer compile-time visibility limits.
Define public surface
index/barrel or separate api package. Lint against deep imports.
Logical data ownership
Schemas or table prefixes per module; foreign keys across modules reviewed as coupling.
In-process events
Domain events dispatched synchronously/async inside process before introducing a broker.
Fitness functions
CI fails on illegal dependencies.
| 1 | // Illustrative rules |
| 2 | module.exports = {{ |
| 3 | forbidden: [ |
| 4 | {{ |
| 5 | name: "no-deep-module-imports", |
| 6 | from: {{ path: "^src/modules/[^/]+" }}, |
| 7 | to: {{ path: "^src/modules/[^/]+/(?!index)" }}, |
| 8 | }}, |
| 9 | ], |
| 10 | }}; |
- Distributed monolith mindset in one repo: fake 'services' as folders that share DB tables freely.
- God module (common/utils) that everything imports.
- Circular dependencies between modules.
- Layer-only folders (controllers/services/repositories) with no domain boundaries — see Layered page for when that helps.
- Premature extraction of modules into networked services without team/scale drivers.
danger
Extract only when requirements demand: independent scale, independent deploy cadence, hard team ownership boundaries, or polyglot needs. Path:
- 1. Harden module API and data ownership inside the monolith.
- 2. Introduce durable integration (outbox + events) at the module edge.
- 3. Strangle: route a slice of traffic to a new service implementing the same API.
- 4. Split databases last — after dual-running and contract tests prove safety.
- 5. Retire old module paths; update ADRs and C4 diagrams.
| Signal to extract | Not a signal |
|---|---|
| Team contending on one release train | Conference FOMO |
| Module needs different SLO/scale | Folder count feels high |
| Compliance boundary requires isolation | Rewrite desire after mess |
best practice
| Requirement | Fit? |
|---|---|
| Small team, fast product learning | Strong fit |
| Single transactional checkout | Strong fit |
| Independent deploy of billing weekly vs catalog daily | Weak — consider services |
| Blast radius isolation for noisy tenants | Maybe — needs careful modular + runtime isolation |
Begin with domain storytelling: list the verbs of the business (browse catalog, add to cart, pay, refund, onboard seller). Cluster verbs that share data and invariants. Those clusters become candidate modules. Challenge each candidate: can it hide its tables? Can it expose a small API? Would a new hire know where to put a feature?
Prefer modules that can be reasoned about in isolation. A checkout module that reaches into catalog tables to adjust inventory on a whim is not modular — inventory adjustments should go through a catalog/inventory API so invariants stay local.
| Design question | Healthy answer | Smell |
|---|---|---|
| Who owns customer email changes? | Identity module API | Every module UPDATEs users table |
| How does billing know an order paid? | Event/API from checkout | Billing queries checkout tables |
| Where do we put PDF generation? | Adapter used by needing module | shared/pdf used everywhere with logic |
- Unit-test domain logic inside modules with fakes at module boundaries.
- Module contract tests: other modules program against public APIs.
- Narrow integration tests for persistence adapters.
- A few journey-level E2E tests across modules — not combinatorial explosion.
- Architecture tests as CI fitness functions.
| 1 | // checkout talks to catalog via API, not tables |
| 2 | export interface CatalogApi {{ |
| 3 | getProduct(id: string): Promise<Product>; |
| 4 | reserveStock(id: string, qty: number): Promise<void>; |
| 5 | }} |
| 6 | |
| 7 | export async function addToCart( |
| 8 | catalog: CatalogApi, |
| 9 | cart: CartRepository, |
| 10 | input: {{ productId: string; qty: number; userId: string }}, |
| 11 | ) {{ |
| 12 | const product = await catalog.getProduct(input.productId); |
| 13 | await catalog.reserveStock(input.productId, input.qty); |
| 14 | return cart.add(input.userId, product, input.qty); |
| 15 | }} |
One deployable means one pipeline — a blessing for small teams. Mitigate blast radius with feature flags, module-level kill switches, and careful migration practices. Observability should still be module-aware: metrics tagged by module, logs with module fields, ownership in CODEOWNERS.
pro tip
| 1 | [ ] Modules map to capabilities, not only tech layers |
| 2 | [ ] Public APIs documented; deep imports forbidden in CI |
| 3 | [ ] Data ownership rules written (even with one DB) |
| 4 | [ ] Cross-module coupling reviewed in PRs |
| 5 | [ ] In-process events defined for decoupled reactions |
| 6 | [ ] Extraction criteria written in ADR (when we would split) |
| 7 | [ ] Observability and ownership tagged by module |
A 10-person B2B SaaS started with a modular monolith: identity, projects, billing, reporting. After two years, reporting queries stressed the primary DB. They extracted reporting as a read-model service fed by events — only because NFRs demanded it — while keeping checkout transactional in the monolith. That is evolutionary architecture guided by requirements.
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.