· 11 min read
Microservices vs the modular monolith: what you actually buy
- Microservices
- Software architecture
- Distributed systems
- Monoliths
Contents · 10 sections
“Microservices” is usually presented as an architecture. It is better understood as a trade: you split one program into several that talk over a network, and in exchange for independent deployment and scaling you take on partial failure, latency, and data that no single transaction can see. Whether that trade is worth it depends almost entirely on things that aren’t technical.
This article separates the two decisions that usually get bundled together: where the boundaries go, and whether a boundary is also a network boundary. It works through what the network costs, with numbers, why data ownership matters more than deployables, and how a modular monolith gets most of the benefit for a fraction of the cost.
What a service boundary is
A microservice, in the sense Lewis and Fowler described in 2014, is a component that is independently deployable, owns its own data, and communicates with others only through a published interface: an HTTP or gRPC API, or events on a broker. It is usually owned by one team, sized so that team can understand all of it.
Two properties are doing the work, and they are independent:
- Encapsulation. Others can’t reach into your internals or your tables. They go through an interface you control, so you can change everything behind it.
- Separate processes. Each component is built, deployed, scaled and fails on its own.
You can have the first without the second. That combination, strong module boundaries inside one deployable, is the modular monolith, and it is the baseline every microservice decision should be measured against.
A network call is not a function call
Replacing an in-process call with a remote one looks like a refactoring. It isn’t. The “fallacies of distributed computing” (Deutsch and others at Sun, in the 1990s) list what programmers tend to assume about the network: that it is reliable, that latency is zero, that bandwidth is infinite, that topology doesn’t change. A function call really does have those properties. A network call has none of them.
The most important new failure mode is the ambiguous outcome. A function either returns or throws. A remote call can also time out, and a timeout means only that you stopped waiting. The request may never have arrived, may have failed, or may have succeeded with the response lost on the way back. Every remote write has to be designed around this: idempotency keys so it can be retried safely, and a way to ask “did that happen?” afterwards. In a monolith, none of that code exists, because the question never comes up.
Availability and latency compound
If a request needs n services that each succeed independently with probability a, the request succeeds with probability:
At a = 99.9% and ten services on the path, A ≈ 99.0%: about seven hours a month in which some request fails, from components that are each down for only 43 minutes. The services aren’t worse; there are just more things that must all work at once.
Latency compounds the same way, but in the tail. Dean and Barroso’s “The Tail at Scale” (2013) points out that if a request fans out to n backends in parallel and waits for all of them, it is as slow as the slowest. If each backend has a 1% chance of a slow response:
With 100 backends, 63% of requests see a p99 response. The tail becomes the typical case.

The mitigations are real, and later articles cover them: timeouts and circuit breakers, hedged requests, caching, and asynchronous messaging so a dependency being down delays work instead of failing it. But they are engineering you only have to do because of the boundary.
Data ownership is the real boundary
The most common failed migration splits the code into services and leaves them sharing one database. Every service reads every table, so a column change still needs every team to agree and deploy together. This is the distributed monolith: the coupling of a monolith with the failure modes of a distributed system.

Once each service owns its data, two everyday operations change shape:
- Reads across boundaries become API calls (composition at request time), or a local copy kept up to date from the owner’s events. The copy is fast and survives the owner being down, but it is eventually consistent.
- Writes across boundaries lose atomicity. “Create the order and reserve the stock” used to be one transaction; now it is a saga of local transactions with compensations, covered in the sagas article.
Finding boundaries
Good boundaries follow the domain, not the technical layers. Domain-Driven Design’s bounded context (Evans, 2003) is the standard tool: a region of the system inside which every term has one meaning. “Customer” in billing (a payer with a tax ID) and “customer” in shipping (an address and a delivery window) are different models, and forcing them into one shared table is how coupling starts.
Boundaries also follow teams, whether you plan for it or not. Conway’s law (1968) observes that systems end up mirroring the communication structure of the organisation that builds them. Splitting a system that one team owns rarely pays off. Splitting it so that each of several teams can ship without coordinating is the main thing microservices are for. Skelton and Pais’s Team Topologies calls designing the teams first the “inverse Conway manoeuvre”.
Heuristics that help in practice:
- Split where the data is naturally owned by one part of the business, and changes for its own reasons.
- Prefer boundaries that let most requests be served by one component without calling others.
- Keep things that change together, together. If two components are always in the same pull request, they are one component.
- Look for different operational needs: a CPU-heavy image pipeline, a component that must stay up when the rest is down, a regulated data store.
The modular monolith
A modular monolith is one deployable with the internal discipline of microservices. Each module has a public interface, owns its schema (other modules may not query its tables), and the rules are enforced by tooling, not convention: package visibility, an architecture test in CI, or a dependency checker such as Shopify’s Packwerk or Spring Modulith.
It keeps the cheap things from the monolith:
- One build, one deploy, one place to look for logs and stack traces.
- Cross-module calls are function calls: no serialisation, no timeouts, no partial failure.
- A request that does span modules can still use one database transaction when it really has to.
- Refactoring a boundary is a code change your IDE understands, not a migration across two services.
And it keeps the option open. If a module later needs to scale or deploy separately, it already has an interface and its own data, so extracting it is mostly an operational change. Fowler’s “MonolithFirst” (2015) argues for exactly this order: it is much easier to find the right boundaries in a running system than to guess them upfront, and moving a boundary between services is expensive.
What you actually buy
Splitting into services, done well, buys specific things:
- Independent deployment. A team ships when it is ready, without a release train. At a few hundred engineers this is usually the decisive benefit.
- Independent scaling and resources. The search service gets memory, the video pipeline gets GPUs, and neither pays for the other.
- Fault isolation, but only if you design for it. A crash in one service doesn’t take down the others. A slow service still can, through blocked threads and retries, unless every caller has timeouts and breakers.
- Technology choice per component, which is useful occasionally and costly as a habit: every extra stack is something to patch, hire for and operate.
The bill: a platform for deploying and discovering services, distributed tracing to debug anything, contract tests instead of a compiler checking calls, eventual consistency in the product, and an on-call rotation that has to understand the interactions. Companies have publicly gone back the other way when that bill outgrew the benefit. Segment merged its many destination services back into one in 2018, and a Prime Video team cut costs by 90% by collapsing a distributed monitoring pipeline into a single process in 2023.
When to split
Signals that a boundary should become a network boundary:
- Teams are blocked on each other’s releases, and the modules’ boundaries are already stable.
- One component’s resource profile or scaling curve is very different from the rest.
- One component must stay available, or be isolated for security or compliance, when the rest is not.
- The build and test cycle of the whole is slow enough to hurt, and it can’t be fixed with better tooling.
Signals that it shouldn’t: one team owns everything; the domain is still being discovered; the proposed services would need to call each other synchronously on most requests; or the motivation is “microservices are how modern systems are built”. Splitting is easy to start and expensive to undo, so make the boundaries good in the monolith first.
The same interface, both ways
The same Payments interface as an in-process module and as a remote service. The remote version isn’t longer because it is badly written. Timeouts, idempotency keys and an explicit “unknown” outcome are the minimum a network call needs.
// One interface, two implementations. The in-process one is what the caller was
// written against; the remote one is what the network forces it to become.
export interface Payments {
charge(orderId: string, cents: number): Promise<ChargeResult>;
}
type ChargeResult = { status: "charged" | "declined" } | { status: "unknown" };
// Modular monolith: a function call into another module, inside the caller's transaction.
export class InProcessPayments implements Payments {
constructor(private ledger: Ledger) {}
async charge(orderId: string, cents: number): Promise<ChargeResult> {
return this.ledger.debit(orderId, cents); // throws → the whole transaction rolls back
}
}
// Microservice: the same call, now with everything the network adds.
export class RemotePayments implements Payments {
constructor(private baseUrl: string) {}
async charge(orderId: string, cents: number): Promise<ChargeResult> {
try {
const res = await fetch(`${this.baseUrl}/charges`, {
method: "POST",
headers: {
"content-type": "application/json",
// The same key on every retry, so the server can deduplicate: see the sagas post.
"idempotency-key": `charge:${orderId}`,
},
body: JSON.stringify({ orderId, cents }),
signal: AbortSignal.timeout(800), // never wait forever
});
if (res.status === 402) return { status: "declined" };
if (!res.ok) throw new Error(`payments: ${res.status}`);
return { status: "charged" };
} catch (err) {
// A timeout or reset tells us nothing about whether the charge happened.
// The caller must reconcile later, by querying the charge with the same key.
if (err instanceof DOMException && err.name === "TimeoutError") return { status: "unknown" };
throw err;
}
}
}
References
- Lewis, J. & Fowler, M. (2014). Microservices: a definition of this new architectural term. martinfowler.com
- Fowler, M. (2015). MonolithFirst. martinfowler.com
- Dean, J. & Barroso, L. A. (2013). The Tail at Scale. Communications of the ACM. doi:10.1145/2408776.2408794
- Rotem-Gal-Oz, A. (2006). Fallacies of Distributed Computing Explained. arnon.me
- Conway, M. E. (1968). How Do Committees Invent? Datamation. melconway.com
- Evans, E. (2003). Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley. domainlanguage.com
- Newman, S. (2021). Building Microservices, 2nd ed. O’Reilly. samnewman.io
- Newman, S. (2019). Monolith to Microservices. O’Reilly. samnewman.io
- Skelton, M. & Pais, M. (2019). Team Topologies. IT Revolution. teamtopologies.com
- Noonan, A. (2018). Goodbye Microservices: From 100s of problem children to 1 superstar. Segment engineering blog. segment.com/blog
- Kolny, M. (2023). Scaling up the Prime Video audio/video monitoring service and reducing costs by 90%. Prime Video Tech blog. primevideotech.com
Related reading
Backend · 10 min read
Retries, timeouts and circuit breakers: how services fail together
How retries amplify small failures into outages: timeouts and deadline propagation, exponential backoff and jitter, retry budgets, idempotency, circuit breakers, metastable failures, load shedding and hedged requests.
Backend · 9 min read
API evolution: changing interfaces without breaking clients
Backward and forward compatibility and why requests and responses differ, protobuf’s field-number rules, tolerant JSON readers, REST, gRPC and GraphQL, expand-migrate-contract, versioning and deprecation, and contract testing.
Backend · 12 min read
Queueing for engineers: Little’s law, utilisation and tail latency
Little’s law, why latency explodes as utilisation nears 100%, Kingman’s formula and the cost of variability, shared queues and the power of two choices, sizing connection pools, overload, and planning headroom.