The Death of the Monolith? When a Modular Monolith Beats Microservices
TL;DR: In the modular monolith vs microservices decision, choose a modular monolith when one or a few teams own the system, deploy together, and share a tightly coupled data model. Choose microservices only for a module that has to scale, deploy or fail independently, and only if you have the platform maturity to run it. The worst outcome is neither option. It is a distributed monolith.
Plenty of teams that set out to “break up the monolith” end up with something worse: a distributed ball of mud. This is a post-mortem of that failure pattern, and the cause of death is usually not the monolith.
The post-mortem: how “modular” turns into a distributed ball of mud
The sequence below is a composite of a pattern that recurs across the industry, not any one company’s story. If you have worked on enterprise modernization for a while, you will probably recognize it.
- The monolith slows down. Builds drag, a billing change breaks shipping, and releases need a coordination meeting. The diagnosis is “the monolith”; the prescription is microservices.
- Services are cut along the wrong seams, by technical layer or by noun (
CustomerService,OrderService) rather than by business capability. Every feature still touches three of them. - The database stays shared. Splitting the schema is hard, so every service connects to the same Postgres instance. The tables remain the real integration contract, now with more processes writing to them.
- Synchronous chains appear. Checkout calls pricing, which calls inventory, which calls customer. A single user request fans out into a call graph, and availability is now the product of every hop’s availability rather than the weakest link.
- Releases get coupled again. A schema change means deploying four services in a specific order. A “release train” returns, this time with Helm charts.
- Observability arrives late. The stack trace that used to point at a line of code is now spread across several logs. Distributed tracing gets added after the first severe outage.
The result is a distributed monolith: services that share databases, deploy together, and cannot function independently. You pay the network, ops and consistency costs of microservices and get none of the independence. Milan Jovanović puts it bluntly: you get the worst of both worlds.
The root cause was never the deployment topology. The original monolith had no enforced internal boundaries, and splitting it into services just distributed the coupling over the network. A modular monolith fixes that cause directly.
What a modular monolith actually is
A modular monolith is a single deployable unit made of modules that are as isolated as services would be:
- Each module owns its data. That means its own schema or set of tables, with no other module reading them directly.
- Modules talk through explicit public APIs, either in-process interfaces or in-process domain events. A module’s internals are not importable from outside.
- Boundaries are enforced by tooling, not good intentions. Violations fail CI.
Here is what that enforcement looks like in a Java/Spring codebase with Spring Modulith:
// One test that fails the build if any module reaches into another's internals
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(Application.class);
@Test
void verifiesModularStructure() {
modules.verify(); // cycles or access to non-exposed types => red build
}
}
ArchUnit does the same for plain Java, and Shopify built Packwerk for Ruby. At the database level, a module schema per bounded context plus a rule that forbids cross-schema joins gives you the property that matters most: you could extract any module later without untangling its data.
This isn’t a fringe position. Shopify considered microservices for its Rails codebase and chose enforced components instead. Martin Fowler’s MonolithFirst argues against starting new systems with microservices, partly because you rarely know the right boundaries early.
Teams that went back to a monolith
Two well-documented public reversals are useful because they explain why the distributed design hurt:
- Segment consolidated more than 140 destination services into one, citing queue blocking from one failing destination, shared libraries drifting across repos, and on-call load (Goodbye Microservices, Twilio Segment blog, 2018). They were candid about the trade-off: weaker fault isolation.
- Amazon Prime Video’s audio/video monitoring team moved a Step Functions and Lambda pipeline into a single process. They reported that “moving our service to a monolith reduced our infrastructure cost by over 90%,” largely by removing per-state-transition charges and intermediate S3 storage of video frames (summary and quotes via Michael Tsai; the original Prime Video Tech post has since been taken down).
Neither case says microservices are wrong. They say that the distribution boundary has to buy you something, whether that is independent scaling, independent deploys or fault isolation. If it doesn’t, you are paying for nothing.
Modular monolith vs microservices: comparison table
| Dimension | Modular monolith | Microservices | Distributed monolith (the trap) |
|---|---|---|---|
| Deployable units | One | Many, independent | Many, released together |
| Module communication | In-process calls and events | Network (HTTP/gRPC, messaging) | Network, mostly synchronous chains |
| Data ownership | Schema per module, one database | Database per service | Shared database |
| Transactions | Local ACID transactions are available | Sagas and eventual consistency | Ad hoc, often inconsistent |
| Independent scaling | Whole application scales together | Per service | Theoretical only |
| Fault isolation | Process-level only (bulkheads in code) | Per service, if designed for it | Cascading failures |
| Refactoring boundaries | IDE refactor and one PR | Cross-team API versioning | Painful and coordinated |
| Ops and platform cost | One pipeline and one runtime to observe | CI/CD per service, service discovery, tracing, on-call per service | All of microservices’ cost |
| Testing | Fast integration tests in one process | Contract tests plus environment management | Brittle end-to-end tests |
| Best fit | One to a few teams with a shared domain model | Many autonomous teams with clear domain seams | Nobody |
The decision checklist
Answer these honestly. Each item is a reason to extract a specific module, not a reason to split the whole system.
1. Team size and topology
- Do several teams currently wait on each other to ship? Is there evidence of this, such as merge queues, release-coordination meetings, or blocked PRs?
- Does each candidate service map to one owning team that can operate it on call?
- If you have one team, or a few that rarely collide, the coordination problem microservices solve does not exist yet. Published team-size thresholds are heuristics, not data. Treat them that way.
2. Deploy cadence
- Does one module need to release more often or on a different schedule than the rest, and is the shared pipeline actually what blocks it?
- Can the pipeline be fixed first with faster builds, trunk-based development, feature flags or module-scoped tests? A 40-minute build is a build problem, not an architecture problem.
3. Data coupling
- Can you draw the candidate’s data boundary without cross-boundary joins or shared tables?
- Does any business invariant span the boundary? For example, “an order can’t be confirmed if stock is reserved elsewhere” turns a database transaction into a saga.
- If most features write to the same aggregates, keep them in one process. Data coupling is the strongest predictor of a distributed monolith.
4. Operational maturity
Before running more than a handful of services, you should already have:
- Automated CI/CD per deployable, with rollbacks that take minutes
- Centralized logs, metrics and distributed tracing (for example, OpenTelemetry)
- Timeouts, retries with backoff, and circuit breakers as defaults
- Service-level objectives (SLOs) and on-call ownership per service
- A way to version APIs and run consumer-driven contract tests
- Platform capacity: someone owns Kubernetes or the serverless runtime, secrets and networking
If several of these boxes are empty, adding services will increase incidents faster than it increases throughput.
5. Scaling and failure profile
- Does one module have a resource profile that is measurably different from the rest, such as GPU inference, bursty ingestion or heavy batch jobs?
- Must its failure not take down checkout, login or another critical path?
- Is there a compliance boundary (PCI, PHI) that is cheaper to audit as a separate deployable?
How to read the result: If you answered “yes” across all five sections for a specific module, that module is an extraction candidate. If your “yes” answers are scattered or theoretical, build or repair a modular monolith first.
When microservices genuinely win
Microservices are the right call when the checklist lights up, and they work very well when done properly:
- Independent scaling with real cost impact, such as an ML inference path or a high-throughput Kafka consumer on its own hardware.
- Autonomous teams at scale that own clear domains and their own release schedules.
- Blast-radius isolation: payment authorization that has to stay up while reporting is on fire.
- Polyglot needs with a measured payoff, such as a latency-critical Rust or Go component next to a JVM core.
Even then, extract one module at a time with the strangler fig pattern. Route traffic through a facade, run both paths, compare outputs, then retire the old code. A module that already owns its data extracts cleanly. One that doesn’t will be a distributed ball of mud from its first day.
A pragmatic path
Map bounded contexts from business capabilities, enforce them in CI, split the schema per module, move cross-module side effects to in-process events (the seam you will later put on a broker), instrument everything, and extract only the modules that pass the checklist.
If you are looking at a legacy system and can’t tell whether you have a monolith worth modularizing or a distributed monolith worth consolidating, that assessment is exactly what our cloud modernization consulting engagements start with. For teams that want to build these skills in-house, the Legacy to Cloud Native program covers boundary design, the strangler fig pattern and Kubernetes operations over 12 weeks.
FAQ
What is the difference between a modular monolith and microservices?
A modular monolith is one deployable unit split internally into modules with enforced boundaries and private data. Microservices are separately deployed processes that communicate over the network and each own their own data store. The boundaries can be identical. What differs is whether a boundary is a function call or a network call.
What is a distributed monolith?
A distributed monolith is a system deployed as many services that cannot change, deploy or fail independently, usually because the services share a database, call each other synchronously in long chains, or have to be released together. It carries the operational cost of microservices without giving you their independence.
Is a modular monolith just a stepping stone to microservices?
Not necessarily. For many systems it is the final architecture, and Shopify chose to modularize its Rails monolith rather than split it. Clean boundaries make a later extraction cheaper, but you should only extract a service when a specific module needs to scale, deploy or fail independently.
How big should a team be before moving to microservices?
There is no proven cutoff. Microservices mainly solve teams blocking each other in one release train. If your teams can ship from one codebase without queueing, they add cost without removing a bottleneck.
How do you enforce module boundaries in a monolith?
Make violations fail the build. Use ArchUnit or Spring Modulith for Java, or Packwerk for Ruby, and give each module its own database schema with no cross-schema joins. Boundaries that live only in code review erode.
When should you extract a module into a microservice?
Extract a module when it has a measured need the monolith can’t meet: a very different scaling profile, a release cadence the shared pipeline blocks, a failure that must be isolated, or a compliance boundary. Confirm that it already owns its data and exposes a narrow API, then extract it incrementally with the strangler fig pattern.