The question "should we split into microservices" usually gets answered by gut feeling, because the cost of splitting is hard to picture until you have actually paid it. By this lesson we already have: Lesson 4 showed that every hop adds latency, Lesson 9 showed what eventual consistency feels like, Lesson 10 showed how hard a distributed lock is, Lesson 11 showed why everything has to be idempotent. That is why this lesson sits at position 15, not position 3.

The lab measures that cost in numbers. Same four-step use case, same amount of work per step: running in one process gives p99 2.06 ms and 18,887 req/s; splitting it into HTTP calls gives p99 8.25 ms and 3,744 req/s — 4 times slower at the tail and 5 times less throughput. With each service failing 1% of the time, an 8-hop chain measures a success rate of 92.18%, matching the 92.27% predicted by theory. And a saga missing a compensating action left 200 orders charged but never fulfilled, with not a single error logged.

ℹ️ Measurement setup
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Three node:22-alpine containers (app1/app2/app3), each capped at 1 CPU and 256 MB, calling each other over Docker's internal network with HTTP keep-alive. The load generator is a hand-written loadgen.js, 20 connections, closed-loop, 3-second warm-up.

The single most important thing about the method: each step does exactly the same amount of work in both architectures — the same doUnitOfWork(w) function — so any difference measured comes only from how it is called. And that work is CPU-bound computation, not sleep: with sleep, the cost of a hop would be swallowed by the wait and the measurement would flatter microservices artificially.

All three containers sit on the same machine, so the network hop here is cheaper than reality (no switch, no crossing an availability zone). Your real numbers will be worse, not better.

15.1 Microservices solve an organizational problem, not a technical one

The first thing to say clearly, because it decides everything that follows: splitting a system up does not make it faster. Section 15.2 proves the opposite with measurements. What microservices actually solve are people problems:

Problem How microservices help Can a modular monolith help too?
Two teams block each other on deploy Each service deploys on its own, without waiting for anyone No — still one pipeline, one release
Blurry ownership boundaries A service boundary is a hard boundary nobody can accidentally reach across Yes, if module discipline is enforced with tooling
One hot part needs to scale on its own Replicate just that service, not the whole system No — replicating a monolith replicates everything
One part needs a different language or runtime Each service picks its own technology No
Tangled code, hard to follow, one fix breaks another spot Does not help — tangled code split up is still tangled, plus a network Yes — this is exactly what modularization is for

The last row is the most misunderstood. Very often the decision to "move to microservices" is really an attempt to fix a tangled codebase, and it never fixes it — because the boundaries you draw are only as good as your understanding of the domain at that moment, and if the code is already tangled that understanding usually is not good enough yet. The result is tangled code spread across a network: still tangled, now with debugging made harder.

This is also where Conway's law shows up: a system's structure tends to mirror the communication structure of the organization that built it. The practical consequence: if your organization has three teams with three clear responsibilities, three services is the natural shape; if your organization has one five-person team, eight services are just eight folders scattered across eight places — plus eight pipelines, eight dashboards, and eight on-call rotations.

⚠️ Pitfall: choosing microservices because it is "modern"
For a five-person team, the operational cost of microservices dwarfs any benefit: every service needs its own CI/CD, its own monitoring, its own alerts, its own on-call process, and a way to trace a request as it travels through all of them (Lesson 16). Multiply all of that by the number of services — that is real work, taking real time, while the headcount stays at five.

The cheapest sanity check: when was the last time a deploy was actually blocked by another team? If you cannot remember, the problem you are about to solve with microservices may not exist yet.

15.2 The measured cost

The lab runs the same four-step use case two ways. mode=mono: the four steps are four function calls inside one process. mode=micro: the four steps are four HTTP requests to other instances. The amount of work per step is identical.

Architecture Throughput p50 p95 p99
Monolith — 4 function calls 18,887 req/s 0.97 ms 1.89 ms 2.06 ms
Microservices — 4 HTTP hops 3,744 req/s 5.18 ms 6.09 ms 8.25 ms
Ratio 5.0x less 5.3x slower 3.2x slower 4.0x slower

And remember the measurement conditions: three containers on the same machine, no switch, no crossing to a different availability zone. With Redis in Lesson 13 we already saw a Docker-internal network round trip cost about 0.07 ms while a real round trip inside a data center costs 0.5–2 ms. Apply that same order of magnitude here and four real hops would add several extra milliseconds, not fractions of one.

But latency is not the most expensive part. The most expensive part is cumulative availability: a chain only succeeds when every link succeeds, so with $n$ services each available with probability $p$, the chain is only available $p^n$ of the time.

$$P_{\text{chain}} = p^{\,n}$$

The lab injects 1% errors into every step and counts the chain's real end-to-end success rate:

Number of hops Success (measured) Theory $0.99^n$ p99
1 98.95% 99.00% 3.08 ms
2 98.08% 98.01% 4.50 ms
4 96.01% 96.06% 9.04 ms
8 92.18% 92.27% 16.52 ms

The gap between measured and theoretical is under 0.1 percentage points at all four levels — the formula is not a rough estimate, it accurately describes what is actually happening. Translated into operational terms: five services each at 99.9% give you a chain of $0.999^5 \approx 99.5\%$, meaning downtime grows from about 43 minutes a month to about 3.6 hours a month — with no single service ever violating its own SLO.

Same four steps of work — the difference is how they get called MONOLITH — 4 function calls in 1 process 1 FAILURE DOMAIN step 1 step 2 step 3 step 4 p99 = 2.06 ms · 18,887 req/s Call cost ≈ 0. No serialization, no parsing, no timeout, no retry, no dropped connection. One database transaction wraps all four steps — ACID is still intact. MICROSERVICES — 4 HTTP hops svc 1 svc 2 svc 3 svc 4 p99 = 8.25 ms · 3,744 req/s 4 FAILURE DOMAINS. Every red arrow is a place that can time out, drop the connection, or return a 500 — and each one needs its own timeout, its own retry, its own circuit breaker (Lesson 17), and it must be idempotent (Lesson 11). Each service fails 1% of the time → a 4-hop chain succeeds 96.01% (measured). An 8-hop chain: 92.18%. There is no longer a transaction wrapping all four steps — that is the subject of section 15.3.
Real measurements, three containers on the same machine. On real infrastructure, the right-hand column is even worse.
⚠️ Pitfall: only looking at average latency when splitting a service
In the measurement above, the mean for micro is 5.34 ms while p99 is 8.25 ms — looking at the mean makes it feel like "just a few extra milliseconds," which sounds acceptable. But Lesson 1 already showed why that is the wrong number to look at: when a request has to pass through $n$ services, the probability it hits at least one slow service is $1-(1-p)^n$ — meaning each service's own p99 becomes part of the user's tail.

The two numbers you must look at together when splitting a service are p99 and cumulative error probability. Both get worse with every hop, and both are invisible on an average-latency chart.

15.3 Crossing a service boundary means losing ACID

In a monolith, "create the order — charge the card — reserve the stock" lives inside one transaction: either all three happen, or none of them do, and the database takes care of that. Once those three things belong to three services with three databases, that transaction no longer exists. Nothing at the infrastructure layer replaces it — you have to write the replacement yourself, and it is called a saga: a chain of local transactions, each step paired with a compensating action to undo it if a later step fails.

The lab builds a three-step saga across three real services, each holding its own data, then makes the third step fail 200 times in a row:

ket_qua_saga.txt
$ ./tools/micro-test.sh saga 200 1     # CO hanh dong bu
  chay 200 saga, hong o buoc 3 (inventory), compensate=1
  app1: {"order":0,"payment":0,"inventory":0} compensations=200
  app2: {"order":0,"payment":0,"inventory":0} compensations=200
  app3: {"order":0,"payment":0,"inventory":0} compensations=0

$ ./tools/micro-test.sh saga 200 0     # KHONG co hanh dong bu
  chay 200 saga, hong o buoc 3 (inventory), compensate=0
  app1: {"order":200,"payment":0,"inventory":0} compensations=0
  app2: {"order":0,"payment":200,"inventory":0} compensations=0
  app3: {"order":0,"payment":0,"inventory":0} compensations=0

Read the second result in business terms: 200 orders were created, 200 charges succeeded, and 0 times was inventory reserved. Two hundred customers paid for something that will never arrive. No exception was thrown, no error line was logged — every individual step reported success exactly because it did succeed. Only the whole is wrong, and no single service stands anywhere that can see the whole.

⚠️ Pitfall: building a saga but forgetting the compensating action
This is almost always the result of only thinking through the happy path. The failure path only runs when something goes wrong, and something going wrong is rare — so the gap does not show up in tests, it does not show up in staging, and in production it quietly accumulates.

What catches it is not an error log but a periodic reconciliation job: count orders in the "paid" state, compare against orders in the "inventory reserved" state, and alert when the two numbers diverge. If you have a saga with no reconciliation job, you have no way of knowing you are drifting — the only question is how long before someone notices, usually a customer.
🔬 Why not just pull in 2PC to "keep ACID"
Two-phase commit (2PC) promises exactly what we just lost: one atomic transaction across multiple databases. Its price is holding locks for the entire duration of both phases. That means one slow service holds locks on every other service, and one service that dies right after voting "ready" leaves a hung transaction that nobody is allowed to release.

Seen through the lens of Lesson 9: 2PC picks C and sacrifices A hard — it turns the availability of the whole system into the availability of its weakest link, exactly when you were trying to avoid that. A saga goes the other way: accept a window where data is not yet consistent, in exchange for no service ever having to wait on another.

The saga's precondition: every step and every compensating action must be idempotent (Lesson 11), because both will be retried. A compensating action that runs twice and deducts stock twice is a worse bug than the one it was meant to fix.

15.4 Where to cut

If you have decided to split, the next question decides success or failure: where the cut goes. There is exactly one principle worth remembering — cut along business bounded contexts, meaning by the work a part is responsible for, not by technical layer. And every service owns its own data: no service reads another service's tables directly.

Two ways to cut — one of them produces a distributed monolith WRONG — cutting by technical layer api-service (every endpoint of every feature) logic-service (every business rule of every feature) data-service (every table of every feature) Add one field to the order form = edit ALL THREE services and deploy them in the RIGHT ORDER, in the same release. This is a DISTRIBUTED MONOLITH: it carries the full cost of distribution (network hops, cumulative availability, sagas, tracing) but gets NONE of the benefit — deploys still have to stay in lockstep as before. RIGHT — cutting by bounded context Orders api rules own DB Payments api rules own DB Inventory api rules own DB Add one field to the order form = edit ONE service, deploy it alone, no coordination needed. The price you accept: deliberately DUPLICATED data. The Inventory service keeps its own copy of the product name instead of JOINing to Orders. Duplication here is a feature, not a bug.
One-sentence test: how many services does a small new feature touch? If the answer is always "all of them," the cut is wrong.
⚠️ Pitfall: several services sharing one database
This is the most common "split" because it is the easiest — the code gets split into several processes but they all still point at one database. The result is the worst of both worlds: coupling is untouched (changing one column means fixing every service that reads it, and you cannot be sure how many services that is), yet the transaction is gone (each service has its own connection, so there is no longer one shared transaction scope).

The blunt test: can you drop a table without asking another team first? If not, that is not a service yet — that is a module that got pushed across a network.

15.5 The practical path, and when not to split

The lowest-risk path is almost always: start with a modularized monolith — one process, but with clear module boundaries enforced by tooling (dependency checks in CI, banning cross-imports). That boundary gives you most of the organizational benefit without paying a cent for network hops, sagas, or tracing. Then only split out of the process when you see a concrete signal:

  • One part has scaling needs very different from the rest (image processing versus plain CRUD, say).
  • Two teams are genuinely blocking each other on deploy — measured by the number of releases actually delayed, not by a gut feeling.
  • One part needs a different runtime or different compliance requirements (card data, health data).
  • One part has a different risk profile: you want it to be able to fail on its own, not take the rest down with it.

When you do split, use the strangler fig approach: put a routing layer in front (this is exactly the API gateway from Lesson 4), move one route at a time to the new service, and keep the rest inside the monolith. This lets you roll back a step at a time and there is never a "big bang" day. A full rewrite is the opposite: you have to guess every boundary correctly on the very first try, while the thing you are missing is exactly the understanding of those boundaries.

Strangler fig — move one route at a time, able to roll back at every step Month 1 gateway monolith 100% of traffic Month 3 gateway /payments → new monolith (the rest) Month 9 gateway /payments /inventory monolith (shrinking) At every point in time the system is running, and rolling back is just changing one routing line Any boundary drawn wrong shows up after a few weeks of running for real — fixing it then is still cheap, because the rest hasn't been touched. A full rewrite: you must guess EVERY boundary right on the first try But understanding those boundaries is exactly what you're missing — if you already had it, you wouldn't need the rewrite.
Every route you move is a small, reversible experiment, instead of one big bet with no way back.
💡 Multiply operational cost by the number of services before deciding
Every service needs: a CI/CD pipeline, a dashboard, a set of alerts, an on-call process, an API versioning strategy, and a place in the tracing system (Lesson 16). With one service that is work you have already done. With eight services that is eight times over — and unless headcount grows too, that time gets subtracted directly from time spent building features.

A rough but useful rule of thumb: if you cannot automate spinning up a new service (repo, pipeline, monitoring, alerts) in under an hour, you are not ready for a fourth service.
🚨 A wrong cut is far more expensive than splitting late
Splitting late costs you a stretch of time living with a slightly-too-large monolith. A wrong cut leaves you with two services constantly calling each other back and forth for every operation — and fixing that means merging them back or migrating data between two live databases, i.e. a project of its own, with downtime, with real risk of data loss.

So the asymmetry is clear: the cost of waiting longer is linear, the cost of cutting wrong is a step function. When you are not sure about the business boundary, waiting is the better bet in expectation — and while you wait, build that boundary as a module first, to test whether it actually holds up.

Reproduce the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab

# --- Cost of the network hop: same 4 steps, same amount of work per step ---
./tools/micro-test.sh cost
#   mono   p99 2.06ms · 18,887 req/s
#   micro  p99 8.25ms ·  3,744 req/s     (4.0x slower at the tail · throughput /5.0)

# --- Cumulative availability: each hop fails 1% of the time ---
./tools/micro-test.sh availability
#   1 hop  98.95%  (theory 99.00%)   p99  3.08ms
#   2 hop  98.08%  (theory 98.01%)   p99  4.50ms
#   4 hop  96.01%  (theory 96.06%)   p99  9.04ms
#   8 hop  92.18%  (theory 92.27%)   p99 16.52ms

# --- Saga: 200 failures at step 3 (reserve inventory) ---
./tools/micro-test.sh saga 200 1   # with compensation    -> order=0,   payment=0,   inventory=0
./tools/micro-test.sh saga 200 0   # without compensation -> order=200, payment=200, inventory=0
#   Read the last line in business terms: 200 orders charged, 0 orders had inventory reserved.

In summary

Microservices solve an organizational problem — independent deploys, clear ownership boundaries, scaling a hot part on its own — not a performance problem. The measurements say the opposite: same four-step use case, splitting it makes p99 4 times worse and throughput 5 times lower, on three containers on the same machine where the network hop is cheaper than reality.

The part that costs more than latency is cumulative availability, and it has an exact formula rather than a rough estimate: the measured success rate (98.95% / 98.08% / 96.01% / 92.18% for 1/2/4/8 hops) matches $0.99^n$ to within 0.1 percentage points. Five services each at 99.9% give you 99.5% — from about 43 minutes to about 3.6 hours of downtime a month, with no single service ever violating its own SLO.

Crossing a service boundary means losing ACID, and its replacement — the saga — only works if you actually write the compensating action. Skip it and the system reports no error at all: the lab left 200 orders charged and 0 times inventory reserved, with every single step reporting success. What catches that is a reconciliation job, not an error log.

On where to cut: cut along business bounded contexts, each service owns its own data, and accept deliberate data duplication. Cutting by technical layer or sharing one database produces a distributed monolith — paying the full cost of distribution while getting none of the benefit. And because the cost of waiting is linear while the cost of a wrong cut is a step function, a modularized monolith is the better bet whenever you are not sure.

Lesson 16 solves the problem this lesson just created: once a request crosses four services, how do you know which hop ate the time and where it broke. Without measuring that, you cannot catch a cascading failure — which is why observability has to come before Lesson 17.

📖 References

Download the practice source code

The lab app used in this lesson: the same four-step use case runnable in both mode=mono and mode=micro, plus a /saga endpoint with and without the compensating action — every number in this lesson comes from this file:

Download app.js (/chain and /saga endpoints — 0 dependencies)

Related lessons in this series

Lesson 14: Event Sourcing & CQRS Lesson 16: Observability — Metrics, Logs & Tracing Back to the System Design roadmap

Comments