Steven's Knowledge

Modular Monolith

One deployable, real internal boundaries — the underrated middle ground between a Big Ball of Mud and microservices

Modular Monolith

A modular monolith is a monolith — one deployable, one process — with internal boundaries that the build system actually enforces. Modules can only depend on other modules they have explicitly declared a dependency on. The data model is partitioned: each module owns its tables and other modules cannot read them directly. Cross-module communication goes through declared interfaces, not through "just import this class."

This is the architecture most teams should reach for first. It captures the operational simplicity of a monolith while preserving the option to split out a microservice later if any single module's pressure justifies it. The cost of doing it well is modest; the cost of not doing it is the slow drift toward the Big Ball of Mud that gives "monolith" its bad name.

What Makes a Monolith Modular

The properties that distinguish a modular monolith from a regular one:

  • Modules are explicit. Each top-level subsystem is a named module (Java module, Maven submodule, Go package with build tags, .NET project, Rails engine, Python package). The boundary is visible in the project structure.
  • Dependencies are declared and checked. Module A can only see module B if A declares a dependency on B. Trying to import from an undeclared module fails the build, not at runtime.
  • Each module owns its tables. Module B's tables are private to B. Module A reads them by calling B's public API, not by writing SQL against B's tables.
  • Public APIs are intentional. Each module has a small, explicit interface visible to other modules. Internal classes stay internal.
  • Cross-module changes are visible. A change that crosses a boundary requires updating the interface and is reviewed as a boundary change, not as a routine refactor.

What is not required:

  • Network calls between modules. The whole point is to keep them as function calls.
  • Separate databases. One database with table ownership conventions is the common shape.
  • Separate teams. A modular monolith works for one team or many.

Why It Is Underrated

The architectural community spent five years (roughly 2014–2019) treating "monolith" as a slur. The fashion was microservices. The cost was that many teams skipped the step of organizing their monoliths well, took on microservices complexity prematurely, and discovered that distributed systems are harder than coupled in-process code.

The pendulum has swung back. Shopify, Amazon retail, Stack Overflow, Basecamp, Square — many sites you would assume run microservices in fact run modular monoliths. The microservices they have are real microservices for specific reasons, not because of an architectural philosophy.

The benefits of a modular monolith over an unorganized monolith:

  • Boundary violations fail the build, not production. Discovering coupling in a code review is cheap; discovering it during a 3am incident is expensive.
  • Splitting later is feasible. A module with clean boundaries and its own tables can become a service with substantially less effort than carving one out of tangled code.
  • Independent reasoning. Engineers can hold one module in their head without understanding the whole system.
  • Team ownership maps cleanly. Each module has an owning team, even within a single deployable.

The benefits over microservices:

  • Atomic transactions across modules — still a regular database transaction.
  • No network failures between modules — still a function call.
  • One deployment — same as a monolith.
  • Refactoring across modules is still possible, just deliberate. You cannot refactor across a service boundary at all.

The Architecture in Practice

A typical modular monolith looks like this in code organization:

src/
├── modules/
│   ├── billing/
│   │   ├── api/           ← public, callable from other modules
│   │   ├── internal/      ← private to billing
│   │   └── database/      ← billing's tables
│   ├── catalog/
│   │   ├── api/
│   │   ├── internal/
│   │   └── database/
│   ├── orders/
│   │   ├── api/
│   │   ├── internal/
│   │   └── database/
│   └── shipping/
│       ...
├── shared/                ← cross-cutting utilities
└── main.{js,go,py,...}    ← composition root

The api/ folders are the contract surface. The internal/ folders are the implementation. Build tooling (ArchUnit for JVM, depcheck rules, custom linters, ESLint import restrictions, Go's internal/ convention) makes cross-module imports of internal/ fail.

Database tables are conventionally prefixed: billing_invoices, catalog_products, orders_orders. The convention is enforceable by a linter that fails CI if a query touches another module's prefix.

Module Communication Patterns

How modules call each other matters more than it seems. Three options, increasingly decoupled:

Direct Function Call

billing.api.chargeCard(...) returns the result synchronously. Simple, fast, transactional.

This is fine for most calls. The risk: synchronous calls create implicit coupling — the calling module's behavior depends on the called module's availability and performance. Within a monolith this is a non-issue (it is the same process); but if you ever split, every direct call becomes a network call.

Domain Events

The module publishes an event ("OrderPlaced", "PaymentCompleted"); other modules subscribe. Within a monolith the event bus is in-memory. Loosely coupled, but harder to reason about — event chains can be hard to trace.

This pattern pairs naturally with the Outbox Pattern when you eventually split: events stop being in-memory and become messages on a broker.

Repository Interfaces

A module exposes a repository interface; consumers call it without knowing the implementation. Useful for read-heavy access patterns where the consumer wants the data but does not want a behavioral contract.

In practice a modular monolith uses all three, with the rule: call across module boundaries only via the published api/ package, never by reaching into internal/.

When to Split a Module Out

The modular monolith makes splitting a deliberate, supported operation. The case for splitting one module out into a service:

  • Independent scaling. The module's load profile is dramatically different from the rest.
  • Independent deployment cadence. This module changes 10x more often, or 10x less, than the rest. Coupled deploys are bad for both.
  • Independent team. A team has full ownership of this module and wants release independence from the rest.
  • Stack divergence. This module would benefit from a different language, runtime, or library that conflicts with the monolith's choices.
  • Failure isolation. A crash in this module must not take down the rest.

When none of these apply, the module stays in the monolith. The default is to keep modules; you split for a specific named reason.

Common Mistakes

  • Calling it modular without enforcement. A README that says "modules should not import each other's internals" but no build-time check is wishful thinking. The discipline rots within months without automation.
  • Shared database tables. "Just this one table is shared between billing and catalog." That sentence is how every monolith becomes a ball of mud. Either it is one module's table or it is in shared/.
  • Modules that mirror the database, not the domain. Carving by table (users-module, orders-module, line-items-module) instead of by bounded context leaves the same coupling in disguise.
  • Treating modular monolith as a way station. Many systems should stay a modular monolith forever. Splitting is a costed decision, not the next step on a path.
  • Module size mismatch. Twenty modules of 200 lines each are not modules; they are subroutines. Three modules of equal weight, each genuinely a subsystem, is the target shape.

Tooling That Helps

The discipline depends on automation. Useful tools by ecosystem:

  • Java/Kotlin: ArchUnit (rule-based architectural assertions), Java Platform Module System (JPMS), Maven submodule scoping.
  • TypeScript/JavaScript: ESLint with eslint-plugin-import (no-restricted-paths), or custom rules. NX workspaces. Rush.
  • Go: Built-in internal/ convention. Tools like go-arch-lint for explicit rules.
  • Python: import-linter (formerly layer-linter), Tach, Pyright with rules.
  • .NET: NDepend, Roslyn analyzers, project-reference structure.
  • Ruby: packwerk (Shopify's tool, the most polished in this category), Rails engines.

If your ecosystem does not have a maintained tool, write a CI step with grep that fails on forbidden import patterns. Even crude enforcement is dramatically better than none.

Further Reading

  • Simon Brown, Modular Monoliths (talk, 2017) — the talk that named the pattern.
  • Kirsten Westeinde, Deconstructing the Monolith (Shopify, 2019) — production experience at scale.
  • Ross Khanas, Modular Monolith Architecture — an extensive practitioner guide.
  • Sam Newman, Building Microservices (2nd ed.), Chapter 3 — discusses modular monolith as the alternative most teams should consider first.
  • Shopify's packwerk README — the most accessible introduction to enforced module boundaries in practice.

Pre-commit Checklist

  • Do my modules have explicit, named boundaries that the build system enforces?
  • Does each module own its own tables, with no cross-module SQL?
  • Are public APIs in a dedicated api/ (or equivalent) and internals genuinely hidden?
  • Is a boundary violation a failing build, not a code review note?
  • Are my modules carved by domain, not by data model?
  • For each module I am tempted to split out into a service, do I have a specific named reason from the "When to Split" list?

On this page