What a microservices-to-monolith migration is and why teams do it
A microservices-to-monolith migration moves code that runs as separate deployable services into a single deployable unit. It is a decision about deployment boundaries: where processes start and stop, and where calls cross a network. It does not have to change domain boundaries, meaning which part of the system owns which concepts, rules and data. Teams that blur the two either keep services they don't need or merge everything into one tangled codebase.
The target is a modular monolith. Each former service becomes a module with a defined public interface, its own internal types and its own tables. Other modules call that interface in-process instead of over HTTP or a message broker. The build or a static analysis check rejects any import that bypasses it.
| Concern | Before: microservices | After: modular monolith |
|---|---|---|
| Unit of deployment | One per service | One artifact |
| Cross-domain call | Network request, serialization, retries | In-process function call |
| Domain boundary | Enforced by the network | Enforced by module interfaces and build rules |
| Failure modes | Timeouts, partial failure, version skew | Ordinary exceptions |
| Data | Database per service | Schema or table ownership per module |
Teams do this when distribution costs more than it returns. Every network hop adds latency and failure handling. Every service needs its own pipeline, dashboards, alerts and on-call coverage. Every cross-service change requires coordinated releases. A small team that owns many tightly coupled services pays all of these costs without getting independent scaling or independent team ownership in return. Consolidating removes those costs and keeps the domain model intact.
BEFORE: microservices AFTER: modular monolith
+--------------------------------------+
+-----------+ HTTP +-----------+ | one deployable artifact, one process |
| Orders |-------->| Billing | | |
| service | | service | | +----------+ call +----------+ |
| (own DB) | | (own DB) | | | orders |------->| billing | |
+-----------+ +-----------+ | | api | | api | |
| | +----------+ +----------+ |
| broker | | |
v | v |
+-----------+ | +----------+ |
| Shipping | | | shipping | |
| service | | | api | |
| (own DB) | | +----------+ |
+-----------+ | |
| tables: orders.* billing.* ship.* |
3 deploys, 3 pipelines, 3 on-calls | build rule: import only via api |
+--------------------------------------+
The same three domains before and after: separate services that talk over HTTP and a broker become modules in one artifact, calling each other's public APIs in-process and each owning its own tables.
Signs you have too many microservices
Before acting, check whether your system is actually in that position. Gut feeling tends to overweight the most recent outage, so test each signal below against data you already have.
Services that always deploy together. Pull deploy history from your CI system and look for services whose releases land in the same window almost every time. If a change to one reliably needs a change to another, they share a boundary that the network only hides.
Chatty synchronous call chains. Take traces for your highest-traffic endpoints and count the synchronous hops per request. When one user action passes through five or six services in sequence, every hop adds latency and another way to fail. The request is only available when all of those services are up.
Sagas guarding one consistency boundary. List your sagas and their compensating actions. If the business never accepts the intermediate states, such as an order that exists without its line items, the data needs a single transaction. The saga is just machinery that rebuilds one.
More services than owners. Map each service to the people who could fix it during an incident. If one engineer is on call for several services, the benefit of independent ownership is already gone.
Shared libraries that force lockstep upgrades. Count how many services have to bump a shared client or schema package at the same time. Coordinated upgrades across repositories are a monolith's release process with extra steps.
One signal by itself rarely justifies a merge. Several signals pointing at the same cluster of services usually do.
When to avoid consolidating and what you give up
Some services earn their boundary, and merging them swaps one coordination problem for a worse one. Keep a component as a service when:
- Its load differs sharply from its neighbours. An image resizer that spikes during uploads should not force you to scale the whole application.
- It handles data under a stricter regime, such as card numbers or health records. Isolation keeps the audit scope small.
- A different team owns it end to end.
- It runs on another stack, so consolidating means a rewrite rather than a move.
Even good candidates carry costs:
- Blast radius grows. A memory leak or runaway query in one module can take down every other module. You need in-process bulkheads: separate thread pools, per-module connection limits, timeouts and container resource limits sized for the combined process.
- Scaling becomes all-or-nothing. You provision for the hungriest module.
- Technology choice narrows to one runtime and one dependency tree.
- Release cadence is shared. Ownership disputes move into code review and the deploy queue.
- A stalled migration is the worst outcome. You pay for both the service platform and the monolith's coupling.
Before committing, check whether a smaller fix addresses the actual pain:
| Option | Addresses | Leaves unsolved |
|---|---|---|
| Coarser APIs | Chatty calls, latency chains | Deploy and repo count |
| Async messaging | Cascading failures, temporal coupling | Operational overhead; adds eventual consistency |
| Merge only the databases | Cross-service joins, consistency | Network hops; risks hidden schema coupling |
| Platform tooling (templates, shared CI) | Per-service setup toil | Runtime cost, distributed debugging |
| Full consolidation | All of the above | Needs ownership agreement; hard to reverse |
Choosing which services to merge
Merge decisions should come from evidence of coupling, not from which services feel small. Three signals matter, in this order of weight.
Co-change history
If two services are routinely modified for the same change, the network boundary between them is adding cost without adding independence. In a monorepo, run git log --since="12 months ago" --name-only --pretty=format:"%H" and map each changed path to its owning service. Each commit then becomes a set of services, and you can count how often each pair appears together. In a polyrepo setup, run git log --pretty=format:"%h %ad %s" --date=short in each repository and join commits on ticket IDs in the commit message. Treat a pair as coupled when one service rarely ships without the other. A single shared migration does not count. A pattern across dozens of unrelated tickets does.
Shared data
Services that read the same tables, or that sync copies of the same entity through events, already depend on each other's schema. Merging them lets you replace eventual consistency with a single transaction.
Request-path coupling
Use traces to find chains where one service synchronously calls another on most requests. Each hop adds latency and a failure mode, and the chain gains no isolation in return.
Ranking the first cluster
Score each candidate cluster on all three signals. Start with the cluster that scores high on every signal, has a single owning team, and sits off the critical revenue path. For example (illustrative), orders, order-pricing and order-notifications might co-change on most tickets, share an orders table, and call each other on every checkout. That would make them a stronger first cluster than any group that scores high on only one signal.
#!/usr/bin/env bash
# Count how often pairs of services change in the same commit (monorepo).
# Assumes each service lives under services/<name>/; adjust the path match to your layout.
set -euo pipefail
MIN_TOGETHER=10 # ignore pairs that co-changed only a few times (e.g. one shared migration)
git log --no-merges --since="12 months ago" --name-only --pretty=format:"@@%H" |
awk -v min_together="$MIN_TOGETHER" '
# Turn the services touched by one commit into unordered pairs.
function flush( n, i, j, a, b, t, list) {
n = 0
for (s in seen) { list[++n] = s; total[s]++ }
for (i = 1; i <= n; i++) for (j = i + 1; j <= n; j++) {
a = list[i]; b = list[j]
if (a > b) { t = a; a = b; b = t } # stable key regardless of order
pair[a " " b]++
}
split("", seen) # reset for the next commit
}
/^@@/ { flush(); next } # commit boundary
/^services\// { split($0, p, "/"); seen[p[2]] = 1 }
END {
flush()
for (k in pair) {
if (pair[k] < min_together) continue
split(k, s, " ")
# ratio = share of the less-active service commits that also touched the other one
m = (total[s[1]] < total[s[2]]) ? total[s[1]] : total[s[2]]
printf "%d\t%.2f\t%s\t%s\n", pair[k], pair[k] / m, s[1], s[2]
}
}' |
# Highest ratio first: a ratio near 1.0 means one service rarely ships without the other.
sort -t$'\t' -k2,2nr -k1,1nr |
{ printf "together\tratio\tservice_a\tservice_b\n"; head -20; }
A git log and awk pipeline that turns 12 months of monorepo commits into service pairs, ranked by how often the less-active service of each pair changes together with the other, while dropping pairs below a minimum count so that one-off shared changes are left out.
Consolidating data without losing consistency
Once you've picked a cluster, the riskiest part is its data, not its code. Code merges can be reverted with a deploy. A bad data merge can corrupt records that you only find out about weeks later.
Start by moving each service's database into its own schema inside one database. Don't merge tables yet. Each module keeps the tables it owns, so ownership stays visible, and cross-schema joins become possible without being required. Then deal with identifier conflicts. Two services may each have a customers table with overlapping integer keys, or one may use UUIDs where the other uses sequences. Choose one canonical key, and keep a mapping table until every reference has been rewritten.
Duplicated and denormalized data comes next. A copy of customer_email inside orders existed because the orders service couldn't query customers. Once both live in the same database, either remove the copy or document why it should stay, for example as an intentional snapshot of the address at the time of order. Replace sagas and outbox events with local transactions only where the data really forms one aggregate. If you wrap unrelated modules in one transaction, you couple them again.
Run every move as a sequence: copy, dual-write or backfill, verify, switch reads, and drop. Each step should have an inverse. Until the drop, the old table is your rollback path, so delay the drop until reconciliation queries have shown zero drift for an agreed period. The example below shows one such step.
-- Step: backfill canonical customer keys into orders (PostgreSQL).
-- Assumes orders.customer_id_map(legacy_id bigint, customer_uuid uuid) is already populated.
-- The legacy column orders.orders.customer_id is not modified, so reads still work unchanged.
-- FORWARD: add the new column, then backfill it from the mapping table
BEGIN;
ALTER TABLE orders.orders ADD COLUMN IF NOT EXISTS customer_uuid uuid;
UPDATE orders.orders AS o
SET customer_uuid = m.customer_uuid
FROM orders.customer_id_map AS m
WHERE m.legacy_id = o.customer_id
AND o.customer_uuid IS NULL; -- safe to re-run; on large tables, batch by id range
COMMIT;
-- VERIFY: run until it returns 0 for every bucket, then repeat for the agreed period
SELECT 'unmapped' AS problem, count(*) FROM orders.orders
WHERE customer_uuid IS NULL
UNION ALL
SELECT 'mismatch', count(*) FROM orders.orders AS o
JOIN orders.customer_id_map AS m ON m.legacy_id = o.customer_id
WHERE o.customer_uuid IS DISTINCT FROM m.customer_uuid
UNION ALL
SELECT 'dangling', count(*) FROM orders.orders AS o
LEFT JOIN crm.customers AS c ON c.id = o.customer_uuid
WHERE o.customer_uuid IS NOT NULL AND c.id IS NULL;
-- INVERSE: nothing reads customer_uuid yet, so dropping it restores the prior state.
-- BEGIN;
-- ALTER TABLE orders.orders DROP COLUMN IF EXISTS customer_uuid;
-- COMMIT;
A reversible backfill step that adds a canonical UUID key alongside the legacy integer key, checks it with drift queries, and can be undone by dropping the new column because the legacy column and the read path are untouched.
How to execute the migration incrementally
Migrate one cluster at a time, and keep its existing API contract fixed while you move it. Callers should not be able to tell where the code runs. If you change the contract and the location together, you cannot tell which change caused a regression.
- Hide each remote client behind an interface. Callers use an interface such as
InventoryClient. It gets two implementations: the current HTTP or gRPC client, and an in-process one that calls the merged module directly. Both return the same domain types and the same error categories, so callers do not have to change. The code example below shows this pattern. - Shift traffic gradually. Use a configuration flag to pick an implementation for each request. Start with a small percentage and increase it in steps. Keep the old service running and shadow-compare its responses until outputs and p99 latency match for the traffic you care about.
- Delete the old service. Remove the remote implementation along with its deployment, its alerts and its client library.
When a call becomes in-process, recheck the logic that existed only because of the network. A retry around a local function call does nothing useful. It can also double side effects if the function is not idempotent. Timeouts sized for network round trips no longer fit, so tie them to the request's overall deadline instead. Idempotency keys still matter wherever a message queue or an external client can redeliver a request.
The biggest risk is hidden database access. Once modules share a process and a connection pool, one module can query another module's tables directly. Give each module its own schema and database role, and add a build-time dependency check that fails if a module imports another module's repository package.
// Shared domain type and error category. Both implementations must honor them.
export type StockLevel = { sku: string; available: number };
export class NotFoundError extends Error {}
// Callers depend only on this interface, never on where the code runs.
export interface InventoryClient {
getStock(sku: string, signal: AbortSignal): Promise<StockLevel>;
}
// Existing remote implementation: translates HTTP status codes into domain errors.
export class HttpInventoryClient implements InventoryClient {
constructor(private readonly baseUrl: string) {}
async getStock(sku: string, signal: AbortSignal): Promise<StockLevel> {
const res = await fetch(`${this.baseUrl}/stock/${encodeURIComponent(sku)}`, { signal });
if (res.status === 404) throw new NotFoundError(sku);
if (!res.ok) throw new Error(`inventory service returned ${res.status}`);
return (await res.json()) as StockLevel;
}
}
// Public API exported by the merged inventory module (not its repository layer).
export interface InventoryService {
findStock(sku: string): Promise<StockLevel | undefined>;
}
// In-process implementation: no retry loop and no network-sized timeout.
// It still checks the caller's request-wide deadline before doing work.
export class InProcessInventoryClient implements InventoryClient {
constructor(private readonly service: InventoryService) {}
async getStock(sku: string, signal: AbortSignal): Promise<StockLevel> {
signal.throwIfAborted();
const stock = await this.service.findStock(sku);
if (!stock) throw new NotFoundError(sku); // same error category as the HTTP path
return stock;
}
}
// Per-request routing during migration. Raise inProcessPercent in steps via config.
export const pickInventoryClient = (remote: InventoryClient, local: InventoryClient, inProcessPercent: number): InventoryClient =>
Math.random() * 100 < inProcessPercent ? local : remote;
An InventoryClient interface with an HTTP implementation and an in-process implementation that return the same domain types and errors, plus a percentage-based selector for shifting traffic from one to the other.
Operating the monolith and keeping boundaries intact
Build and test pipeline
A single repository with one test suite quickly becomes the new bottleneck. Structure the build so each module is its own build target with declared dependencies. CI can then rebuild and test only the modules a change touches, plus the modules that depend on them. Run the full suite on merge to main or on a schedule, not on every pull request. Without test selection, consolidation trades many small, fast pipelines for one slow one.
Observability
Network hops no longer separate your units of work, so distributed traces lose most of their detail. Add in-process spans at module entry points, and tag logs and metrics with the module name. This keeps latency and error rates attributable to a specific team's code.
Ownership and on-call
Map each module to one owning team in a code-owners file. Route alerts by the module tag, not by the deployable, so on-call rotations stay aligned with the code each team knows.
Preventing drift
The dependency rules shown in the configuration example declare which modules may import which, and allow calls only through each module's public interface package. CI fails on any violation. Changing a rule requires review from both owning teams, which turns new coupling into an explicit decision.
Extraction criteria
Write down in advance when a module should become a service again. Typical reasons are scaling needs that differ sharply from the rest of the monolith, a different release cadence, or isolation requirements such as compliance. Agreeing on these criteria early keeps extraction from turning into a reflex.
Impact on cost and engineering time, and how to measure it
Consolidation saves engineering time in three ways. Deploys no longer need sequencing across services. Debugging stays inside one process and one stack trace instead of crossing network hops. There are fewer pipelines, clusters and dashboards to maintain. The cost is the migration itself, which means weeks of work that ship nothing users see. Decide before you start how you will judge whether the trade paid off.
Metrics to baseline up front
Record each metric for several weeks before the migration, then compare the same length of time afterward. Compare only against your own baseline, because figures from other teams reflect different systems.
| Metric | What it reveals | Expected direction |
|---|---|---|
| Deploy frequency | Whether coordination overhead dropped | Up or flat |
| Change lead time | Friction between commit and production | Down |
| p95 latency on key paths | Removed network hops versus new in-process contention | Down or flat |
| Incident count | Whether fewer moving parts means fewer failures | Down |
| Infrastructure cost | Compute, managed services, observability spend | Down |
Illustrative estimate (assumed numbers, not a benchmark)
Break-even (weeks) = migration engineer-weeks ÷ engineer-weeks saved per week
Assume three engineers spend eight weeks on the migration, which is 24 engineer-weeks. Assume the team saves 6 hours a week on deploy coordination and 10 hours on cross-service debugging. That is 16 hours, or 0.4 engineer-weeks at 40 hours each. Break-even is 24 ÷ 0.4 = 60 weeks.
If the system will change shape before that point, the time savings alone won't justify the migration. Lower infrastructure cost can shorten the payback period.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to microservices to monolith, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.