Most of Your Microservices Should Be Modules

A network call is a thousand times slower than a function call and a hundred times harder to debug. Most service boundaries were drawn for organisational…

Share
Most of Your Microservices Should Be Modules. Abstract opinion illustration in orange and dark grey on debugly.dev

Here is the thesis. The majority of systems that are split into microservices would be faster, simpler and more reliable as a modular monolith, and the split was made for reasons that had little to do with the software. I say this as someone who has operated both, and who has spent a meaningful share of a career debugging the gap between them.

I am not arguing that microservices are never right. I am arguing that the default has drifted far past the point where the trade pays, and that the costs are paid by the people debugging at two in the morning, not by the people who drew the diagram.

The cost is real and it compounds

A function call is nanoseconds and cannot fail independently. A network call is milliseconds, can fail, can be slow, can return a duplicate, can arrive out of order, and must be retried, timed out and circuit broken. Every service boundary converts the first kind of call into the second kind.

Multiply that by the number of boundaries a request crosses and you have your tail latency. A request that touches six services has six chances to hit a slow hop, and the p99 of the whole is the product of the tails of the parts, which is the arithmetic in p99 and why averages lie, which is why the two fixes look alike.

Then add the operational surface. Each service needs deployment, discovery, observability, versioning and a team or at least an owner. A monolith with ten modules needs one deploy pipeline and one place to look. Ten services need ten of each, and the difference is not linear in practice because the interactions between services add their own failure modes.

Why the split happens anyway

To be fair, the reasons are human and they are real, and I want to steelman them properly.

Independent deploys are the honest technical argument. If two teams genuinely ship at different cadences and one blocks the other, a boundary buys autonomy. This is the original Conway's law insight and it is legitimate when the organisational friction is real.

Independent scaling is the second honest argument. If one component is CPU hungry and the rest are not, separating it lets you scale it alone. This is real, but it applies to a small number of components in most systems, not to all thirty services.

The dishonest reasons are the ones that drive most splits. Microservices as a signal of maturity. As a way to let teams choose different languages and frameworks, which reads as freedom and pays for it in every cross team debug. As a hedge against a big codebase that nobody wants to refactor, where the split exports the mess into thirty smaller messes with network calls between them.

The concession, precisely stated

There are systems where the split is correct, and I want to name the test so the argument is usable rather than sloganeering.

The boundary is worth it when the two sides have genuinely different scaling, failure or deployment profiles, and when the team on each side can own its data and its interface without constant coordination. A payment processor with strict compliance and a marketing site have different profiles. Two modules that share a database and must change together do not.

The tell for a bad boundary is shared state. If two services must coordinate on the same rows, or must be deployed together to keep a contract, you have distributed the code without distributing the coupling, which is the worst of both worlds. You now have network calls and you still have the coordination.

The modular monolith is not the bad old monolith

The counter to this position usually attacks a straw man, the giant ball of mud with no internal structure. That is not what I am advocating. A modular monolith has enforced module boundaries, dependency rules and clear interfaces, exactly like good service boundaries, except the calls are in process.

The discipline is the same. The transport is different. And the difference in transport is the difference between a call that fails when your code is wrong and a call that fails when the network, the peer, the serialiser or the timeout is wrong. Keeping the discipline and dropping the network is a large win for most teams.

There is even a migration path that makes the argument practical. Start modular, and extract to a service only when a boundary proves it earns its keep, by scaling or deployment pressure. Extracting one module later is easy. Un splitting thirty services later is one of the hardest migrations I know of.

What this has to do with debugging

The reason this belongs on a site about failure is that distributed systems do not just add latency, they add whole classes of bug that simply do not exist in process. Partial failure, retries that duplicate, distributed transactions that are not, caches that disagree, clocks that skew. Every one of those is a debugging tax paid on every incident, and every one is absent in a monolith.

When I read an incident report and the root cause is "the retry across a service boundary duplicated a write", I am reading a cost that was chosen at architecture time, by someone who will not be on the pager for it.

The rule of thumb

Default to modules. Draw a service boundary only when you can name the scaling, failure or deployment difference that pays for it, and when the two sides do not share state. If the reason for the split is organisational, fix the organisation if you can, because encoding an org chart into your network topology makes every future debug pay the org's debt.

Most microservices are modules that got a promotion they did not need, and the latency bill arrives nightly. The same "boundary drawn for the wrong reason" defect appears inside code as bad abstraction, which is the subject of the cost of abstraction in debugging.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud