What consolidation means and when it makes sense
When you merge microservices into a monolith, you move services one at a time into a single deployable unit with enforced internal module boundaries. Each step is small enough to reverse. You are not rewriting the system, and you are not dissolving every service into one shared codebase with no boundaries left. The goal is a modular monolith in which each former service becomes a module that keeps its existing contract.
Teams usually start this work for three reasons:
- Operational overhead. Every service brings its own pipeline, dashboards, alerts, dependency upgrades and on-call surface.
- Distributed-call latency. Chains of network hops, each with its own retries and timeouts, add latency that in-process calls avoid.
- Lockstep deploys. When services must ship together anyway, the independence you pay for does not exist.
Consolidation fits backend and platform teams running many small services owned by a few people. In that situation the cost of each service lands on the same engineers.
Avoid it when services have very different scaling profiles. It is also a poor fit when isolation or compliance rules require separate processes, or when different runtimes or languages would force a rewrite. Teams that genuinely ship independently are another reason to leave services split.
| Option | What it fixes | What it leaves |
|---|---|---|
| Full merge | Overhead, latency, lockstep deploys | Shared failure and scaling domain |
| Partial consolidation | The worst-coupled clusters | Some per-service overhead |
| Shared platform or service template | Per-service setup and upgrade cost | Network calls, coordinated deploys |
| Leave it alone | Nothing, at no migration cost | Current costs continue |
Choosing which services to merge from evidence you already have
Don't pick candidates by intuition about which services "feel" too small. Your version control, CI system, tracing backend and on-call rota already show which boundaries cost more than they protect.
Co-change from version control. If two services almost always change in the same week, the boundary isn't isolating anything. In a monorepo, run git log --name-only over a fixed window and group the changed paths by service directory per commit. Then count how often each pair of services appears in the same commit. With one repository per service, use time windows or shared ticket IDs in commit messages instead of commits, because a single change spans several repositories.
Lockstep deploys from pipeline logs. Look for services whose releases have to be ordered or rolled back together. These pairs already deploy as one unit, but without the safety of one.
Synchronous call graphs from tracing. Find chains where a request blocks on several hops, and services that fan out to the same small group on almost every request. Each of those hops adds latency and a partial-failure mode that in-process calls would avoid.
Ownership and overhead. Services owned by the same on-call team are cheap to merge organisationally. Services with low traffic but their own pipeline, dashboards and dependency upgrades are pure overhead.
| Signal | Points toward merging | Points toward keeping separate |
|---|---|---|
| Co-change | Frequent shared commits | Changes independently |
| Deploys | Released in lockstep | Released on its own schedule |
| Runtime coupling | Synchronous, high fan-out | Asynchronous or rarely called |
| Ownership | Same team and rota | Different teams |
| Scaling | Similar load profile | Very different resource needs |
| Isolation | No special requirements | Compliance, security or fault isolation needed |
Score each pair on these rows. Start with the pairs that score high on merge signals and have no strong keep-separate signal.
#!/usr/bin/env bash
# Rank pairs of services by how often they change in the same commit.
# Assumes a monorepo laid out as services/<name>/...
# Usage: ./cochange.sh "6 months ago"
set -euo pipefail
SINCE="${1:-6 months ago}"
# One marker line per commit, followed by the paths it touched.
# --no-merges avoids counting merge commits that repeat their parents' changes.
git log --no-merges --since="$SINCE" --name-only \
--pretty=format:'@@commit' -- services/ |
awk '
# Count each service once per commit, then count every unordered pair.
function flush( a, b) {
for (a in seen) {
total[a]++
for (b in seen) if (a < b) pair[a "\t" b]++
}
split("", seen) # portable way to clear the array
}
/^@@commit$/ { flush(); next }
/^services\/[^\/]+\// { split($0, p, "/"); seen[p[2]] = 1 }
END {
flush()
# ratio = shared commits / commits of the less active service.
# Near 1.0 means the quieter service rarely changes on its own.
for (k in pair) {
split(k, s, "\t")
lo = (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] / lo, s[1], s[2]
}
}
' |
# Highest shared-commit counts first; keep the top 20 as merge candidates.
sort -t $'\t' -k1,1nr -k2,2nr |
head -n 20 |
{ printf 'shared\tratio\tservice_a\tservice_b\n'; cat; }
A git log and awk pipeline that counts how often each pair of service directories in a monorepo changes in the same commit, and normalises that count by the less active service's commit count to surface co-change merge candidates.
Designing the target: a modular monolith with enforced boundaries
Target shape
Give each former service its own module. A module exposes a small public interface, which is the same operations its HTTP or RPC contract offered. Everything else sits in internal packages that other modules cannot import. For example, a billing module would have billing/api as its only importable package, with billing/internal/... holding the domain logic, persistence and adapters.
Process boundaries used to enforce this separation for free. In-process calls do not, so a single convenient import can join two modules together. Write the rule down and check it in CI: only */api packages may be imported across modules, and no import cycles between modules. Use an architecture test or import linter, and fail the build on any violation. A boundary that is only a convention will erode within a few sprints.
┌──────────── gateway / ingress ────────────┐
│
┌──────────────────────── host process ────────────────────────┐
│ ┌─ orders ──────┐ ┌─ billing ─────┐ ┌─ users ─────┐ │
│ │ api (public) │───▶│ api (public) │ │ api (public)│ │
│ │ internal/... │ │ internal/... │ │ internal/...│ │
│ └───────────────┘ └───────────────┘ └─────────────┘ │
│ shared: config · health · shutdown · telemetry (module tag) │
└──────────────────────────────────────────────────────────────┘
Host application first
Set up the host before you move any module into it:
- Single build and one process. This shows integration problems early.
- Config namespaced per module, such as
billing.db.url, so keys from different modules do not collide. - Health checks that report each module's readiness separately.
- Graceful shutdown that stops accepting traffic first, then drains each module in order.
- One telemetry setup that tags every log, metric and trace with the module name, so dashboards can still be broken down the way they were per service.
// Module layout (one module per former service):
// com.example.billing.api <- only package other modules may import
// com.example.billing.internal.. <- domain, persistence, adapters
// com.example.orders.api / orders.internal..
// com.example.users.api / users.internal..
package com.example.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
// Runs as a normal unit test, so any violation fails the CI build.
@AnalyzeClasses(packages = "com.example", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
// A module's internals may only be used by code inside that module.
private static ArchRule internalsArePrivate(String module) {
return noClasses().that().resideOutsideOfPackage("com.example." + module + "..")
.should().dependOnClassesThat()
.resideInAPackage("com.example." + module + ".internal..")
.because(module + " is a former service; call it through " + module + ".api only");
}
@ArchTest static final ArchRule billing = internalsArePrivate("billing");
@ArchTest static final ArchRule orders = internalsArePrivate("orders");
@ArchTest static final ArchRule users = internalsArePrivate("users");
// Each top-level package is one module; modules must not depend on each other in a cycle.
@ArchTest
static final ArchRule noModuleCycles =
slices().matching("com.example.(*)..").should().beFreeOfCycles();
}
An ArchUnit test, run in CI, that prevents any module from importing another module's internal packages and fails the build if modules depend on each other in a cycle.
Moving services in one at a time behind their existing contracts
Take one service at a time and run its code as a module inside the monolith, still serving the exact API its callers use today. Shift traffic gradually with weighted routing or a feature flag at the gateway, load balancer or DNS. Gateway and load-balancer weights take effect quickly. DNS is slower to reverse because clients cache records. At each step, compare responses, latency and error rates against the old deployment. Keep that deployment running and scaled so it can take traffic back. Rollback should be a configuration change, not a redeploy.
Once all traffic reaches the module, replace network calls with in-process calls. Keep the existing client interface and swap only the transport adapter, so callers don't change. Several semantics change with the transport:
- Retries, timeouts and circuit breakers were there for network failures. In-process they mostly hide real bugs, so remove them on purpose rather than leaving them as dead layers.
- Serialization used to give each side its own copy of the data. Passing shared references can expose mutation bugs that the copy was masking.
- Status codes become typed errors. Map each one explicitly, especially cases where a 404 or 409 drove caller logic.
Decide asynchronous flows one at a time. A queue can become a direct call or an in-process event. Keep it durable if it provides buffering, retry or ordering that you still need. Check whether any consumer relied on at-least-once redelivery for recovery, because a direct call won't redeliver.
Finish each migration before starting the next. A half-migration that stalls while running both topologies costs more to operate and debug than either end state.
// Callers depend only on this interface; it does not change during the migration.
interface Reservation { id: string; sku: string; qty: number }
interface InventoryClient { reserve(sku: string, qty: number): Promise<Reservation> }
// Typed errors replace the status codes that used to drive caller logic.
export class SkuNotFoundError extends Error {}
export class InsufficientStockError extends Error {}
// Old transport: an HTTP call to the separate service. Status codes are mapped explicitly.
class HttpInventoryClient implements InventoryClient {
constructor(private baseUrl: string) {}
async reserve(sku: string, qty: number): Promise<Reservation> {
const res = await fetch(`${this.baseUrl}/reservations`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sku, qty }),
});
if (res.status === 404) throw new SkuNotFoundError(sku);
if (res.status === 409) throw new InsufficientStockError(sku);
if (!res.ok) throw new Error(`inventory returned ${res.status}`);
return (await res.json()) as Reservation;
}
}
// The inventory code, now running as a module inside the monolith.
interface InventoryModule { reserve(sku: string, qty: number): Reservation } // throws the typed errors above
// New transport: an in-process call. Retries, timeouts and the circuit breaker are removed
// deliberately, because a failure here is a bug, not a network blip.
class InProcessInventoryClient implements InventoryClient {
constructor(private inventory: InventoryModule) {}
async reserve(sku: string, qty: number): Promise<Reservation> {
const reservation = this.inventory.reserve(sku, qty);
// Keep the copy semantics that serialization used to provide until shared-mutation risks are audited.
return structuredClone(reservation);
}
}
Swapping only the transport adapter behind an unchanged client interface: HTTP status codes become typed errors, network resilience layers are removed, and structuredClone keeps the copy semantics that serialization used to provide.
Consolidating data last and in stages
Moving code is reversible because you can redeploy the old service. Moving data is not. Once the monolith writes rows the old service never saw, rolling back requires a reverse migration under live traffic. Do data last, after every module runs in-process, and in stages that each leave a way back.
| Stage | What changes | Reversibility | Risk |
|---|---|---|---|
| 1. Module-owned schemas or databases | Each module keeps its own tables; no other module reads them | High | Low |
| 2. Co-location on one database server | Schemas share a server; ownership is still enforced per schema | Medium: splitting out again means a data move | Medium: noisy neighbours, shared failure |
| 3. Cross-module joins and shared transactions (optional) | Queries and transactions span module schemas | Low: modules are now coupled through data | High |
To relocate data, avoid application-level dual writes where you can. Two writes are not atomic, so one can fail after the other succeeds, and concurrent requests can land in different orders on each side. Change-data capture with a backfill is safer, but the backfill reads a snapshot while CDC applies newer updates. A backfilled row can overwrite a fresher one unless writes compare a version or timestamp, or the backfill only inserts rows that are absent. Before cutover, run reconciliation checks such as counts and per-key-range checksums, and repeat them until they match consistently.
Replacing a saga with a local transaction changes behaviour. Compensation logic disappears, intermediate states stop being visible, and lock contention appears. Make that change on purpose, one workflow at a time, never as a side effect of co-location.
Trade-offs and risks you take on
Consolidation removes network hops and deployment overhead. In exchange, it puts more things in one place where they can fail together.
Blast radius. A memory leak, a crash loop or a bad deploy in one module now takes down everything sharing the process. You can limit the damage but not remove it. Bounded thread pools and connection pools per module act as bulkheads. Container memory and CPU limits stop a runaway from starving the host. Canary rollouts catch a bad build before every instance runs it.
Scaling. If one module is CPU-bound and another mostly waits on I/O, you now scale both together. You pay for capacity that only one of them needs. When the profiles diverge sharply, keep that module separate.
Boundary erosion. This is the most likely long-term failure. A direct call across modules is a single import away, and each shortcut looks harmless when it is made. Without enforced boundary checks in CI, the modular monolith turns into a tangled one within a few release cycles.
Dependency conflicts. Services that pinned different versions of the same library, or ran on different runtime versions, must now agree on one. Expect forced upgrades.
Build and test time. The codebase grows, and so does every pipeline run. Invest early in incremental builds and in test selection based on which modules changed.
Team friction. Teams that once deployed on their own now share a release train. If you merge the code but leave the team structure and ownership as they were, people will route around the process instead of following it.
Exposed bugs. Network latency used to hide some defects. In-process calls run much faster and can run concurrently, so race conditions and unsafe shared mutable state start to show up. Load-test each merged module before you trust it.
Adapting delivery, decommissioning, and measuring cost and time saved
Delivery and ownership
Run one pipeline, but scope tests by module. A change under billing/ runs billing's tests plus contract tests for the modules that call it. Path-based ownership files route review to the team that owned the old service, so accountability survives the merge. Build caching and test selection keep pipeline time close to that of the per-service pipelines you replaced. Every deploy now ships all modules, so a bad change has a larger blast radius. Gate risky changes behind feature flags, canary to a small slice of traffic, and make rollback a single artifact revert.
Decommissioning checklist
Wait until an old service has received zero traffic for an agreed window. Then delete its deployments, service accounts, secrets, DNS entries, dashboards, alerts, queues, runbooks and service catalog entries. Anything you leave behind keeps costing money, keeps credentials live, and can still page someone.
Measuring cost and time saved
Record a baseline before the first merge, then compare each quarter.
| Metric | What to watch |
|---|---|
| Deploy frequency, lead time | Should hold or improve despite one pipeline |
| Change failure rate | Rises if blast-radius controls are weak |
| p95/p99 on formerly cross-service paths | Should drop as network hops disappear |
| Infrastructure cost | Fewer runtimes, load balancers, brokers |
| On-call pages | Fewer components, fewer alerts |
| Local setup time | One process instead of many |
Illustrative estimate (assumed numbers, not a benchmark)
Monthly hours saved = (S_before − S_after) × N + (P_before − P_after) × H
Assumed inputs:
- S (local setup time per engineer) drops from 8 h to 2 h.
- N (engineers onboarding per month) is 3.
- P (on-call pages per month) drops from 20 to 12.
- H (hours spent per page) is 1.5.
Result: (8 − 2) × 3 + (20 − 12) × 1.5 = 18 + 12 = 30 hours per month.
#!/usr/bin/env bash
# Module-scoped test selection for a single monolith pipeline.
# Runs tests only for modules touched by this change, plus contract
# tests for modules that call them. Falls back to the full suite when
# shared code or build config changes.
set -euo pipefail
BASE="${BASE_REF:-origin/main}"
# Who calls whom. Keep this next to the code and review it like code.
declare -A CALLERS=(
[billing]="orders invoicing"
[orders]="checkout"
[inventory]="orders"
)
changed="$(git diff --name-only "$BASE"...HEAD)"
# Shared code or build files can affect every module: run everything.
if printf '%s\n' "$changed" | grep -qE '^(shared/|build\.gradle|pom\.xml|package\.json)'; then
echo "Shared code changed; running full suite"
exec make test-all
fi
# Top-level directory of each changed file is its module.
modules="$(printf '%s\n' "$changed" | awk -F/ 'NF>1 {print $1}' | sort -u)"
for m in $modules; do
[[ -d "$m" ]] || continue # skip deleted or non-module paths
echo "== unit/integration tests: $m"
make test MODULE="$m"
for caller in ${CALLERS[$m]:-}; do
# Contract tests prove $caller still works against $m's public API.
echo "== contract tests: $caller -> $m"
make contract-test CONSUMER="$caller" PROVIDER="$m"
done
done
A CI step that diffs the change against the main branch, runs tests only for the touched modules plus contract tests for their callers, and falls back to the full suite when shared code changes; the make targets are placeholders for your build tool.
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 merge microservices into 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.