Microservices too early: the tax you pay before you need them

Every service on a request path multiplies failure and slow tails. A readiness scorecard, the distributed tax in numbers, and when a modular monolith is the answer.

The diagram usually arrives before the users do. Twelve boxes: user-service, auth-service, notification-service, payments, billing, email, webhooks, analytics, search, media, a config service, and an API gateway in front of the lot. Behind it are three engineers and a few hundred customers. Every feature touches three services, local development needs a dozen containers, and whoever set up the cluster now spends most of the week keeping it alive rather than building anything.

Nobody chose that outcome. Each split looked sensible on the day it happened. The cost shows up later, spread thinly across every task, which is what makes it so hard to argue about without numbers.

I have worked both sides of this. I joined Acefone while it was migrating from a PHP monolith to MERN microservices, took the greenfield work, and defined the service patterns the new services follow, including Interaction Hub, which handles voice, WhatsApp messaging and WhatsApp Calls in one inbox. I also built EMPRO alone as one well-understood Node service with MongoDB, and it ran at 99.9% uptime. Both were right for where they were. The useful skill is telling which situation you are actually in, so this post puts numbers on the cost, and ends with a scorecard you can fill in.

What services actually buy you

Two things, mainly. Independent deployment: a team can ship its part without coordinating a release with everyone else. Independent scaling: the part that needs sixteen CPUs and a GPU can have them without the rest of the application following it around. Fault isolation and the freedom to use a different language are real too, though they show up less often than people expect.

Each of those solves a problem that only exists at a certain size. With forty engineers in one pipeline, a broken main branch blocks everybody and release coordination becomes a job. With five engineers, that coordination is a message in a channel. Buying the cure before the disease means you pay for the medicine every day, forever.

Fowler put the same idea more carefully in Microservice Premium: "microservices introduce complexity on their own account. This adds a premium to a project's cost and risk", and his primary guideline is to not "even consider microservices unless you have a system that's too complex to manage as a monolith". The word to notice is manage. Not "big". Not "impressive on a diagram".

The distributed tax

The price of a split has three parts, and the one everyone talks about is the smallest.

The first part is availability, and it is pure arithmetic. When a request needs every service on its path to answer, the availabilities multiply, exactly like the composite SLA of any dependency chain. Four services at 99.9% each give 0.999⁴, which is 99.60%: about 2 hours 55 minutes of expected downtime a month, against 43 minutes 50 seconds for a single service at the same level.

A table showing the effect of adding services to one request path when each is 99.9% available. One service is 99.90% and 43m 50s of downtime a month; four services are 99.60% and 2h 55m; eight are 99.20% and 5h 49m. The share of requests hitting at least one slow p99 rises from 1.0% to 7.7%, while added wire time only reaches 3.5 ms.
Each service on the path multiplies the risk. The network time, in the last column, is the part that matters least.

The second part is the slow tail, and it is the same multiplication in a different suit. Dean and Barroso's The Tail at Scale puts it starkly: a server that "typically responds in 10ms but with a 99th-percentile latency of one second" makes one user request in a hundred slow on its own, but if "a user request must collect responses from 100 such servers in parallel, then 63% of user requests will take more than one second". Four services is not a hundred, but the direction is the same: about 3.9% of requests will hit at least one hop having a bad moment, and a request is only as fast as its slowest part.

The third part is the network itself, and this is the one people argue about. It is cheap. A round trip inside the same data centre is about half a millisecond in Jeff Dean's numbers, so three extra hops cost you around 1.5 ms of wire time. Nobody's product died of 1.5 ms. What costs is everything you have to build around those hops: a timeout on each call, a retry policy, idempotency so that retries are safe, a decision about what to render when one call fails, and a way to trace one request across four processes when it does.

Here is that in code. The version that lives in one process is a query:

TypeScript
async function orderHistory(userId: string) {
  return db.query(
    `SELECT o.id, o.created_at, o.total, p.name, p.image_url
       FROM orders o
       JOIN products p ON p.id = o.product_id
      WHERE o.user_id = $1
      ORDER BY o.created_at DESC
      LIMIT 20`,
    [userId],
  )
}

The version across services is a distributed join that you now maintain by hand:

TypeScript
async function orderHistory(userId: string) {
  const [orders, user] = await Promise.all([
    orderClient.listByUser(userId, { limit: 20 }),
    userClient.get(userId),
  ])

  const products = await Promise.all(
    orders.map((order) => productClient.get(order.productId)),
  )

  return orders.map((order, i) => ({ ...order, product: products[i], user }))
}
The same order-history feature drawn two ways. As one deployable: an API process with orders, products and users modules, one JOIN against Postgres, one query, one deploy, one thing to be up. As a gateway in front of order-service, product-service and user-service, each with its own database: 22 network calls for 20 orders, four deploys, four things to be up.
Twenty orders means twenty product lookups. The N+1 query you would have caught in review is now an N+1 across the network.

The usual answers are a batch endpoint, productClient.getMany(ids), which is one more API to design, version and keep compatible, or a local copy of product names inside the order service, which means accepting that the copy is sometimes stale. Both are fine. Both are work that the JOIN did for free.

Monolith first, modular always

MonolithFirst, from June 2015, reports a pattern that has aged well: "Almost all the successful microservice stories have started with a monolith that got too big and was broken up", while "almost all the cases where I've heard of a system that was built as a microservice system from scratch, it has ended up in serious trouble." The reason is boundaries. Services "only work well if you come up with good, stable boundaries between the services", and "even experienced architects working in familiar domains have great difficulty getting boundaries right at the beginning."

That is the crux. A service boundary is a guess about your domain, cast in network protocol. Move a responsibility between modules in a monolith and it is a refactor your IDE can do. Move it between services and it is two repositories, two deploys, a migration and a compatibility window.

The alternative to guessing early is not a big ball of mud. It is a modular monolith: one deployable, with boundaries that tooling actually enforces. Shopify has written the best public account of this. Their 2019 post describes "one of the largest Ruby on Rails codebases in existence", worked on "for over a decade by more than a thousand developers", and defines the pattern plainly: "A modular monolith is a system where all of the code powers a single application and there are strictly enforced boundaries between different domains." They rejected microservices because they would mean "maintaining multiple different test & deployment pipelines and infrastructural overhead for each service".

The 2020 follow-up is the practical one: 2.8 million lines of Ruby, 37 components, and a static analysis tool, packwerk, that rejects changes that "break the dependency graph or component encapsulation before they get merged into our main branch". That is the part people skip. Folders are not boundaries. A rule in CI is a boundary.

Code
src/
  modules/
    orders/        index.ts      ← the only thing other modules may import
                   orders.service.ts
                   orders.repository.ts   ← owns the orders tables, nothing else touches them
    products/      index.ts
    payments/      index.ts
  shared/          database, auth, config
  app.ts

Two rules make this real: a module may only be imported through its index, and a module's tables belong to it alone. Enforce both in CI and you have the thing that makes extraction cheap later, because the only question left on the day you split is deployment.

MonolithModular monolithMicroservices
Deploysoneoneone per service
Boundaries held bydisciplinea rule in CIthe network
A cross-module call isa function calla function calla round trip, a timeout, a retry
Datashared freelyowned per modulea store per service
Scalesas one unitas one unitper service
Suitsone team finding the productseveral teams, one productteams that must ship and scale apart

What to have in place before the first extraction

Fowler's Microservice Prerequisites, from 2014, lists three: rapid provisioning, so "you should be able to fire up a new server in a matter of hours"; basic monitoring, because "it's essential that a monitoring regime is in place to detect serious problems quickly"; and rapid application deployment. If standing up a new service takes a fortnight of ticket-raising, you will pay that fortnight per service.

I would add a fourth, from my own mistake. If I could change one thing about Interaction Hub at Acefone, I would have put the interaction event schema under contract tests on day one. We got versioning wrong and paid for it twice during rollout. Between modules in one process, a wrong assumption about a payload is a failing build. Between services, it is a production incident with two teams in the call, and the second one you fix is more expensive than the first, because by then other consumers depend on the shape you are trying to change.

The other prerequisite is being able to follow one request across processes. In a monolith, a stack trace tells you the whole story. Across services you need a request ID on every log line at minimum and real tracing before long, which is the difference between logging a lot and being able to answer a question, the subject of logging everything and learning nothing.

Score your own readiness

Two things decide this, and they are independent. Pressure is how badly you need what services provide. Readiness is whether you can afford to run them. High pressure with low readiness is how teams end up with twelve services and no way to debug them.

A quadrant chart with readiness to run services on the horizontal axis and pressure to split on the vertical. Below 40 pressure the whole width says stay a monolith. Above it, readiness under 70 says modular monolith first and readiness over 70 says extract one service. Two points are plotted at readiness 19: pressure 10 for the scorecard defaults, and pressure 70 for the same team with a proven bottleneck.
The map the scorecard below is drawing. Most teams that ask the question are somewhere on the left-hand side.

scorecard · twelve questions, two scores, one verdict

Are you ready for microservices?

pressure to split: the problems services actually solve

readiness: whether you can afford to run them

READINESSout of 100
PRESSURE TO SPLITout of 100
VERDICT

readiness by area

the gaps, roughly in the order to close them

    the distributed tax: one request that needs every service on its path to answer

    PATH AVAILABILITYevery service must answer
    DOWN A MONTHexpected, 30.4-day month
    HITS A SLOW p99of requests, at least one hop
    WIRE TIME ADDEDnetwork only, before serialising

    expected downtime a month, one bar per service added to the path

    The weights are my judgement, not a standard: they encode Fowler's prerequisites (provisioning, monitoring, rapid deployment) and the failure modes that show up most in early splits. The tax assumes services fail independently and each is slower than its own p99 on 1% of calls, the setup from Dean and Barroso's "The Tail at Scale".

    The defaults describe a common seed-stage team: six to fifteen engineers, weekly deploys, tests that gate merges, logs searched by hand, and none of the rest in place. That scores 10 for pressure and 19 for readiness, which is a straightforward "stay a monolith". Now tick the two that change everything, a measured bottleneck and teams blocking each other weekly, and set the team to 16 to 40. Pressure jumps to 70, readiness is still 19, and the answer becomes a modular monolith first: the need is real, the ability to operate services is not there yet, and the gap list is the work.

    The tax panel underneath uses the order-history path: four services at 99.9% each, which is 99.60%, about 2 hours 55 minutes a month, 3.9% of requests hitting a slow tail somewhere, and 1.5 ms of actual wire time.

    quick check

    Your checkout request passes through a gateway and three services, each 99.9% available, and all four must answer. Roughly how much expected downtime does that path have in a month?

    Services in series multiply: 0.999⁴ is about 0.996, so 99.60%. Four tenths of a percent of a 30.4-day month is roughly 175 minutes. Only redundant copies of the same service back each other up; a chain does the opposite.

    When extracting one service is the right call

    Three reasons hold up. A measured bottleneck, where one workload needs different hardware or scales on a different curve, such as video transcoding or model inference. A different runtime or release cadence, such as a Python model that ships daily beside a Node application that ships weekly. And regulatory isolation: Shopify extracted credit card vaulting for exactly that reason, alongside storefront rendering for performance, while keeping the rest in the monolith.

    When you do it, go one at a time, and use the strangler fig approach: new behaviour is built beside the old system, and behaviour moves across a piece at a time rather than in one cutover. Move the data ownership first, keep the old path working until the new one is proven, and then delete the old path properly instead of leaving both.

    It is worth knowing the story runs the other way too. Segment described going back to a monolith in 2018 after their per-destination services grew past 140, with three full-time engineers "spending most of their time just keeping the system alive" and shared library changes so risky that versions quietly diverged. After consolidating, they made 46 improvements to those shared libraries in a year, against 32 in the year of the microservice architecture.

    And sometimes the answer is not services at all. At TRIBE, what let a small team go from nothing to over 100,000 users was not splitting the backend up. It was moving expensive work off the request path into BullMQ and AWS SQS, and running live prices over Socket.io on Redis pub/sub. A queue gives you independent scaling for the slow part without giving you a distributed system to debug, though it brings its own arithmetic, which is the backlog problem.

    questions people ask

    When should a startup move to microservices?

    When there is a measured reason, such as a workload that scales differently or teams blocking each other on releases every week, and the basics are in place: fast provisioning, monitoring, rapid deployment and contract tests. Before that, a modular monolith is cheaper and faster.

    What is a modular monolith?

    One deployable application with strictly enforced boundaries between domains. Shopify defines it as a system where all the code powers a single application and boundaries between domains are enforced, in their case by static analysis that blocks violating changes in CI.

    How many engineers do you need before microservices make sense?

    There is no magic number, but the pressure usually appears when separate teams cannot ship without coordinating, which is typically well past ten engineers on one codebase. Team size alone is not a reason; blocked releases and measured bottlenecks are.

    Are microservices slower than a monolith?

    The network itself costs about half a millisecond per round trip inside a data centre, which is nothing. The real latency cost is the tail: with four services at a 1% p99 rate, about 3.9% of requests hit at least one slow hop.

    What is the monolith first approach?

    Martin Fowler's 2015 observation that almost all successful microservice systems began as monoliths that were broken up, while systems built as microservices from scratch usually ran into serious trouble, because stable service boundaries are very hard to guess early.

    Can you split a monolith later without a rewrite?

    Yes, if the modules already have enforced boundaries and own their data. Then extraction is mostly a deployment change. The strangler fig approach moves one capability at a time rather than doing a single cutover.

    The short version

    Microservices solve coordination and scaling problems that appear at a certain size. Below that size you pay the premium and get nothing back: availability multiplies down, slow tails compound, and every call needs a timeout, a retry and a story for partial failure. The wire time is the cheap part.

    Build the modular monolith instead. One deployable, boundaries enforced in CI, each module owning its own tables. Then when a measured reason turns up, extract that one service, with contract tests on the events from day one, and see how it goes before you do the next one.

    S

    Sanjeev Sharma

    Product Engineer at Acefone, building real-time communications at carrier scale: WhatsApp, voice and IVR in one agent inbox. Built and runs PostEngage, a WhatsApp automation SaaS, on his own. Contributor to litellm and the Vercel AI SDK. Takes on a small number of consulting engagements each year.