system-design

Monolith vs Microservices

Microservices solve an organizational problem, not a technical one. Choosing them for the wrong reason is the most expensive mistake in this repo.

Prerequisites: Availability, The 8 Fallacies Time to read: ~26 minutes


The problem

Your application is one codebase, one deployment, one database. It works. Then:

🚨 Notice what’s on that list: almost all of it is about people and process, not about throughput. That’s the correct reason to split, and it’s why the framing matters so much.


The monolith, fairly described

One deployable unit. All modules in one process, one codebase, usually one database.

🚨 A monolith is not “unstructured.” A well-built monolith has clear module boundaries, dependency rules, and separation of concerns. It just deploys as one artifact. The opposite of a monolith is distribution, not organization — and conflating the two is the most common confusion in this topic.

What it gives you, and these are substantial:

Advantage Why it matters
ACID transactions across everything No sagas, no eventual consistency, no compensation logic
A function call, not a network call ~1 ns, no timeout, no retry, no circuit breaker, no partial failure
Refactoring across boundaries Your IDE renames it everywhere; the compiler finds every caller
One thing to deploy, monitor, and debug A single stack trace tells the whole story
Simple local development Run the app. Done.
No distributed systems problems No Part 4 at all
Far cheaper One deployment, no service mesh, no per-service infrastructure

📐 Reality check: Stack Overflow served billions of page views a year from a handful of servers running a monolithic application. Shopify, GitHub, and Basecamp run large monoliths at significant scale. The monolith is not a scaling ceiling — it’s an organizational one.

Where it genuinely breaks down:


Microservices, fairly described

Independently deployable services, each owning its data, communicating over a network.

What you gain:

Advantage The real benefit
Independent deployment 🚨 The main one. Teams ship without coordinating
Independent scaling Scale only what’s hot
Technology freedom The right tool per service
Fault isolation If designed for it — see below
Team autonomy Clear ownership; smaller cognitive scope per person
Independent failure and recovery One service restarting doesn’t restart everything

What you pay — and this list is longer than people expect:

🚨 1. Availability multiplies downward.

8 services in the request path, each 99.9% available:
0.999⁸ = 99.2%  →  ~70 hours of downtime per year

Worse than any individual service. → Availability

🚨 2. Every function call becomes a network call. ~1 ns becomes ~1 ms, and now it can fail, time out, be retried, arrive twice, or arrive out of order. Every arrow needs a timeout, a retry policy, a circuit breaker, and an idempotency story. → Part 4

🚨 3. No cross-service transactions. Business operations spanning services need sagas with compensating actions — real code, real edge cases, no isolation.

4. Distributed debugging. One request touches eight services. Without distributed tracing — which is now mandatory, not optional — you cannot answer “why was this slow?”

5. Data consistency becomes eventual. Services own their data, so the same fact exists in several places and they diverge.

6. Operational multiplication. 30 services × (CI pipeline + deploy + monitoring + alerts + on-call + runbooks + secrets + dashboards).

7. Versioning and compatibility. Every inter-service contract must survive independent deployment of both sides. → Versioning

8. Local development. Running 30 services on a laptop is a genuine problem that costs teams real productivity, and the solutions (mocks, shared dev environments, service virtualization) all have downsides.

9. Cost. More instances, more networking (cross-AZ traffic is billed), a service mesh, more observability data.


The honest comparison

  Monolith Microservices
Deploy independence The reason to choose it
Transactions ✅ ACID ❌ Sagas
Latency between components ~1 ns ~1 ms + failure modes
Debugging One stack trace Distributed tracing required
Availability One thing at 99.9% Multiplies down
Scaling granularity Whole app Per service
Technology choice One Per service
Team scaling Poor past ~50 engineers ✅ Good
Operational cost Low High
Infrastructure cost Low 2–5×
Time to first feature Fast Slow

Conway’s Law, and why it decides this

“Organizations design systems that mirror their own communication structure.” — Melvin Conway, 1967

🚨 This is the actual determinant. If you have four teams and split into forty services, each team owns ten services and changes still require coordination — you got all the costs and none of the benefit. If you have forty teams sharing one monolith, coordination overhead dominates everything.

The rule that follows: “Number of services should roughly track number of teams, not number of nouns in your domain.”

The inverse Conway manoeuvre — deliberately organizing teams around the architecture you want — is how companies actually change their architecture successfully. Architecture follows org structure, so change the org structure first.

🎙️ “How many engineering teams are there? That’s the main input — microservices solve a coordination problem, so with three teams I’d stay with a modular monolith and revisit when team count makes deployment coordination the bottleneck.”

That question — “how many teams?” — is one of the strongest things you can ask in a design interview, because it shows you understand what the decision is actually about.


The modular monolith: the answer people skip

🚨 The best default for most systems, and proposing it confidently is a senior signal.

One deployable unit, with strictly enforced internal module boundaries:

app/
├── billing/          ← may only be accessed via billing/api.py
│   ├── api.py        ← the public interface
│   ├── domain/
│   └── store/        ← its own tables; nothing else touches them
├── orders/
│   ├── api.py
│   └── ...
└── shipping/

Enforcement matters — without it, boundaries erode. Options: module systems (Java modules, .NET assemblies), package-private visibility, architecture tests (ArchUnit, import-linter), dependency rules in CI, or separate schemas per module with no cross-schema queries.

What you get:

🚨 This last point is the strongest argument. Domain boundaries are hard to get right up front, and in a monolith a wrong boundary is a refactor. In microservices it’s a migration project. Get the boundaries right cheaply first.

🎙️ “I’d start with a modular monolith with enforced boundaries and separate schemas per module. It gives us the design discipline of services without the distributed systems cost, and when a module genuinely needs independent deployment or scaling, extracting it is straightforward.”


When to actually split

Split when you have a concrete, named reason:

Reason Example
Team coordination is the bottleneck Twelve teams; every release requires a coordination meeting
Radically different scaling Video transcoding needs GPUs and 100 instances; the API needs 3
Different availability requirements Payments needs four nines; the recommendation engine doesn’t
Genuine technology need ML inference in Python; the rest is Java
Compliance isolation Card data must live in a PCI-scoped environment
A genuinely independent lifecycle A partner integration that changes on someone else’s schedule

Do not split because:

🚨 The most important warning: never start a greenfield project with microservices. You don’t yet know the domain boundaries, and getting them wrong at the service level is enormously expensive to correct. Martin Fowler’s “monolith first” argument is the standard reference, and it’s right.


Sizing: how “micro” is micro?

There’s no line count. Useful heuristics:

🚨 The strongest test: “Does a typical feature require changing only this service?” If most features touch four services, the boundaries are wrong — you have a distributed monolith, the worst outcome available.

Nano-services are a real failure mode: services so small that any change spans several, so you have all the distributed cost and none of the independence.


Migrating

Big-bang rewrites fail. The strangler fig pattern is the standard approach:

flowchart LR
    C[Client] --> P[Proxy / façade]
    P -->|most routes| M[Monolith]
    P -->|extracted routes| S1[New service]
    P -.->|later| S2[Next service]
  1. Put a façade in front.
  2. Extract one bounded context — starting with something at the edge of the domain, with few dependencies (notifications, search, reporting), not the core.
  3. Route its traffic to the new service.
  4. Repeat, and stop when the pain is gone — a hybrid is a legitimate end state.

🚨 Extract the data too. A “service” sharing the monolith’s database is not a service — it’s a distributed monolith with extra steps. This is the most common half-migration failure. → Strangler Fig


⚖️ Trade-offs

Decision Gain Cost
Monolith Simplicity, transactions, speed of development, low cost Deployment coupling; whole-app scaling; blast radius
Modular monolith Boundaries + simplicity + a migration path Requires discipline and enforcement
Microservices Independent deploy and scale, team autonomy Distributed systems complexity, cost, availability multiplication
Splitting early Boundaries enforced from day one Boundaries are probably wrong, and expensive to fix
Splitting late Boundaries validated by real usage Coordination pain in the meantime

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Measure the coupling in a real system. Take the last 50 pull requests in any codebase you have access to. For each, count how many modules or services it touched. If most touch several, your boundaries are wrong — and that’s true whether they’re modules or services. This is a genuinely useful diagnostic and takes twenty minutes.

2. Feel the availability multiplication. Build a 4-service chain where each has a 1% random failure rate. Measure the end-to-end success rate — it’ll be ~96%, not 99%. Then add fallbacks to two of them and measure again.

3. Enforce a boundary. Take a monolith and add an architecture test that fails the build if orders imports from billing/internal. Watch how many existing violations you find. That number is the boundary erosion you didn’t know about.

4. Extract one module. Pick the most independent module in a codebase you know and actually try to extract it — including its data. Note where the shared tables, shared transactions, and hidden dependencies are. That exercise is what tells you whether a split is feasible.


Check yourself

1. What problem do microservices actually solve? Organizational scaling — specifically, letting many teams deploy independently without coordinating with each other. The secondary benefits (independent scaling, technology choice, fault isolation) are real but are usually achievable other ways or aren't the reason the pain exists. **Microservices do not solve throughput**: a monolith scales horizontally by running more copies, and often more efficiently, since there's no network hop between components. If your problem is "we can't handle the load," microservices are not the answer. If it's "twelve teams can't ship without a coordination meeting," they might be.
2. Why does splitting into services reduce availability? Because dependencies in series multiply. If a request must traverse eight services, all eight must be available, so 0.999⁸ ≈ 99.2% — roughly 70 hours of downtime a year, worse than any individual service. Every network hop also adds new failure modes that didn't exist in-process: timeouts, retries, partial failures, and connection exhaustion. Mitigations: reduce the number of *synchronous* dependencies in the critical path, make non-essential calls optional with timeouts and fallbacks (graceful degradation), cache aggressively, and use asynchronous messaging where a synchronous response isn't required.
3. What is a modular monolith and why is it often the right default? A single deployable unit with strictly enforced internal boundaries — modules with explicit public interfaces, private internals, and often a separate database schema each, with dependency rules enforced in CI. You get the design discipline of services (clear ownership, defined contracts, decoupled domains) while keeping ACID transactions, in-process calls, one deployment, and one stack trace. Critically, **it's a migration path**: a module with a clean interface and its own tables can be extracted into a service when there's a concrete reason. And it lets you discover whether your domain boundaries are correct while fixing them is still a refactor rather than a migration project.
4. Why is starting a greenfield project with microservices usually a mistake? Because you don't yet know where the boundaries belong. Domain boundaries emerge from understanding the domain, which comes from building it — and early-stage products change direction frequently. A wrong boundary in a monolith is a refactor your IDE can largely do; a wrong boundary between services is a data migration, an API redesign, and coordinated deploys. Meanwhile you've paid the full distributed-systems cost — sagas, tracing, service discovery, versioning, multiple pipelines — from day one, slowing down exactly the phase where iteration speed matters most. Build a modular monolith, learn the boundaries, extract when there's a reason.
5. What's the strongest test of whether your service boundaries are right? Whether a typical feature requires changing one service or several. If most features span three or four services — requiring coordinated deploys, contract changes, and cross-team planning — the boundaries don't match how the system actually changes, and you have a **distributed monolith**: all the operational cost of microservices with none of the independent-deployment benefit. Good boundaries follow the axes along which the system changes, which usually means business capabilities owned by a single team, not technical layers or database tables. Measuring this on real pull requests is a cheap and revealing diagnostic.

Further reading