Modular Monolith
Concept
A modular monolith is a single deployable application — one codebase, one process, one database at the infrastructure level — internally organized into modules with enforced boundaries that mirror what a microservices split would look like, without paying microservices' operational cost. Each module represents a distinct business capability (orders, payments, inventory), owns its own internal data model and business logic, and exposes only a deliberate public interface to the rest of the application. Everything else about the module — its internal types, its private tables or schema, its helper functions — stays inaccessible from outside, enforced not by network boundaries (there are none, it's all one process) but by tooling: package/namespace visibility rules, dependency-direction linting, or a dedicated boundary-enforcement tool.
The pattern sits deliberately between two extremes. A plain monolith has no enforced internal boundaries at all — any code can reach into any other code's internals, and over time this produces the "big ball of mud" where nothing can be changed in isolation because everything is implicitly coupled to everything else. Microservices enforce boundaries at the strongest possible level — a network call, its own deployment, its own database — which guarantees isolation but adds real distributed-systems cost (network latency and failure modes, data consistency across service boundaries, service discovery, per-service operational overhead) that many teams pay before they actually need it. A modular monolith keeps the deployment simplicity of the first while borrowing the organizational discipline of the second: module boundaries are structural and enforced, but nothing crosses a network to get from one to another. Shopify is the most-cited real-world example — one of the largest Rails codebases in production, deliberately kept as a single deployable, with internal boundaries between capabilities like "Orders" and "Payments" enforced by their open-source tool Packwerk rather than by splitting into separate services.
Tradeoffs
| Approach | Deploy/ops cost | Boundary enforcement | Team scaling |
|---|---|---|---|
| Plain monolith, no enforced boundaries | Lowest — one thing to build, deploy, and monitor | None — any code can reach any other code's internals | Breaks down fast; every team's changes risk breaking every other team's code |
| Modular monolith | Same as plain monolith — still one deployable | Structural, but enforced by tooling/convention within a single process, not by the network | Each module can be owned by a distinct team with a real, checked interface — most of microservices' clarity without the network |
| Microservices | Highest — N services to build, deploy, monitor, and keep available independently | Strongest possible — a network boundary and (often) a separate database per service | Strongest team independence, including independent deploy cadence, at the cost of distributed-systems complexity every team now shares |
The row that decides which is right for a given system is team scaling versus operational cost: a modular monolith buys most of the organizational-clarity benefit microservices are usually adopted for, while deferring the real distributed-systems tax until (and unless) independent deployability specifically becomes the actual constraint.
When to use / when not to
- Use it when a monolith's organizational coupling is the pain (unrelated features breaking each other, unclear ownership) but the team isn't yet large enough, or the domain boundaries aren't yet stable enough, to justify paying for actual service boundaries.
- Use it as a deliberate staging ground before a microservices migration — a module with a genuinely clean, already-enforced interface and its own already-isolated data is a straightforward extraction candidate later; a module extracted straight out of an unstructured monolith usually isn't.
- Don't adopt it as a permanent state without actually enforcing the boundaries with tooling (see the common pitfall) — "modular" in name only, decided by convention alone with no lint rule or dependency check behind it, degrades back into a plain monolith under normal delivery pressure.
- Don't reach for actual microservices before a modular monolith's boundaries have proven themselves stable in practice — a wrong boundary is far cheaper to redraw inside one codebase (rename a package, move a folder) than after it's been extracted into a separately-deployed service with its own database and callers depending on its network contract.
- Don't force every part of a system into the same modularity granularity — some capabilities are genuinely small and stable and don't need their own enforced module; over-modularizing adds ceremony without a matching coupling problem to solve.
Common pitfall
Declaring module boundaries in documentation or team convention ("please don't import Payments internals directly from Orders") without any tooling that actually checks it. Under a delivery deadline, a "just this once" cross-module import into another module's internals is the path of least resistance, and once the first one lands without consequence, the boundary stops being real — the module structure becomes a description of how the code was originally organized rather than a constraint on how it's allowed to change. Shopify's own solution to this at scale was building Packwerk specifically to make boundary violations a CI-checkable failure rather than a code-review nicety, which is the pattern's actual load-bearing mechanism: modularity that isn't enforced by tooling is, in practice, indistinguishable from no modularity at all once a codebase is large enough that no single person is reviewing every change.
Engineering Lens
The Principal-level judgment call here isn't "modular monolith vs microservices" as a binary architecture choice — it's recognizing that the two problems people usually reach for microservices to solve (organizational clarity and independent scaling/deployability) are separable, and a system frequently needs the first long before it needs the second. The strongest design-review answer names which specific problem is actually being solved: if the real pain is "nobody knows who owns what, and everything breaks everything," a modular monolith with enforced boundaries solves that directly, at a fraction of the operational cost of a premature microservices split — and defers the genuinely harder distributed-systems problem until independent deployability is an actual, not hypothetical, requirement.
Sources
- Deconstructing the Monolith: Designing Software that Maximizes Developer Productivity — Shopify Engineering
- Under Deconstruction: The State of Shopify's Monolith — Shopify Engineering