Cruxly › System design › Advanced Trade-offs
Monolith, Modular Monolith or Microservices: a Trade-off Analysis
the architecture question as a costed decision: what distribution charges, what one deployable charges, and the trigger that flips the answer
Open the interactive lessonMost architecture arguments are opinions. This lesson turns "which architecture?" into a decision you can defend: what microservices cost in availability, latency, consistency and people, what a monolith costs in coupling and contention, what a modular monolith buys, and which facts about your organisation should decide. It does not re-teach where to cut a system; that is Decomposing a System into Services. This is the step before it: whether to cut at all.
A decision is a trade-off table, not an opinion
An architecture choice is defensible when you can name what you pay, what you get, and the fact that would change your mind. "Microservices are modern" and "monoliths are legacy" are both slogans. The staff-level move is to treat the question as a costed comparison with inputs you can measure.
There are three real options, not two:
- Monolith: one codebase, one deployable, in-process calls. Cheap to start and to reason about; coupling grows unless something stops it.
- Modular monolith: still one deployable and usually one database, but modules have declared public interfaces and the build rejects calls that bypass them.
- Microservices: many deployables, each owned by a team, each with its own data, talking over a network.
Martin Fowler's "Microservice Premium" post (13 May 2015) frames the whole debate as a premium: microservices add complexity of their own (automated deployment, monitoring, failure handling, eventual consistency), and that premium only pays when the system is complex enough that a monolith has become too big to modify and deploy. His guideline in that post is not to consider microservices unless the system is too complex to manage as a monolith. The Azure Architecture Center's microservices page (dated June 2025) lists the same two columns: benefits such as independent deployment, scaling, fault isolation and small teams, against challenges such as complexity, testing, network latency, data integrity, versioning and the skills the team needs.
:::key Verified November 2026 Fowler, "Microservice Premium", 13 May 2015, and "MonolithFirst", 3 June 2015. Azure Architecture Center, "Microservices architecture style", page dated 30 June 2025. Both are opinion and guidance, not measurements; the numbers in this lesson are either from a company's own post, named as such, or labelled arithmetic. :::
The shape of the answer. Costs of distribution are paid on every request and every release by everyone. Benefits of distribution are collected only by the parts of the system or the teams that actually need independence. So the decision is about concentration: how much of the system needs independence, and is it worth taxing the rest.
What experts say in the room. "What is the problem a network boundary solves here?" If nobody can name one (a team blocked, a component that must scale alone, a data class that must be isolated), the boundary is a cost with no benefit. What a senior checks: that the proposal states the trigger for the next change of architecture, in either direction, before it is built.
What distribution costs: latency, consistency, operations
Every network hop is a tax on availability, on latency, on consistency and on people, and the first two can be computed before you build. What follows is arithmetic and stated assumptions, not any company's measurements.
Availability multiplies. If a request needs ten services in series and each is available 99.9 percent of the time, and failures are independent, the request succeeds with probability 0.999^10 = 0.990 (99.0 percent). A year has 8,760 hours: 0.1 percent downtime is 8.8 hours, while 1.0 percent is about 87 hours. At twenty services in the chain, 0.999^20 = 0.980, about 174 hours. The same code in one process had one availability number, not ten. Retries, fallbacks and caching can claw some of it back, but each is more machinery (see SLAs, availability maths and error budgets and Overload, load shedding and cascading failure).
Tail latency multiplies. Suppose each call exceeds its own 99th percentile 1 percent of the time. A request that makes 10 calls will see at least one slow call with probability 1 - 0.99^10 = 9.6 percent; with 30 calls it is 26 percent; with 100 it is 63 percent. At 100 calls, most requests meet at least one dependency's slow tail, so the tail stops being rare. Add a fixed cost per hop (serialisation, TLS, a proxy, queueing): an assumed 1.5 ms per hop is 15 ms across ten serial hops, on top of the work itself, versus nanoseconds for in-process calls.
Consistency gets weaker. In a monolith a business operation can be one database transaction. Across services each one owns its data, so, as the Azure page puts it, the change is unlikely to be one ACID transaction and leans toward eventual consistency. The tools for this (outbox, sagas, idempotency) are covered in Event-Driven Architecture: Choreography, Orchestration and Sagas and Service-to-Service Communication: Discovery, Mesh, Identity and Compatibility; the cost of adopting them is what to count here. Every cross-service invariant becomes code, a monitor and a runbook.
Operations scale with the service count. Each service needs a build pipeline, deployment config, dashboards, alerts, secrets, a database and its backups, an on-call owner and a minimum of two running instances. Assume, as an illustration and not a published figure, 1.5 engineer-days per month of fixed upkeep per service. At 40 services that is 60 engineer-days a month, about three full-time engineers (at 20 working days each), before any feature work. A platform team can reduce the per-service figure but cannot make it zero. The number of possible pairwise integrations also grows as n(n-1)/2: 10 services give 45, 20 give 190, 40 give 780.
| Cost | Monolith | 40 microservices (illustrative arithmetic) |
|---|---|---|
| End-to-end availability, chain of N services at 99.9% each | one number, 99.9% | 0.999^N: 99.0% at N=10, 98.0% at N=20 |
| Chance a request hits some call's p99 | single | 9.6% at 10 calls, 26% at 30 |
| Business operation atomicity | one transaction | sagas and compensations |
| Upkeep at an assumed 1.5 eng-days per service-month | near zero marginal | about 60 eng-days per month |
| Possible service-to-service links | none | up to 780 |
Testing and cognitive load. A change that crosses services cannot be tested by one unit-test run. You need contract tests, a shared test environment or production-like staging, and a way to run a subset of the system locally. The engineer's mental model has to hold which service owns which fact, which calls are synchronous, and what happens when each is down. This is the cost that is hardest to measure and that most often decides whether a small team is productive.
What experts say in the room. "Each hop must buy something." The numbers are 0.999^10 and the n(n-1)/2 link count. The failure is the long synchronous chain that makes a 99.9 percent promise unachievable by arithmetic. What a senior checks: the longest synchronous call chain on the critical path, the per-service upkeep a platform team really delivers, and whether any business invariant now spans two stores.
What a monolith costs: coupling, deploy blast radius, team contention
A monolith's cost is not slowness; it is that everything is coupled by default and nothing stops it. Fowler's "Microservice Premium" is fair to monoliths: he disputes the claim that they inherently block continuous delivery, and cites large companies that deliver quickly from one codebase. The honest list of costs is shorter and more specific than the slogans.
- Coupling drifts. The Azure page notes that in a monolith, code dependencies tangle over time and a new feature can touch many parts. Nothing in a single repo stops module A reaching into B's tables. This is a discipline problem, and it is solvable inside one deployable (next section).
- Blast radius of a deploy and of a bug. One deployable means one bad release, one memory leak or one infinite loop takes down every feature. Segment's own 2018 post lists this among the costs it accepted when it consolidated: a bug that crashes the service can take down all destinations.
- Team contention. One release train means one merge queue, one test suite and one rollback decision for everyone. A flaky test or a broken module blocks every team. This grows with headcount, not with traffic, and it is the cost most often under-weighted by engineers who work alone on one part.
- Scaling is coarse. To scale one hot path you scale the whole process. That costs memory and money, not correctness, and with stateless instances behind a balancer (see Scaling Up, Scaling Out and Splitting) it is often cheap enough. Fowler's "Microservice Premium" lists growth in scale as the last of several sources of complexity, after large teams.
- Technology lock-in. One runtime, one language, one upgrade schedule. A framework major-version upgrade must happen everywhere at once.
- Shared database contention. One schema, one set of migrations, one connection pool. A long migration or a runaway query hurts every module.
The key insight is that these costs have different cures. Coupling is cured by module boundaries and a build rule. Deploy blast radius is partly cured by feature flags, canaries and fast rollback. Team contention is cured by ownership of modules and a fast pipeline. Only a few costs truly need a process boundary: independent scaling of a very different workload, hard fault isolation, a regulatory wall, or a technology that cannot live in your runtime.
:::warn Equating monolith with spaghetti When a monolith is a mess, the cause is usually missing boundaries, not the single deployable. Splitting a tangled monolith into services keeps the tangle and adds a network between its strands; this is what Decomposing a System into Services calls a distributed monolith. :::
What experts say in the room. "What exactly hurts?" The argument is to list the pain, then match each pain to the cheapest cure: a lint rule, a flag, a faster pipeline, or a process boundary. The failure to avoid is prescribing the most expensive cure for a cheap problem. What a senior checks: deploy frequency and lead time per team, the percentage of builds blocked by another team's failures, and the count of cross-module imports that bypass a public interface.
The modular monolith as the middle path
A modular monolith keeps one deployable and adds what a monolith lacks: boundaries that the build enforces. Fowler's "MonolithFirst" (3 June 2015) names this as the first of two approaches for a new system: design the monolith with strong modularity at the API boundaries and in data storage, so it can be split later. He also says he would feel more comfortable with this route if he had heard a decent number of stories where it worked out, and that most systems acquire too many dependencies between their modules to be sensibly broken apart. Boundaries only count if something enforces them, which is the point of the build rule below.
Shopify's engineering post "Under Deconstruction: The State of Shopify's Monolith" (16 September 2020, Philip Muller) is the clearest published account. Shopify says its core monolith had over 2.8 million lines of Ruby and 500,000 commits, developed since at least 2006 by hundreds of developers. Its stated aim is that developers should feel they work on a much smaller app than they really do, by dividing the code into components organised around commerce subdomains. It enforces this with Packwerk, a static analysis that finds references across component boundaries and runs on pull requests so that a change breaking the dependency graph or a component's encapsulation is rejected, plus Sorbet type signatures to define the contracts at component boundaries. The post says that at that time about a third of the 37 components used Packwerk, and that an earlier runtime call-graph tool (Wedge) was dropped because its output was noisy and the analysis took over an hour. Shopify also says it splits out services only for good reasons, because moving from one application to a distributed system increases overall complexity considerably; its examples are storefront rendering, a high-throughput read-only workload that scales on its own, and credit-card vaulting, where sensitive data should not flow through the rest of the system.
What the middle path buys.
| Property | Plain monolith | Modular monolith | Microservices |
|---|---|---|---|
| Deployables | 1 | 1 | many |
| Call between modules | direct, unrestricted | via declared interface, build-checked | network call |
| Transaction across modules | yes | yes, but limit it by design | saga |
| Data ownership | shared tables | each module owns its tables by convention or schema | each service owns its store |
| Independent release | no | no | yes |
| Independent scale or isolation | no | no | yes |
| Cost of getting a boundary wrong | high, silent | a refactor in one repo | cross-team migration |
The last row is the strongest argument. Moving a boundary inside one repository is a refactor with compiler help and one atomic commit; moving it between services is a migration with two deploys, a data move and a compatibility period. Fowler's second reason for monolith-first is exactly this: boundaries are hard to move once locked into services, so find them while they are cheap to change.
How to make a boundary real. A module exposes a small interface (a facade or a set of types) and keeps everything else private; a static check or build rule fails any import or constant reference that crosses it; each module owns its tables, and a lint rule or schema separation prevents cross-module joins and foreign keys; cross-module communication that does not need a reply goes through in-process events, which later become a real queue. Do these and a later extraction is mostly mechanical: the interface becomes an API and the tables move to a new store.
What experts say in the room. "Boundaries without enforcement are comments." The number to cite is Shopify's scale, hundreds of developers in one deployable. The failure is a module system on paper: cross-module imports creep back in and the extraction later turns out to be a rewrite. What a senior checks: that the boundary rule runs in CI and blocks merges, the count of allowed exceptions and whether it shrinks, and that each module owns its tables.
Real moves in both directions
Companies move toward services and away from them, and the published reasons are specific, not fashionable. Read each in the company's own words and note what it did not claim.
Segment, consolidating back (Alexandra Noonan, 10 July 2018). Segment's post says its pipeline receives hundreds of thousands of events per second and forwards them to over 100 types of server-side destination. It had one service per destination. The post names the pain: a single shared queue suffered head-of-line blocking, because when one destination slowed, retries flooded the queue and delayed all; shared libraries drifted across the destination repositories (over 50 new destinations meant 50 new repositories), so a change could mean redeploying dozens of services; load varied from a few events a day to thousands a second, so small services had to be scaled by hand during spikes; and each new destination, about three a month, added a repository, a queue and a service. At the low point, the post says, three full-time engineers spent most of their time keeping the system running. Segment moved the destinations into one monolithic service fed by a new component called Centrifuge, planned to consolidate the more than 140 services into one, on a single version of each of its 120 unique dependencies, and built a traffic recorder so destination tests replayed recorded HTTP responses instead of calling live endpoints. The post reports that the test suite for those destinations went from taking as long as an hour to milliseconds, that shared-library improvements rose from 32 in the microservice period to 46 in the year after the move, and that one engineer could deploy in minutes.
The post also lists what it gave up: fault isolation (a bug that crashes the service takes down every destination), cache effectiveness (in-memory caches spread over more than 3,000 processes are hit less often), and the risk that a dependency upgrade breaks several destinations at once, which it mitigated with its automated tests. What the post does not say is that microservices are wrong; the lesson is that per-destination services with highly similar code, one team and uneven load paid the tax for little independence. Note also that the post is from 2018 and Segment's later architecture is not described by it.
Shopify, staying modular (16 September 2020). As above: a large codebase, many developers, and boundaries enforced with Packwerk, with services extracted for specific reasons such as independent scaling of a read-heavy workload and isolation of sensitive card data. This is a move not made: the evidence of a company at hundreds of developers that chose enforced modularity over wholesale distribution.
The move toward services. Fowler's "MonolithFirst" notes that the successful microservice stories he knew had started as a monolith that grew too large and was then broken up, while systems built as microservices from the start often ran into serious trouble; he states his evidence is thin and the advice tentative, and he records the counter-argument that starting with services builds team habits early if the team has done it before. Treat it as a hypothesis from a thoughtful practitioner, not a law.
:::key Verified November 2026 Segment, "Goodbye Microservices", published 10 July 2018 (canonical URL now redirects to twilio.com); Shopify Engineering, 16 September 2020; Fowler, 3 June 2015. The Segment and Shopify figures are each company's own claims as of those dates; both architectures may have changed since. :::
How to migrate in either direction.
| Direction | Safe path | The step that goes wrong |
|---|---|---|
| Monolith to services | Enforce module boundaries first, extract one module behind its interface, strangle traffic over (see Decomposing a System into Services) | Shared tables left behind, so the new service still depends on the old schema |
| Services back to fewer deployables | Merge services that always change and deploy together; unify the repository and dependency versions first, then the runtime, then the data | Merging runtimes without isolating failure domains, so one bug now has a larger blast radius |
| Either | Move traffic in slices with a rollback, keep the contract tests | A big-bang cutover with no way back |
What experts say in the room. "Reversibility is part of the design." Segment's numbers (140 services, 120 dependencies, 32 to 46 library improvements) show that consolidation is a project with a plan, not a revert. The failure is treating the first decision as permanent. What a senior checks: which services always deploy together (candidates to merge), the cost of the rollback path of any migration step, and whether the stated reason for a move is measured or assumed.
Team size and ownership as an input
The decision factors are not equal; team structure comes first, and traffic usually comes last. In rough order of how often they should decide:
- Team count and release coupling. Fowler's "Microservice Premium" lists large teams as the first source of complexity and applies Conway's law: organisations split into small groups to get a modular structure. The Azure page makes the same point from the other side: a microservice should be small enough for one feature team to build, test and deploy, and large teams are less productive because communication is slower. It is the number of teams that cannot release independently that matters, not the number of engineers alone.
- Rate and independence of change. If different parts change at very different cadences (a pricing engine weekly, a ledger quarterly), separate release trains help. If everything changes together, services only add coordination.
- Independent scaling needs. A component with a very different resource profile (a GPU job, a read-only high-throughput path like Shopify's storefront rendering) justifies a boundary even in a small organisation.
- Fault isolation. If a feature's failure must not take down checkout, isolate it. Be sure the isolation is real: the Azure page notes isolation holds only if upstream services handle faults correctly.
- Data ownership. If a data set has a distinct lifecycle, retention or consistency need, it favours a boundary. If two modules need to read and write the same rows in one transaction, they probably belong in one unit.
- Regulatory or security isolation. A wall that must keep sensitive data (such as card data, per Shopify's vaulting example) away from the rest of the system is a strong reason for a separate deployable.
Fowler also warns that team size alone does not settle it: he contrasts a team of 60 with 20 services and a team of 4 with 200, and says it is not clear how service size affects the premium. Use team count as the first input, not as a formula.
A decision table an interviewer will accept.
| Situation | Default | Why |
|---|---|---|
| Early product, under about 10 engineers, boundaries unclear | Monolith, or modular monolith | Speed and learning; boundaries are cheap to move now |
| Several teams, one product, shared domain | Modular monolith with module ownership | Contention is cured by ownership and a fast pipeline, not by a network |
| One component with a very different scale or hard isolation need | Modular monolith plus that one service | Pay the tax only where it buys something |
| Many autonomous teams, stable boundaries, platform tooling in place | Microservices | Independence outweighs the tax, and boundaries are known |
| Compliance wall around one data class | Separate deployable for that class | The boundary is the requirement |
| Services that always deploy together | Merge them | They are one unit with a network in the middle |
What experts say in the room. "Draw the team boundary first; the architecture follows or fights it." The failure is services cut to a diagram that no team owns, which leave the work as coordinated as before plus a network. What a senior checks: the ratio of services to teams (a service with no owner is a liability), whether a typical feature touches one team's services or three, and whether on-call exists per service.
Writing the decision down: an architecture decision record
A decision that is not written down gets re-argued or silently reversed. An architecture decision record (ADR) is a short document with a fixed shape: context, decision, alternatives considered, consequences, and the trigger to revisit. Its value in this topic is that it forces the cost to be named at the moment the choice is made.
A useful ADR for this decision has these parts:
- Context: team count and ownership, release friction measured (lead time, blocked builds), scaling profile, isolation and data rules.
- Decision: for example, modular monolith with Payments as the first extraction candidate.
- Alternatives rejected and why: plain monolith (no enforced seams), full microservices (platform and on-call not ready, boundaries not stable).
- Cost accepted: one deployable means shared blast radius; mitigated by flags, canaries and module-level ownership. Segment's post shows the same cost listed openly when a team chooses one deployable.
- Triggers: "Extract Payments when its release cadence blocks others more than twice a quarter, or when the card-data wall requires it." A trigger is a measurable condition, not a mood.
- Review date: because the facts that justify the decision (team size, load) will change.
An example trigger (an assumption, not a benchmark). "If more than 20 percent of builds in a month fail because of another team's change, isolate that module's CI first; if it still blocks others, extract it." That tries the cheap cure before the expensive one.
:::warn Triggers without owners A trigger such as "revisit when traffic grows" with no owner and no metric never fires. Name who watches the metric and where it is graphed. :::
What experts say in the room. "What would make you change your mind?" is the interview's strongest question, and the ADR is the written answer. What a senior checks: that every ADR in this area has a cost accepted, a measurable trigger and a date.
Interview script
Say it in four moves: context, default, trigger, accepted cost. This is how a staff engineer compresses the whole analysis into a minute and survives the follow-ups.
- Context. "I'd want the team count, how often they block each other, the scaling profile, any isolation or compliance wall, and how stable the domain boundaries are." Ask two or three of these; the answers decide.
- Default. "Absent other facts I'd start with a modular monolith: one deployable and database, module interfaces enforced in the build, each module owning its tables." Add the arithmetic when asked why: "Ten synchronous hops at 99.9 percent is 99.0 percent end to end, roughly 87 hours of downtime a year versus 8.8."
- Trigger. "I'd extract a module when it needs independent scaling, a regulatory wall, or when its release cadence blocks other teams. That's the same reasoning Shopify gives for pulling out storefront rendering and card vaulting."
- Cost accepted. "One deployable means shared blast radius and a deploy that touches everyone; I'd contain that with feature flags, canary releases and module ownership. And I'd keep the decision reversible: Segment merged more than 140 services back into one when per-service overhead exceeded the independence it got."
Follow-ups to be ready for.
- "Won't a monolith not scale?" Stateless instances behind a balancer scale horizontally (see Scaling Up, Scaling Out and Splitting); the real limit is usually the database, which microservices do not remove unless the data is split too.
- "What if the interviewer says 'Netflix does it'?" Ask which of the factors apply: many autonomous teams, stable boundaries, platform tooling. A company's choice is evidence about its context, not yours.
- "How would you migrate?" Enforce module boundaries, extract one module behind its interface, move traffic in slices, keep a rollback; see Decomposing a System into Services for the strangler approach.
- "And if we already have 80 services?" Do not recommend a rewrite; find the services that always change together and merge them, as Segment did, and cut the longest synchronous chains.
Do not answer with a technology list, recite "loose coupling" without naming a cost, or present microservices as free. Do name numbers, the trigger, and the cost you accepted.
Further reading: Neal Ford, Mark Richards, Pramod Sadalage and Zhamak Dehghani, Software Architecture: The Hard Parts (chapters 4 Architectural Decomposition, 7 Service Granularity and 15 Build Your Own Trade-off Analysis); Sam Newman, Building Microservices, 2nd edition. Primary sources: Fowler's "MonolithFirst" and "Microservice Premium" (2015), Segment's "Goodbye Microservices" (2018), Shopify Engineering's "Under Deconstruction" (2020) and the Azure Architecture Center microservices page.
How to say it in the interview
"My default is a modular monolith: one deployable, one database, hard module boundaries enforced in the build. Distribution is a cost I pay only for a reason, and the reason is almost never traffic; it is the number of teams that cannot release without each other. A chain of ten synchronous calls at 99.9 percent each is 99.0 percent end to end, about 87 hours of downtime a year instead of 8.8, so each hop must buy something. I would split out a component when it has its own scaling profile, its own regulatory boundary, or a team that is blocked by shared releases. The cost I accept is a harder fault-isolation story and a deploy that touches everyone, which I contain with module ownership, feature flags and a staged rollout."
Checklist
- Write down the facts that decide: team count, release friction, scaling profile, isolation and compliance needs, boundary stability.
- Compute the availability of the longest synchronous chain on the critical path (the product of each hop's availability).
- Estimate per-service upkeep (pipeline, dashboards, on-call, database, backups) and multiply by the planned service count.
- Match each current pain to its cheapest cure before choosing a process boundary: lint rule, feature flag, faster pipeline, module ownership.
- If staying with one deployable, enforce module interfaces and table ownership in CI, and block merges that cross them.
- List services that always deploy together and treat them as merge candidates.
- Name the cost accepted for the chosen option and the mitigation (blast radius, contention, or the distribution tax).
- Record an ADR with alternatives rejected, a measurable trigger with an owner, and a review date.
- Plan migration steps with a rollback and in slices, in either direction.