|$ curl https://forge-ai.dev/api/markdown?path=docs/architecture/monolith
$cat docs/modular-monolith.md
updated Today·20-28 min read·published

Modular Monolith

ArchitectureMonolithModularityIntermediate🎯Free Tools
Introduction

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

Pair with Hexagonal inside modules and Microservices for extraction criteria.
Why Choose a Modular Monolith
DriverHow monolith helps
One product, few teamsAvoids distributed coordination cost
Evolving domain boundariesCheaper to move code than split networks
Strong consistency needsSingle DB transactions where appropriate
Simple ops / early stageOne pipeline, one artifact, one mental model
Need modularity anywayModules 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.

Modularity Boundaries

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).
module-map.txt
TEXT
1app/
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
How to Implement

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.

dependency-cruiser.fragment.js
JavaScript
1// Illustrative rules
2module.exports = {{
3 forbidden: [
4 {{
5 name: "no-deep-module-imports",
6 from: {{ path: "^src/modules/[^/]+" }},
7 to: {{ path: "^src/modules/[^/]+/(?!index)" }},
8 }},
9 ],
10}};
Anti-Patterns
  • 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

A monolith with invisible coupling is worse than either a clean modular monolith or well-bounded services — you get neither operational simplicity nor independent deployability.
Migration Path to Services

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 extractNot a signal
Team contending on one release trainConference FOMO
Module needs different SLO/scaleFolder count feels high
Compliance boundary requires isolationRewrite desire after mess

best practice

Extraction without prior modularity copies the mud onto the network. Modularize first.
Requirements That Fit / Do Not
RequirementFit?
Small team, fast product learningStrong fit
Single transactional checkoutStrong fit
Independent deploy of billing weekly vs catalog dailyWeak — consider services
Blast radius isolation for noisy tenantsMaybe — needs careful modular + runtime isolation
How to Design Modules in Practice

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 questionHealthy answerSmell
Who owns customer email changes?Identity module APIEvery module UPDATEs users table
How does billing know an order paid?Event/API from checkoutBilling queries checkout tables
Where do we put PDF generation?Adapter used by needing moduleshared/pdf used everywhere with logic
Testing a Modular Monolith
  • 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.
module-api.example.ts
TypeScript
1// checkout talks to catalog via API, not tables
2export interface CatalogApi {{
3 getProduct(id: string): Promise<Product>;
4 reserveStock(id: string, qty: number): Promise<void>;
5}}
6
7export 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}}
Operations & Delivery

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

CODEOWNERS per module creates team topology alignment without networked services.
Modular Monolith Checklist
modular-monolith-checklist.txt
TEXT
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
Mini Case Study

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.