open atlas
↑ Back to track
Architecture Patterns ARCH · 10 · 01

The Modular Monolith

A modular monolith enforces hard seams between features inside a single deployable. Package-by-feature, not by layer. Clean modules are the prerequisite for any future extraction — you earn the right to split.

ARCH Senior ◷ 22 min
Level
FoundationsJuniorMiddleSenior

The B2B billing platform started as a single Rails app organized by layer: app/models/, app/controllers/, app/services/. Eighteen months in, the team had 60 developers and a deploy that took 40 minutes. Every change to the billing logic required touching files in models/, services/, and sometimes controllers/. Nobody could tell which service classes were safe to call from billing without checking three directories. A tech lead proposed splitting into microservices. Management approved. Six months later, the team had three services that deployed in lockstep, shared a database, and were harder to change than the original monolith. What went wrong? They broke apart a mudball and got a distributed mudball. The modular structure they needed was internal discipline, not a network boundary.

Package-by-layer vs package-by-feature

The classic Rails / Spring / Django scaffold organizes code by technical role: models/, controllers/, services/, repositories/. This is package-by-layer. It feels clean at project start. It becomes a liability at scale.

Package-by-layer groups every model from every domain together. OrderService and InvoiceService and CustomerService all live in services/. When you want to understand the ordering domain, you read across four directories. When you add a feature to ordering, you scatter changes across the same four directories. Every domain change touches every layer. The coupling is invisible: nothing prevents InvoiceService from calling OrderRepository directly.

Package-by-feature (or package-by-domain) groups everything for one business capability together. The ordering/ module contains its own models, its own services, its own repositories, its own API surface. The billing/ module is a peer with its own internals. Cross-module access is explicit and controlled.

Enforcing module boundaries

The modular monolith earns its name from enforcement. Packaging files into subdirectories is not a module boundary. A boundary must be enforced so that violations are a build error, not just a code-review comment.

Enforcement mechanisms by language/ecosystem:

  • Java modules (JPMS): module-info.java declares what is exported and what is requires. Attempts to access an unexported package fail at compile time.
  • TypeScript with @typescript-eslint/no-restricted-imports: configure eslint rules to ban cross-module imports except through index re-exports. Violations fail the lint gate.
  • NestJS modules: @Module({ exports: [...] }) makes explicit what the module surface is. Injectable services not in exports are invisible to outside modules.
  • Go packages with tooling (golang.org/x/tools/go/analysis): custom analysis passes can assert that no package in internal/ordering/ is imported directly from internal/billing/.
  • ArchUnit (Java) / ts-arch (TypeScript): architecture tests that assert dependency rules. Fail the test suite if billing imports a non-public class from ordering.

The key insight is that the mechanism must be automated. If enforcing the boundary requires human review, it will erode under deadline pressure. The modular monolith is a commitment to automation.

Why this works

Why does the boundary need to be inside a single deployable and not a network boundary? Because a network boundary introduces latency, distributed failure modes, and serialization overhead for every cross-module call. An internal module boundary costs nothing at runtime: it is a compile-time or test-time constraint. You can experiment with the shape of the boundary cheaply. If you later extract a module into a service, the boundary is already defined and tested — the extraction is a mechanical operation, not an architectural redesign.

The Fowler MonolithFirst default

Martin Fowler’s “MonolithFirst” argument is not a preference for monoliths. It is a statement about information: at the start of a project, you do not know where the right service boundaries are. Domain boundaries are discovered through use, not designed upfront.

The failure mode Fowler observed repeatedly: teams start with microservices because “we know we’ll need to scale,” decompose prematurely along the wrong seams, and then face two problems simultaneously — building the product and refactoring the service boundaries. The cost of a wrong microservice boundary is high: shared data, API versioning, distributed transactions, and two teams who now must coordinate every schema change.

The modular monolith is the intermediate position: commit to clean internal structure now, defer the distribution decision until the domain is understood. When you do split, you split along seams that have been validated by real use — not speculated upfront.

Quiz

A team architect proposes: 'We should split into microservices now, while the codebase is small. It will be harder to split later when there is more code.' A senior engineer disagrees. What is the specific flaw in the architect's reasoning?

What makes a good module boundary

Not every subdirectory is a module. A good module boundary is defined by:

Explicit public API. The module exposes a surface — a set of types, functions, or interfaces — and hides its internals. Callers depend on the public API, not on implementation classes. When the internal implementation changes, only the module’s own code changes.

Single business capability. A module should map to one bounded context (unit 06) or one business capability. ordering/ handles the lifecycle of an order. billing/ handles invoicing and payment. inventory/ handles stock. Mixed-capability modules — ordering-and-billing/ — betray that the seam is wrong.

Independent test suite. A module can be tested without standing up other modules. If testing billing requires importing live code from ordering, the dependency is wrong — billing should depend on an interface or a test double for what it needs from ordering.

Low afferent coupling on internals. The module’s internal classes are not imported by anything outside the module. All external access flows through the public API. If the internal Order class is imported directly by BillingService, the boundary is already broken.

Quiz

The billing platform's codebase has an `ordering/` module and a `billing/` module. A code review reveals: BillingService imports OrderRepository from `ordering/repositories/OrderRepository` directly, bypassing the `ordering/api` public surface. An engineer argues: 'It's just a read — we're not modifying ordering data, so the boundary isn't really violated.' What is the structural problem with this reasoning?

When to split: earning the right

The modular monolith is not a permanent destination. It is the state you should be in before you split. The split is earned, not granted.

Signs you have earned the right to extract a module into a service:

  • The module’s public API is stable and has been used by multiple callers without changing contract.
  • The module has its own data store (even within a shared DB, its tables are owned exclusively by the module).
  • The module’s test suite is independent — it can be run in isolation.
  • The module’s deployment cadence is clearly different from other modules (it needs to release multiple times a week while others release monthly).
  • The team owning the module is large enough that communication overhead across the module boundary is now more expensive than the deployment boundary.

If these conditions are not met, the split will be premature and the service boundary will be wrong. The most expensive mistake in microservices is not staying monolithic too long — it is splitting too early along the wrong seams.

Quiz

The billing platform's inventory module has been in production for six months. The team considers extracting it as a service. An engineer reviews the module and finds: (a) InventoryService.getAvailableStock() is imported by both OrderService and BillingService through the public API. (b) The inventory module's database tables are also queried directly by BillingService via a shared ORM connection. (c) The inventory test suite requires a running OrderService to mock some edge cases. Which of these findings should block the extraction, and why?

Recall before you leave
  1. 01
    What is the core difference between package-by-layer and package-by-feature, and why does it matter for module boundaries?
  2. 02
    What does 'enforcing module boundaries' mean in practice, and why must enforcement be automated?
  3. 03
    What are the signs that a module has 'earned the right' to be extracted as a microservice?
Recap

The billing platform’s failed microservices migration broke apart a mud ball and got a distributed mud ball. The missing ingredient was not a network boundary — it was internal discipline. A modular monolith provides that discipline: hard seams between business capabilities, enforced by build tooling, organized by feature rather than layer.

The MonolithFirst pattern is not nostalgia for the monolith. It is a statement about information: you do not know the right service boundaries until the domain is understood through production use. Splitting prematurely locks in wrong boundaries at high cost. Splitting after a period of modular monolith discipline means splitting along seams that have been validated by real callers, stable APIs, and independent test suites.

The module boundary inside a deployable costs nothing at runtime — it is a compile-time or test-time constraint. It can be reshaped cheaply. Once it becomes a network boundary, reshaping it requires API versioning, data migration, and cross-team coordination. The modular monolith is the phase where you do the cheap experimentation so that the network boundary, when it comes, is placed correctly.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.