The industry spent a decade splitting monoliths and is now spending one merging services back. Neither movement was wrong; both were reactions to a real cost that the other approach imposes. The useful question was never which is better, but which constraint is currently binding on your team.
Microservices buy one thing that nothing else buys: independent deployment and independent scaling by team. Everything else attributed to them — modularity, clear interfaces, technology choice, testability — is achievable inside a single deployable, and considerably more cheaply.
What a network boundary actually costs
Crossing a process boundary converts a function call into a distributed system problem. A local call either happens or throws; a remote one can time out having succeeded, succeed twice, or return after the caller gave up. A transaction spanning two modules becomes a saga with compensation. A refactor spanning two modules becomes a coordinated release across two teams.
You also inherit an operational surface: service discovery, retries and budgets, distributed tracing, per-service pipelines, dashboards and on-call. That is a genuine platform investment, and the teams who suffer most are the ones who took on that cost with four engineers and no platform team.
The framework I use
- Team count and coupling: if fewer than three teams are blocked on each other's release cadence, deployment independence is not yet worth buying.
- Failure isolation: is there a component whose failure genuinely must not affect the rest? A real isolation requirement justifies a boundary on its own.
- Scaling shape: does one component need ten times the resources of the others, or specialised hardware? That is a legitimate split.
- Transaction boundaries: if two candidate services would constantly need to change data together, the boundary is in the wrong place, and no amount of eventual consistency will make it right.
- Change frequency: components that change together should live together. Deployment coupling follows change coupling whether you designed for it or not.
The default I now recommend is a modular monolith with boundaries enforced in the build rather than by convention: modules with explicit public interfaces, no cross-module database access, communication through in-process events or published interfaces, and a CI check that fails when a module reaches into another's internals. It gives you nearly all the design benefit, keeps refactoring cheap, and preserves the option to extract a module later.
src/
Billing/
Public/ <- the ONLY thing other modules may import
BillingApi.php
Events/InvoiceIssued.php
Internal/ <- enforced private: repositories, models, services
Inspections/
Public/
Internal/
# The boundary is only real if a machine enforces it.
# deptrac.yaml
layers:
- name: BillingInternal
collectors: [{ type: directory, value: src/Billing/Internal/.* }]
ruleset:
Inspections: [BillingPublic] # may depend on Billing's public surface
# ...and nothing grants access to BillingInternal. A violation fails the build.That structure makes extraction a mechanical exercise when the day comes: the module already talks to the rest of the system through a narrow, explicit surface, so moving it behind HTTP is a transport change rather than an archaeology project.
Microservices are an organisational solution with a technical implementation. If your problem is not organisational, you are buying the implementation and none of the solution.
The failure I see most often is not choosing wrongly — it is choosing once and never revisiting. Extract a service when a specific, articulated constraint demands it, and be equally willing to merge two services whose boundary turned out to be wrong. Architecture is a set of decisions with expiry dates, and pretending otherwise is how teams end up defending a diagram instead of a system.