Modular monolith vs microservices: which should you build?
Most modular monoliths are not modular. The word describes an intention recorded in a wiki, while the code has one shared data layer that every feature reaches into, so the design collapses within a year and gets blamed on the monolith rather than on the absent enforcement. A modular monolith is a real architecture only when something mechanical stops the boundaries being crossed, and that is the part worth getting right.
What is a modular monolith?
One deployable unit containing several modules that each own their data and expose a narrow interface to the others. It has the boundaries of a microservice architecture and the operational profile of a single application: one build, one deploy, one process, one transaction scope when you need it.
The important part is data ownership rather than folder structure. If the orders module is the only code that reads or writes the orders tables, and everything else asks it for what it needs, the boundary is real. If the reporting code joins across orders and billing directly, there is no boundary, regardless of how the source tree is arranged.
That single rule is what makes the design worth having. It is also the rule that gets broken first, usually by a deadline and a query that would be much easier if it just joined the two tables.
How do you enforce module boundaries without a network?
With the build, not with reviews. A boundary that depends on people remembering it during code review has a predictable half-life. The mechanisms differ per language but the shape is the same: make crossing the boundary fail the build rather than fail a conversation.
In practice this is language visibility plus an architecture test plus schema separation. Package-private or internal types keep the module's insides unreachable. An architecture test asserts the allowed dependency directions and fails CI when a new import violates them. And each module gets its own database schema, with the application user for one module holding no grant on another module's tables, so a cross-module join is refused by the database rather than merged by a reviewer.
The last one does most of the work and is the one most often skipped. Permissions are a stronger design tool than convention, because they are checked at runtime by something that has no deadline pressure.
What do you keep and what do you give up?
The comparison worth making is three-way, because the plain monolith is a legitimate choice for a small system and pretending otherwise leads to modularity theatre in codebases that do not need it.
| Plain monolith | Modular monolith | Microservices | |
|---|---|---|---|
| Deploy unit | One | One | Many, independently |
| Boundary enforcement | None | Build and database grants | Network and separate repos |
| Cross-boundary change | Trivial | One commit, one deploy | Coordinated across teams |
| Transactions | Available everywhere | Available, use sparingly | Not available, use sagas |
| Scaling granularity | Whole app | Whole app | Per service |
| Operational cost | Low | Low | Platform team required |
Where does the modular monolith genuinely fall down?
At the points where one runtime is the constraint. If a module needs a GPU, or a different language runtime, or ten times the memory of everything else, it does not belong in the shared process and no amount of modularity changes that. Same for a workload that saturates the CPU in bursts and starves the request path while it does so.
It also falls down on deployment risk at a certain size. Every change redeploys everything, so a bad release in one module takes the whole application with it. That is tolerable when releases are small and frequent and rollback is quick. It becomes intolerable when the build takes forty minutes and twenty people are queuing behind it, which is a genuine signal rather than an aesthetic complaint.
And it falls down when teams start blocking each other on the release itself. Not on merges, which are usually fine, but on the readiness of a shared deploy. When that becomes routine, the modular monolith has done its job and the boundaries it established tell you exactly where to cut.
Does building modules first make extraction easier later?
Substantially, provided the data was separated too. A module that owns its own tables and is only reached through a defined interface is already a service in every respect except its transport. Extracting it means replacing an in-process call with a remote one and moving its schema to its own database, which is a mechanical job rather than an archaeology project.
A module that shares tables with three others is not close to being a service, however tidy its package structure. The extraction work in that case is the data separation, and it is the same work whether you do it before or after deciding to split, which is why it is worth doing early and independently of any microservices decision.
This is the strongest practical argument for the modular monolith: it is the only version of the work that pays off in both futures. If you never split, you have a maintainable system. If you do split, you have already done the expensive part.
How do you test whether your monolith is actually modular?
Pick your two most important modules and try to answer one question: which code outside module A reads module A's tables. In most codebases this is answerable in twenty minutes with a search for the table names, and the result is usually surprising to the people who designed the system.
Then try to compile or build one module in isolation, with the others removed. If it cannot be done, list what it dragged in. That list is the true dependency graph, as opposed to the intended one, and it is the input to any sensible discussion about splitting.
Finally, look at the database user your application connects as. If it has full access to every table, no boundary is enforced at the only layer that cannot be talked around. Splitting grants per module is a change you can make in an afternoon and it will immediately surface every violation you currently have.
Common questions
- What is a modular monolith?
- A single deployable application divided into modules that each own their data and expose a narrow interface to the rest. It has the boundaries of a microservice architecture with the operational profile of one application: one build, one deploy, one process. The defining rule is data ownership, meaning only the orders module reads or writes the orders tables, rather than any particular folder structure.
- Is a modular monolith better than microservices?
- For most systems, yes, because it provides the same boundaries at a fraction of the operational cost. Microservices become the better choice when one runtime is the constraint, such as a component needing a GPU or a different language, or when teams routinely block each other on a shared release. Until then the modular monolith keeps the design benefits without requiring a platform team.
- How do you enforce boundaries in a modular monolith?
- Through the build and the database rather than code review. Use language visibility so a module's internals are unreachable, add an architecture test that fails CI when a disallowed import appears, and give each module its own database schema with grants that prevent one module's connection from reading another's tables. Boundaries that rely on people remembering them during review do not survive deadlines.
- Does a modular monolith make it easier to move to microservices later?
- Yes, if the data was separated as well as the code. A module that owns its own tables and is only called through a defined interface is already a service apart from its transport, so extraction becomes mechanical. A module sharing tables with others still needs the data separation done, which is the expensive part and is required either way.
- When should you split a modular monolith?
- When a specific constraint appears, not on a schedule. The usual triggers are a module needing a different runtime or hardware profile, a workload that starves the request path when it runs, or teams repeatedly waiting on a shared release rather than on merges. At that point the existing module boundaries indicate where to cut, which is why establishing them first is worthwhile.