Modular monolith vs microservices

"When is a modular monolith the right answer, and what forces an extraction?"

What it is

A modular monolith is a single deployable unit with enforced internal boundaries: modules expose explicit APIs, direct calls across module internals are prevented by tooling, and no module reads another module's database tables. It is not "a monolith with packages"; the enforcement is the whole point, because without it you have a big ball of mud with folders.

Microservices are independently deployable services communicating over a network, each owning its data.

The distinction that matters is not modular versus not. Both architectures require the same modelling work: find the bounded contexts, define the interfaces, own your data. The distinction is whether those modules are separated by a function call or by a network hop, and everything else follows from that one choice.

The confusion worth clearing: a "distributed monolith" is not a monolith. It is microservices with the coupling of a monolith and the operational cost of microservices, which is the worst of both. You get it by extracting services without first getting the boundaries right, usually by splitting along technical layers or by leaving a shared database in place.

The problem each solves

The monolith's problem is that at some scale one deployable becomes a coordination bottleneck. Forty engineers merging to one trunk means one team's failing test blocks everyone's release; one memory-hungry component forces the whole application onto larger instances; one team's choice of framework version constrains everyone.

The microservice's problem is that you have paid a large, permanent tax to solve a problem you may not have. The tax is not optional and it is not amortised: every service boundary is a network call that can fail partially, a contract that must be versioned, a trace that must be correlated, an eventual consistency window, an integration test that needs an environment, and an on-call rotation.

The 2010s consensus was that the tax was worth paying by default. The current position, backed by several public reversals, is that it is worth paying when a specific force demands it and not before.

Mechanics

Enforcing boundaries without a network

This is the technical crux, because "we'll be disciplined about the module boundaries" is not a mechanism.

// Java: the module system makes the boundary a compile error, not a convention.
module com.shop.orders {
    exports com.shop.orders.api;        // the contract
    // com.shop.orders.internal is NOT exported: unreachable from other modules
    requires com.shop.inventory.api;    // may use inventory's contract only
}
// Or an architecture test, which works in any language with a similar library
// (ArchUnit for Java/Kotlin, import-linter for Python, depguard for Go,
// eslint-plugin-boundaries for TypeScript). This runs in CI and fails the build.
@Test fun `modules only touch each other's public api`() {
    classes().that().resideInAPackage("..orders..")
        .should().onlyDependOnClassesThat()
        .resideInAnyPackage("..orders..", "..inventory.api..", "java..")
        .check(classes)
}

And the data boundary, which is the one people skip and the one that actually determines whether extraction is ever possible:

-- One schema per module, one database role per module. The orders module
-- physically cannot read inventory's tables, so a shortcut is a runtime
-- permission error in development rather than an architectural surprise
-- eighteen months later.
CREATE SCHEMA orders;    CREATE ROLE orders_svc;
CREATE SCHEMA inventory; CREATE ROLE inventory_svc;
GRANT USAGE ON SCHEMA orders TO orders_svc;
REVOKE ALL ON SCHEMA inventory FROM orders_svc;

With those three things (an exported API, an enforced dependency rule, and a private schema) extracting a module later is mechanical: replace the in-process call with a client, replace the shared transaction with a saga or an outbox, and deploy. Without them, extraction is a rewrite.

What the network hop actually costs

ConcernIn-process moduleNetwork service
CallFunction call, nanosecondsRPC, sub-millisecond at best, plus tail
FailureException, deterministicTimeout, retry, partial failure, unknown outcome
ConsistencyOne database transactionSaga, outbox, compensations
Refactoring an interfaceCompiler finds every callerVersion the contract, support N-1, coordinate a rollout
Debugging a requestOne stack traceDistributed trace across N services, if you built it
Integration testingRun the testProvision an environment or write contract tests
RollbackDeploy the previous artifactCoordinate across services with independent versions

Each row is a real cost with a real headcount attached. The reason to enumerate them in an interview is that "microservices are complex" is a vibe, and this table is an argument.

The extraction forces

Extract a module into a service when one of these is true and demonstrable, not when it feels cleaner:

  1. Independent scaling. One module's resource profile differs by an order of magnitude. An image processor needing GPUs, or a component whose traffic is 50 times the rest, genuinely should not be co-scaled.
  2. Independent deploy cadence for separate teams. Two teams whose release schedules genuinely conflict, where the coordination cost is measurable in delayed releases rather than annoyance.
  3. Fault isolation. A component whose failure must not take the rest down, and where in-process isolation (a bulkhead, a circuit breaker, a separate thread pool) is genuinely insufficient.
  4. Polyglot necessity. The work requires a different runtime: a Python inference service, a Rust hot path, a C++ codec.
  5. Regulatory or data-residency separation. PCI scope reduction, or data that must live in a specific jurisdiction.

Notice what is not on the list: "the codebase is large", "we want clean boundaries", "team autonomy", "it's more modern". Large codebases need modules, not services. Clean boundaries are achievable in-process and cheaper there. Team autonomy comes from ownership and deploy independence, which a monolith with feature flags and trunk-based development can also provide.

The strangler fig, when you do extract

Phase 1  Route reads through a facade in the monolith.
         Nothing has moved yet; you have created a seam.

Phase 2  Stand up the new service. Dual-write from the facade to both the
         monolith's tables and the new service. Compare on a sampled basis
         and alert on divergence.

Phase 3  Shift reads to the new service behind a flag, percentage by
         percentage, comparing results.

Phase 4  Stop writing to the old path. Verify no readers remain (log every
         access to the old tables for a full cycle before believing it).

Phase 5  Delete. The phase everyone skips, which is why organisations end up
         running both.

The property that makes this work is that every phase is independently deployable and independently reversible. A migration where step three cannot be rolled back without data loss is not a strangler fig, it is a big-bang rewrite with extra steps.

A worked example

A 45-engineer retail platform. One Rails monolith, 400,000 lines, one Postgres database, deploys twice a day. Complaints: the test suite takes 40 minutes, a failing test from any team blocks everyone, and the checkout path shares instances with a batch reporting job that periodically consumes all the memory.

Analysis before proposal. The three complaints have three different causes and only one of them is architectural.

The 40-minute test suite is a build problem. Test parallelisation and selective test execution based on changed modules typically cut this to under ten minutes. Cost: weeks. No architectural change.

The shared-blocking-deploy problem is a process problem. A merge queue plus trunk-based development with feature flags decouples merging from releasing. Cost: weeks. No architectural change.

The reporting job consuming memory and affecting checkout is a genuine extraction force: fault isolation plus a completely different resource profile, and in-process isolation is not credible because it is a memory problem in a shared process.

The proposal. Extract exactly one thing: the reporting and analytics component. It has a different resource profile, it is batch rather than interactive, its failure must not affect checkout, and it is a leaf in the dependency graph (it reads, it does not write to the transactional path), which makes it the cheapest possible first extraction.

Then invest in the monolith: enforced module boundaries via architecture tests in CI, one schema per module with separate database roles, and a merge queue. That gives 80 percent of what the team wants from microservices for a fraction of the cost, and it makes any future extraction mechanical.

The counterfactual worth pricing. Full decomposition into, say, twelve services costs roughly: a service template and deployment pipeline, distributed tracing across all of them, a contract-testing setup, twelve on-call rotations or one rotation with twelve runbooks, an integration testing strategy, and the eventual consistency work for every cross-service transaction that used to be one database transaction. Conservatively three to four engineer-years of platform work before a single feature ships faster, on a team of 45. That number is the argument, and being able to produce it is what makes this a staff-level answer rather than a preference.

Where the extraction actually goes wrong, and the thing to watch: nobody completes phase 5. Two years later the reporting queries still exist in the monolith "just in case", the dual-write is still running, and you are paying for both. Put a deletion date in the plan and treat it as a deliverable.

Production evidence

Segment published "Goodbye Microservices: From 100s of Problem Children to 1 Superstar" (2018), describing a move back to a monolith after their per-destination microservices produced an unmanageable operational surface: shared library versions drifting across services, per-service queues to monitor, and defect isolation that turned out to be worse rather than better. It is the most detailed public account of a microservices reversal.

Amazon's Prime Video team published a 2023 write-up of their audio/video monitoring service moving from a distributed serverless architecture to a monolith, reporting an infrastructure cost reduction of over 90 percent. The reported cause was the orchestration and data-transfer overhead between components dominating the actual work. The nuance worth stating: this is one team's service, not Amazon abandoning microservices, and quoting it as the latter is a mistake an interviewer may be testing for.

Shopify runs one of the largest Rails monoliths in existence and has published extensively on componentisation: enforced module boundaries, a dependency graph they actively police, and tooling to detect cross-boundary calls. It is the best public evidence that a modular monolith is a viable end state at very large scale rather than a waypoint.

Uber's DOMA (Domain-Oriented Microservice Architecture, 2020) is the counterweight from the other direction: having reached thousands of microservices, they grouped them into domains with clear interfaces and anti-corruption layers, which is an admission that unbounded service proliferation has a cost and the fix is coarser boundaries.

Google's monorepo demonstrates the orthogonal point that is often confused with this one: repository structure is independent of deployment structure. You can have one repo and a thousand services, or many repos and one deployable.

The debate

The case for microservices-first is real and should be stated fairly: if you are confident the system will need independent scaling and independent deploys, and you have the platform capability to support it, starting distributed avoids a painful extraction later. Extracting from a monolith that was never modularised is genuinely expensive, and some organisations never manage it. Conway's law also cuts this way: if you are already 200 engineers in autonomous teams, one deployable is not a realistic option regardless of technical merit.

The case against, which I hold: you cannot design correct boundaries before you understand the domain, and you understand the domain by building it. Boundaries drawn in month two are drawn from a guess, and a wrong boundary in a monolith is a refactor while a wrong boundary between services is a distributed migration. The modular monolith lets you move boundaries cheaply while you are still learning where they go.

My position: default to a modular monolith with enforced boundaries and per-module schemas. Extract when a specific, nameable force demands it, one service at a time, using a strangler fig with a deletion date. If you cannot name which of the five forces applies, you are extracting for aesthetics. The enforcement is what makes this position credible rather than lazy: a monolith without architecture tests in CI degrades into a ball of mud, and then you have neither option.

A modular monolith is the wrong answer when a component genuinely needs a different runtime, when regulatory scope must be physically separated, when organisational scale has already passed the point where one deployable is coordinatable (somewhere north of 100 engineers on one codebase, depending on tooling), or when one component's scaling profile is so different that co-scheduling wastes an order of magnitude of capacity.

Follow-up Q&A

"When is a modular monolith the right answer, and what forces an extraction?" Right answer by default, because the modelling work is identical and the operational cost is far lower: one deploy, one trace, one transaction, one rollback. The forces that justify extraction are independent scaling, independent deploy cadence for separate teams, fault isolation that in-process bulkheads cannot provide, a genuine polyglot need, and regulatory or residency separation. Not on that list: codebase size, wanting clean boundaries, or team autonomy, all of which are achievable in-process and cheaper there.

"What is a distributed monolith and how do you recognise one?" Microservices that must be deployed together. The tells: a change requires coordinated releases across several services; services share a database or read each other's tables; synchronous call chains three or four deep where any failure fails the request; and a shared library that every service must upgrade in lockstep. It has the operational cost of microservices and the coupling of a monolith. The usual cause is extracting along technical layers, or extracting without also separating the data.

"You have 45 engineers and a slow monolith. What do you do?" Diagnose before prescribing, because most monolith complaints are not architectural. A 40-minute test suite is a build problem, fixed by parallelisation and selective execution. A blocking shared trunk is a process problem, fixed by a merge queue and feature flags. Only a genuine resource-profile conflict or a fault-isolation requirement is an architectural problem. I would fix the first two, enforce module boundaries with architecture tests and per-module schemas, and extract only the one component with a demonstrable force.

"How do you actually enforce module boundaries?" Three mechanisms, and you need all three. Compile-time or CI-time dependency rules (the Java module system, ArchUnit, import-linter, depguard) so a cross-boundary import fails the build. Separate database schemas with separate roles, so a module physically cannot read another's tables. And code ownership on the module's public API directory, so changing a contract requires the owning team's review. Convention alone fails within about two quarters, and the failure is invisible until you try to extract.

"What does the Prime Video article actually say?" That one team's audio/video quality-monitoring service moved from a distributed serverless design to a monolithic one and reduced infrastructure cost by over 90 percent, because the orchestration and inter-component data transfer dominated the actual processing. It is a strong data point about the overhead of fine-grained distribution for a data-intensive pipeline. It is not Amazon abandoning microservices, and anyone citing it that way has read the headline rather than the article.

Common misconceptions

The most common is that a monolith means unmodular. The two are orthogonal: deployment topology and internal structure are independent choices, and Shopify is the standing counterexample.

The second is that microservices give you team autonomy. Autonomy comes from clear ownership and the ability to deploy without coordination, and a modular monolith with feature flags and trunk-based development provides both. What microservices give you is isolation, which is a different property with a different price.

The third is that you can design the right boundaries up front. You cannot, because boundaries encode domain understanding you do not have yet, which is precisely the argument for making them cheap to move.

Interview delivery note

Say this: "Default to a modular monolith with enforced boundaries, because the modelling work is identical either way and the operational cost is an order of magnitude lower. The forces that justify extraction are independent scaling, independent deploy cadence for separate teams, fault isolation, a genuine polyglot need, and regulatory separation. Codebase size isn't on that list. Enforcement is what makes this credible: architecture tests in CI so a cross-boundary import fails the build, and one database schema and role per module so a module physically can't read another's tables. That's also what makes a later extraction mechanical rather than a rewrite."

The depth signal is diagnosing before prescribing: "most monolith complaints turn out to be build problems or process problems, and splitting the service doesn't fix either." Then price the alternative out loud, because a number ends the argument faster than a principle. Naming the Segment and Prime Video reversals, with the correct caveat about what the Prime Video article actually claims, shows you have read the sources rather than the headlines.

Further reading

  • Segment, "Goodbye Microservices: From 100s of Problem Children to 1 Superstar" (2018).
  • Amazon Prime Video Tech Blog, "Scaling up the Prime Video audio/video monitoring service and reducing costs by 90%" (2023), read in full.
  • Shopify engineering on modular monoliths and componentisation, and Uber's Domain-Oriented Microservice Architecture (2020) for the other direction.
  • Sam Newman, Monolith to Microservices, for the strangler fig mechanics and the extraction decision criteria.