Somewhere in your stack, one checkout crosses six services, two queues, and a retry policy nobody has read since it shipped. Each hop was supposed to buy independence. What it bought was a release calendar. The modular monolith vs microservices argument has turned in the last year, and it turned on production experience, not fashion. The CNCF and SlashData State of Cloud Native Development report for Q3 2025 found service mesh adoption fell from 18% in Q3 2023 to 8% in Q3 2025. Kubernetes use among backend developers slid from 36% to 30% over the same window. The same report says 46% of backend developers still run microservices, so the pattern is not dead. It is being unwound where it never earned its keep. This essay is about why that happened, what it costs, and how to build so that extraction stays a choice instead of a rescue.
The distributed monolith trap
Netflix, Google and Uber decomposed their systems because scale forced the decision. Netflix had thousands of engineers and a measurable share of evening internet traffic. Google ran planet-scale infrastructure with dedicated platform teams. Uber had hundreds of teams that needed to ship without waiting on each other. The conference talks that followed described the resulting shape in loving detail. They spent far less time on the forces that produced it. Martin Fowler warned about this in 2015 with MonolithFirst: nearly every successful microservice story he knew started as a monolith that got too big.
So the shape got copied without the forces. A twelve-person team with one PostgreSQL database stood up a cluster, a mesh, and fourteen repositories. The domain had not changed. The headcount had not changed. Only the number of network hops changed. This is not a judgment of the engineers who did it. The reference architectures of the day described the destination and skipped the prerequisites. The structural gap was in the guidance, not in the people following it.
The words matter here, because two very different things share the name.
A real microservice has an independent lifecycle. It deploys on its own schedule. It owns its data, and nobody else reads its tables. It can be rewritten in another language without a meeting. If it goes down, the rest of the system degrades, because no other service needs it synchronously to finish a write.
A distributed monolith fails every one of those tests while paying for all of the plumbing. Its services share a database, or they coordinate schema changes in a group chat. Releases go out as a train, because Orders 3.2 only works with Inventory 1.9. One write spans three services, so one slow dependency slows the whole request. You get the latency and partial failures of a network, without the autonomy that would justify them.
Here is the test we use. Can you deploy one service on a Tuesday afternoon without telling anyone? If not, you have one application running in several processes.
The Fallacy of Free Boundaries. A network hop is not a domain boundary. It is a function call with a timeout. If two services share a schema, deploy together, or call each other to finish one write, you still have one domain. You have only moved the coupling to where the compiler cannot see it, and where the fallacies of distributed computing can. Boundaries come from domain modeling. The network only makes bad ones expensive.
The hidden tax of premature decomposition
The tax comes in three installments. Each one is paid by engineers who would rather be building the product.
Operational complexity. A microservice estate for a team under fifty engineers is still a full platform. Someone owns cluster upgrades, Helm charts, ingress, secrets, certificate rotation, sidecar proxies, and the mesh control plane. The CNCF report notes that service meshes carry higher CPU cost, which is one reason their adoption has more than halved. At Big Tech scale, a platform team of forty amortizes across thousands of engineers. At mid-market scale, the same work lands on the two people who also carry the pager. Every hour on the mesh is an hour not spent on the thing customers pay for.
Data consistency. The monolith had a transaction. Begin, three writes, commit. The distributed version has a saga: a chain of local transactions, each with a compensating action for when a later step fails. Microsoft's Azure Architecture Center is candid about the saga pattern. Participants commit locally, so nothing can be rolled back, only compensated. Compensation itself can fail. There is no isolation across services, so sagas invite lost updates and dirty reads. Debugging gets harder with every participant you add. Dual writes to a database and a queue lose or duplicate messages. The outbox pattern fixes that by adding a table, a relay process, and at-least-once delivery that every consumer must tolerate. Eventual consistency sounds gentle until a customer sees an order confirmed and an inventory count that says otherwise. None of this is wrong when the domain demands it. It is a tax when the domain never asked.
Developer velocity. To reproduce one bug, an engineer needs eight services, their databases, a broker, and a compose file that drifted from production months ago. Breakpoints stop at the network edge. Deployments need choreography: ordered releases, flags across repositories, and backward-compatible contracts for consumers that ship next sprint. A shared client library sits at version 4 in three services and version 2 in five. A one-field change becomes four pull requests and a coordination thread.
The Prime Video team published the pattern in miniature in 2023. They moved an audio and video monitoring workload from distributed serverless components into one process. InfoQ reported a 90% reduction in operating cost for that workload. Moving data between components had become the dominant expense. The network was the bill.
Here is what one checkout looks like both ways.

The same flow as Mermaid source, if you want it in your own architecture docs:
flowchart TB
subgraph DM["Distributed transaction: one checkout, six network hops"]
direction LR
C1([Client request]) -->|"TLS + routing<br/>3 to 8 ms"| GW[API Gateway]
GW -->|"token check<br/>5 to 15 ms"| AUTH[Auth Service]
AUTH -->|"create order<br/>5 to 20 ms"| ORD[Order Service]
ORD -->|"reserve stock<br/>timeout? retry?"| INV[Inventory Service]
INV -->|"charge card<br/>retry = double charge?"| PAY[Payment Service]
PAY -->|"queue, at-least-once<br/>duplicate email?"| NOTE[Notification Service]
end
subgraph MM["Modular monolith: one process, one transaction"]
direction LR
C2([Client request]) -->|"in-memory dispatch<br/>under 1 ms"| BUS[(In-memory event bus)]
BUS --> O[Orders module]
BUS --> I[Inventory module]
BUS --> P[Payments module]
BUS --> N[Notifications module]
end
DM ~~~ MM
classDef risk fill:#3b1d24,stroke:#f87171,color:#fecaca;
classDef calm fill:#0f2a3a,stroke:#38bdf8,color:#e9eef5;
class INV,PAY,NOTE risk;
class BUS,O,I,P,N calm;
Add the hops up and a single write can spend 20 to 60 ms on the wire before any domain work happens. Three of those hops can partially fail. Each one needs an idempotency key, a retry budget, and a compensating action. The in-process version spends microseconds on dispatch and finishes inside one database transaction.
Modular monolith vs microservices: what the modern monolith actually is
A modular monolith is one deployable artifact with strict domain encapsulation inside it. Each module owns its tables, its domain logic, and a small public interface. Modules talk through typed internal interfaces or an in-memory event bus. They never reach into each other's data. The database may be one physical instance, but each module's schema is private to it. Shopify described this model in 2019, in Deconstructing the Monolith, for a Rails codebase that more than a thousand developers had worked on. The tooling has matured since. Spring Modulith 2.0 went GA in November 2025 and verifies module structure as part of the test suite.
This is not the old monolith with a tidier folder layout. The difference is enforcement. A boundary that only lives in a code review comment is not a boundary. It is a request.
The enforcement mechanism depends on the language, and every mainstream stack has one:
- Java and Kotlin: ArchUnit tests fail the build when a class in
ordersimports anything underinventory.internal. Spring Modulith adds module verification and a registry of published events. - Go: a package under an
internal/directory can only be imported from inside its parent tree. The compiler enforces it, as the cmd/go documentation spells out. - Rust: items are private by default, and
pub(crate)scopes visibility to one crate, per the Rust reference on visibility and privacy. - TypeScript and Node: workspace packages with an explicit
exportsmap, plus a lint rule that blocks deep imports across packages.
Here is the shape in Go. The inventory module exposes one interface and one data transfer type. Its SQL models never leave the module.
// modules/inventory/inventory.go
// Public surface of the module. Nothing else is importable.
package inventory
import (
"context"
"database/sql"
"example.com/app/modules/inventory/internal/service"
"example.com/app/modules/inventory/internal/store"
)
// Reservation is the only shape other modules ever see.
// No table columns, no ORM struct, no foreign keys leak out.
type Reservation struct {
ID string
SKU string
Quantity int
}
// Service is the contract. Orders depends on this, never on store.
type Service interface {
Reserve(ctx context.Context, sku string, qty int) (Reservation, error)
Release(ctx context.Context, reservationID string) error
}
// New wires the module. Callers receive a Service, not a database.
func New(db *sql.DB) Service {
return service.New(store.New(db))
}
// modules/orders/checkout.go
// This line does not compile. The build is the boundary.
// import "example.com/app/modules/inventory/internal/store"
// error: use of internal package .../inventory/internal/store not allowed
Scalability is where the modular monolith gets underestimated. Horizontal scaling of a stateless monolith is cheap. Run more copies behind a load balancer. The expensive thing was never the copies. It is serialization. Thirty microservices means every cross-domain call pays for JSON encoding, TLS, a proxy hop, and decoding, in both directions. An in-process call pays for a function call. When one module needs more CPU than the rest, run the same artifact with a role flag. Web copies serve requests, worker copies run the background modules. Same code, same deploy, different scaling.
We run this way in production. The unified operations platform we built for a contract-logistics site is one Flask application with more than fifty operational screens, 185 server routes, and 24 background automations. Each capability is a self-contained module, registered in the database so it becomes callable and grantable the day it ships. The fleet coordinates scheduled jobs through database-level leader election, so exactly one process runs each job however many servers are online. Routine work moved to the scheduler. Judgment and exceptions stayed with the people on the floor, who now have a screen that says when something broke. Because one process serves floor screens, office reporting, and the background jobs, one trace per request is the default rather than a project.
The four shapes compared across the vectors that actually hurt:
| Vector | Traditional Monolith | Distributed Monolith (anti-pattern) | Modular Monolith | True Microservices | |---|---|---|---|---| | Boundary enforcement | None. Any class can touch any table. | None. Coupling survives the network hop and hides behind it. | Compiler, build, and architecture tests enforce module lines. | Process, repository, and ownership lines, enforced by separate teams. | | Deployment complexity | One artifact, one pipeline. | Many artifacts that must ship together. Release trains, ordering rules. | One artifact, one pipeline. Modules ship together by design. | Many independent pipelines. Needs a platform team and orchestration. | | Transactional integrity | ACID across the whole schema. | BASE forced on domains that wanted ACID. Sagas, dual writes, retries. | ACID inside the process. One transaction can span modules when needed. | BASE by contract. Sagas, outbox, idempotency as first-class work. | | Local development friction | Low. One process, one debugger. | Highest. Ten-plus services, mocks, and a drifting compose file. | Low. One process, one debugger. Modules test in isolation. | High. Stubs, contract tests, and an environment per service. | | Observability overhead | Logs and metrics from one process. | Full distributed tracing required to answer "which hop failed." | One trace per request by default. Module tags are optional. | Tracing, correlation IDs, centralized logging, and SLOs per service. |

When a service actually deserves extraction
Extraction is a tool, not a maturity level. Three forces justify it. Nothing else does.
- Disproportionate scaling. Video transcoding, image processing, bulk EDI parsing, nightly rating runs. Their resource curve has nothing to do with user authentication or order entry. Extracting them lets you scale CPU-heavy work without ten idle copies of everything else.
- Regulatory or compliance firewalls. Card data, protected health information, export-controlled drawings, anything an auditor examines in isolation. A separate process with its own credentials shrinks the audit perimeter and makes the boundary demonstrable.
- An autonomous team boundary. A team that can own the runtime end to end, with minimal domain overlap. Amazon's two-pizza team idea is about ownership, not size. If every change needs a conversation with another team, the boundary is not real yet.
Rule of Extraction. Extract a module into a service only when you can name the force a process boundary resolves. There are three: a scaling curve the rest of the system does not share, a compliance perimeter that must be audited alone, or a team that can own the runtime without asking permission. If the honest answer is "cleanliness" or "future scale," keep it in the monolith and harden the module boundary instead.
The path is the same every time, and it starts long before any service exists.
- Start modular. Draw module lines from the domain language, not from this quarter's org chart. Give each module its own schema namespace from day one.
- Verify the boundary with time. If a year of commits respected it, with no "temporary" cross-module query, the boundary is real. If engineers kept tunneling under it, the model was wrong, and a network would have made that worse.
- Extract in order. Interface first, because the module's public API is already the service contract. Data second, because the module's private tables have no other readers. Deploy third, with its own pipeline and runtime.
- Change the delivery semantics on purpose. In-process events were synchronous and ran inside the caller's transaction. The network version is at-least-once. Add idempotency keys and an outbox before the first consumer goes live, not after the first duplicate.
The modular monolith does not avoid distributed systems. It defers them until the domain proves a boundary, and then it hands you a contract that is already stable.

Run this check before your next architecture meeting
Pick the single most common write in your system. Place an order, confirm a receipt, close a ticket. Trace it end to end and write down four numbers:
- Network hops required to complete that one write.
- Services that must deploy together for a one-field change on that path.
- Services an engineer must run locally to reproduce a bug on that path.
- Elapsed time from merge to running in production, across every repository the path touches.
If the first number is three or more, the second is two or more, and the third does not fit on a laptop, you are paying the microservices tax on a monolith. The fix is not a rewrite. It is boundary hardening inside one artifact, with extraction reserved for the three forces above.
If the check turns up a distributed monolith, or you are structuring a system that does not exist yet, that is work our custom software and web development practice does. We build modular by default, enforce the boundaries in the build, and extract only when the traffic or the auditor says so. The work is fixed price, quoted up front, and the code is yours.


