Microservices and Back: When Merging Services Is the Right Call
Splitting a monolith is usually sold as a way to deploy independently and scale parts of the system separately. That works when service boundaries match team boundaries and the organization can carry the operational cost. When they don't, the result is a distributed monolith: every cost of microservices and none of the independence.
Merging services back together is a normal engineering decision, not an admission of failure. Some teams have written about it publicly, for example Segment in "Goodbye Microservices" (2018) and the Prime Video team in 2023, who moved a monitoring service from Step Functions and Lambda into a single process. Martin Fowler's "MonolithFirst" argues for starting with a monolith for similar reasons.
What microservices cost
- Network calls instead of function calls. Every call can be slow, fail halfway or be retried. You need timeouts, retries with backoff, idempotency and circuit breaking everywhere.
- No shared transactions. Data that used to be updated in one database transaction now needs sagas, an outbox, or acceptance of temporary inconsistency.
- API versioning. Every interface between services is a contract that has to stay compatible across deploys.
- Per-service overhead. Each service needs a pipeline, dashboards, alerts, an on-call owner, dependency updates and security patches.
- Harder debugging. Following one request requires distributed tracing and correlated logs.
- Harder local development. Running the full system on a laptop gets slower or impossible.
These costs are worth paying when you get independence in return. The question is whether you do.
Signs of a distributed monolith
- Services have to be deployed together or in a fixed order.
- A typical feature needs pull requests in several repositories.
- Services read or write each other's database tables.
- Requests go through long chains of synchronous calls, and one slow service makes the whole request slow.
- There are clearly more services than teams, so each person owns several services that always change together.
Most of these can be measured. In a monorepo, count commits that touch more than one service directory:
# commits in the last 90 days that changed more than one service under services/
git log --since="90 days ago" --name-only --pretty=format:"--%h" -- services/ \
| awk '/^--/ { if (n > 1) multi++; total++; n = 0; split("", seen); next }
/^services\// { split($0, p, "/"); if (!(p[2] in seen)) { seen[p[2]] = 1; n++ } }
END { if (n > 1) multi++; print multi + 0 " of " total " commits touched several services" }'
With one repository per service, do the same with ticket IDs: count how many tickets have linked pull requests in more than one repository. Your tracing data shows call depth and fan-out per request. Deploy logs show how often services are released together.
Options short of a full merge
You do not have to go back to one big codebase.
- Merge the services that always change together. If two services are always deployed as a pair, they are one service.
- Give every table one owner. Shared tables are the strongest coupling. Move each table under one service, and have the others call it or consume its events.
- Build a modular monolith. One deployable, with internal modules and enforced boundaries between them. You keep transactions and in-process calls, and the module boundaries keep the code from turning into a tangle.
Module boundaries need tooling, or they erode. Packwerk does this for Ruby, ArchUnit for Java, Go has internal packages, and import-linter does it for Python:
# .importlinter
[importlinter]
root_package = my_app
[importlinter:contract:billing-boundary]
name = orders and users must not import billing internals
type = forbidden
source_modules =
my_app.orders
my_app.users
forbidden_modules =
my_app.billing.internal
Run lint-imports in CI and the build fails when someone reaches into another module's internals.
Merging without a big bang
- Pick one pair of services that always change together. Do not start with the whole system.
- Move the code into one deployable, but keep the old API as an in-process interface. The callers still use the same contract.
- Route traffic to the merged service behind the same paths or hostnames, and shift it gradually through the ingress or load balancer.
- Replace network clients with direct calls behind the same interface, one call site at a time.
- Merge data stores last, if at all. Two schemas in one database, owned by two modules, is a fine end state.
- Delete the old pieces. Pipelines, dashboards, alerts, DNS records, secrets and IAM roles of the retired service. Anything left behind will confuse someone later.
Keep the parts of microservices that worked. You can still run separate process types from one codebase (web, background workers, schedulers) and scale them independently. Clear module ownership still maps to teams.
When splitting is still right
Keep a separate service when the part has:
- a very different scaling or resource profile,
- different reliability or latency requirements,
- its own team with its own release cadence,
- a security or compliance reason to be isolated.
If none of these apply, the default should be one deployable with good internal boundaries.
Checklist
- List what the current split costs: pipelines, on-call load, cross-service changes, incidents caused by service-to-service calls.
- Measure coupling: multi-service changes, lockstep deploys, shared tables, call depth.
- Merge services that always change together, starting with one pair.
- Enforce module boundaries in CI if you consolidate.
- Move traffic gradually and merge data last.
- Clean up every resource of a retired service.
