Somewhere right now, a three-person team is drawing boxes on a whiteboard. There is an auth-service, an orders-service, a notifications-service, a message broker between them, and an API gateway in front. The product has zero users. The team has never operated a distributed system. And every box on that whiteboard is about to become a repository, a deployment pipeline, a set of environment variables, and a production alert at 2 a.m.
This post makes one argument, carefully: microservices are an organizational scaling pattern, not a technical upgrade. But before arguing against a thing, let's define it properly.
First: what are microservices, actually?
Microservices are an architectural style where an application is built as a collection of small, independently deployable services. Each service:
- owns a single business capability (payments, orders, notifications)
- runs in its own process
- owns its own data model and persistence, with no shared tables
- talks to other services over the network, through HTTP or gRPC calls, or through messages in a broker
The opposite is the monolith: one codebase, one process, one deployable artifact. A request comes in, function calls happen in-process, one database transaction commits, done.
The promise on the microservices side sounds great. Services scale on their own, deploy on their own, fail on their own, and can even be written in different languages. Netflix, Amazon, and Uber run this way, and their engineering blogs are the canon of our industry.
Here is the catch. Copying their architecture without their problems is like buying a fleet of trucks because you saw a logistics company do it. The trucks are not the business. The trucks are what the business was forced to buy once it got big.
What problem do microservices actually solve?
Strip away the conference talks and the answer is narrow.
Microservices exist so that many teams can deploy independently without coordinating with each other. That is the core of it. Everything else, like independent scaling, mixed tech stacks, and fault isolation, is real but secondary: often achievable with simpler techniques first, or only economically meaningful at a scale most systems never reach.
Amazon did not move to services primarily because a monolith could not handle the traffic. They moved because hundreds of teams were stepping on each other: merge conflicts, release trains, one team's bug blocking another team's launch. The service boundary is a social contract. It says: you own this, I own that, we talk through an API, and neither of us needs to attend the other's standup.
"Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations."
Melvin Conway, 1967
Conway's Law is usually quoted as a warning. Read it the other way and it becomes the whole justification for microservices. If your communication structure is one team in one Slack channel, your natural system design is one deployable unit. Splitting that team's codebase into nine services does not decouple anything. The same people still coordinate every change, except now they do it across network boundaries, mismatched API versions, and distributed transactions.
The cost curves cross late:
A monolith's coordination cost grows with headcount. Microservices trade that for a large fixed operational tax that grows more slowly. Below the crossover point, which usually means several independent teams and not several developers, you are paying the tax with no refund.
The bill nobody itemizes
Every microservice split converts problems you understand into problems you don't:
| Concern | Monolith | Microservices |
|---|---|---|
| Function call | Nanoseconds, in-process, type-checked, easy to refactor in your editor | A network hop: latency, retries, timeouts, serialization, partial failure, versioned contracts |
| Transaction |
BEGIN ... COMMIT, the database does the hard part |
Sagas, outbox pattern, idempotency keys, compensating actions, eventual consistency you must explain to product |
| Debugging | One stack trace, one log stream, a breakpoint | Distributed tracing (OpenTelemetry, collectors, sampling), correlation IDs, log aggregation, all before you can even ask "what happened?" |
| Refactor | Rename symbol, done, the compiler finds every caller | A cross-service migration: deprecation windows, dual writes, contract tests |
| Local dev | pnpm dev |
docker compose up times 12, or a shared staging environment everyone breaks |
| Deploy | One pipeline, one artifact, clean rollback | N pipelines, compatibility matrices, and the question "which versions are live right now?" |
None of these distributed-systems problems are unsolvable. They are just expensive, and the expense is permanent. Sagas, tracing, and contract tests are not a one-time setup fee. They are a mortgage on every feature you ship afterward.
Here is what the same checkout flow looks like once it is distributed, with the failure modes labeled honestly:
Same feature, same team. But the function call became nine failure modes, four pipelines, four schemas, and one saga.
"But we need to scale." Do you, though?
Scaling is the most common justification and the least examined one. Three facts get lost:
1. Monoliths scale horizontally just fine. A stateless Node/Express or NestJS monolith behind a load balancer scales the same way a microservice does. You add replicas. For most of its growth, Stack Overflow served one of the highest-traffic sites on the internet from a handful of servers running what is essentially a monolith. Shopify's core application has stayed a modular Rails monolith through flash-sale traffic that would terrify most microservice shops, with services extracted only at the edges. GitHub's core application has historically remained a large Rails monolith despite enormous scale. Basecamp is a monolith on purpose.
2. The bottleneck is almost never "the app tier can't be split." It is a slow query, a missing index, an N+1, an unbounded queue, or a hot row. Microservices fix none of these. They often add latency and complexity without removing the underlying bottleneck, because the query you used to do with one JOIN is now two HTTP calls and a merge in memory.
3. "Independent scaling" usually saves pennies. Unless one workload has a very different resource profile (GPU inference, video transcoding, heavy PDF rendering, and those do deserve extraction, more on that below), the money saved by scaling components separately is a rounding error next to the engineering cost of the split.
Ask around about the last ten "we can't scale" incidents on any team:
Illustrative distribution, not survey data. The point is the shape: check your own team's postmortems against it.
Postmortems overwhelmingly point at the data layer and at specific hot paths. Those are problems solved by indexes, caching, and targeted extraction, not by decomposing the entire application. Only the last bar is the one microservices actually address.
The alternative is not a big ball of mud
The honest objection to monoliths is real. Many of them rot into tangled dependency graphs where everything imports everything and no change is safe. But the cure for bad boundaries is boundaries, not networks. This is the modular monolith: one deployable, internally split into modules that behave like services without the distributed tax.
The rules are few and enforceable:
-
Modules own their domain.
orders/,payments/,inventory/, each with its own controllers, services, repositories, and (this part matters) its own tables. No module touches another module's tables. Ever. - Modules expose a small public API. A facade, a set of exported functions, or in-process events. Everything else is private.
- The boundary is enforced by tooling, not discipline. Lint it. Discipline is what fails at 6 p.m. on a Friday before a deadline.
In a TypeScript codebase this is almost free. ESLint's import rules (or dependency-cruiser, or Nx module-boundary tags) turn architectural intent into CI failures:
// eslint.config.ts, the architecture as a lint rule
{
rules: {
'import/no-restricted-paths': ['error', {
zones: [
// nobody reaches into another module's internals
{ target: './src/modules/orders',
from: './src/modules/!(orders)/**/internal/**' },
{ target: './src/modules/payments',
from: './src/modules/!(payments)/**/internal/**' },
],
}],
},
}
// modules/payments/index.ts, the module's entire public surface
export { capturePayment, refundPayment } from './internal/service';
export type { PaymentResult } from './internal/types';
// everything under internal/ is invisible to the rest of the app
For workflows that cross modules, prefer in-process domain events over direct calls. OrderPlaced fires, and the payments and notifications modules subscribe. You get the decoupled shape of an event-driven system with exactly-once delivery, zero brokers, and a stack trace that still makes sense. And if you ever extract a module, the event seam is already exactly where the network will go.
The point worth remembering: the real benefit of microservices is enforced boundaries. A modular monolith gives you the boundaries and lets you skip the enforcement-by-network part. You keep
BEGIN ... COMMIT, keep the debugger, keep one pipeline, and keep the option to split later, because well-bounded modules are exactly what extract cleanly.
When microservices are the right call
To be clear, none of this means microservices are bad. Distributed systems are a serious engineering achievement, and the companies running thousands of services are making the correct choice for their situation. The mistake is treating microservices as a maturity milestone rather than a response to specific operational pressure. A three-person startup and Amazon can both be architecting correctly while building completely different systems.
So this is not monolith absolutism. There are legitimate triggers, and you can recognize them because they are pains you have observed, not pains you are predicting:
- Team topology pressure. Multiple genuinely independent teams (say 4+ teams, 25+ engineers) whose release schedules keep colliding: release trains, merge queues, "we can't ship because their migration is mid-flight." This is the signal.
- A very different workload. One component needs GPUs, a different runtime, 100 times the scale, or a different compliance boundary (card data, health records). Extract that component. One service, not a whole architecture.
- Isolation as a product requirement. A truly untrusted or unstable subsystem, like user-submitted code execution or a flaky third-party integration, where limiting the blast radius is worth a process boundary.
- A naturally separate lifecycle. A component that ships to a different destination, like an edge worker, an embedded agent, or a public API with its own versioning contract.
Notice what is missing from this list: "microservices are more modern," "we might need to scale someday," "the resume wants Kubernetes." Also notice that the legitimate cases mostly justify extracting one thing. The path is monolith, then modular monolith, then selective extraction, and each step should be forced by evidence.
The decision, honestly drawn:
Every "no" exits early to the cheaper option. Even the final "yes" produces selective extraction, not a nine-box whiteboard.
"We'll just start with microservices so we don't have to migrate later"
This is the most tempting counterargument, so it deserves a direct answer: starting distributed does not avoid the migration. It guarantees a worse one.
Service boundaries are the hardest thing to change in a distributed system. Moving a responsibility between two modules in a monolith is an afternoon and a compiler pass. Moving it between two services is a multi-week project with dual writes and a deprecation window. And early in a product's life, your understanding of the domain is at its lowest. You will draw the boundaries wrong, because you do not yet know where the product is going. Premature microservices do not lock in good architecture. They lock in your earliest misunderstandings, at the layer where mistakes are most expensive to fix.
Field note: even teams famous for distributed systems say this out loud. Amazon's own Prime Video team published a case study describing how they consolidated a distributed, serverless monitoring pipeline into a single monolithic process, reporting an infrastructure cost reduction of over 90% for that workload. The team that helped write the microservices canon will happily merge services when the workload says so.
A monolith with clean modules keeps every option open. Boundaries wrong? Refactor, the compiler has your back. Boundaries right and one team outgrows a module? Extract it, the seam already exists and the events already flow through it. The modular monolith is the reversible decision. The early split is the irreversible one: extracting a well-bounded module is a project, but merging two services whose boundaries turned out wrong is an ordeal. When in doubt, choose the door that swings both ways.
This is not a new position. Martin Fowler made the same argument years ago in Monolith First, and named the cost side of the trade in Microservice Premium: don't pay the premium until complexity forces you to.
What if you are already in microservices?
Then this is not an argument to merge everything tomorrow. Architecture accumulates constraints the same way code does, and a big-bang consolidation is just the original mistake in reverse. Instead, stop the bleeding: resist further fragmentation unless a new split solves a pain you have actually observed. Merge services whose boundaries turned out wrong, especially the ones that always deploy together, since those are a distributed monolith already paying network prices for nothing. And put real investment into the operational floor: tracing, contracts, and local dev, because if you are going to pay the tax, at least collect the benefits. Good architecture is not about maximizing or minimizing service count. It is about matching the system to the organization and the workload you actually have.
What to do on Monday
- Default to one deployable. One repo, one pipeline, one process, replicated horizontally behind a load balancer.
- Spend the saved effort on module boundaries. Domain-owned folders, private internals, a lint rule that fails the build on cross-module reach-ins, module-owned tables.
- Use in-process domain events for cross-module workflows. They are your future extraction seams, and you get them for free.
- Fix scaling where scaling actually breaks: indexes, query shape, caching, connection pools, background-job isolation. Measure before you architect.
- Write down your extraction triggers now: team count, deploy-contention frequency, divergent workload. That way the future decision is made by evidence instead of fashion.
- If a trigger fires, extract one module. Not the system. One module, along an existing seam, with the outbox pattern at the boundary. Then measure again.
Microservices are a genuinely great answer to the question "how do fifty teams ship without meetings?" If that is not your question, do not buy the answer. Build a modular monolith you are proud of, draw your boundaries in code before you draw them over the network, and let the org chart tell you when it is time to split. It will not be shy about it.





Top comments (0)