What service consolidation is and when it is warranted
Service consolidation, or reverse migration, means deliberately folding several independently deployed services into one deployable application with explicit internal module boundaries. The target is a modular monolith, not the tangled codebase many teams originally left. Each former service becomes a module with its own public interface and its own data ownership. The difference is that modules call each other in-process rather than over the network.
This is a correction, not a retreat. Microservices pay off when separate teams need to deploy, scale and fail independently. When one small team owns a dozen services, it pays for distributed systems without getting those benefits. That cost shows up as network latency, partial failures, versioned APIs, per-service pipelines and a pager for each component.
The approach fits teams where the ratio of services to engineers is high and ownership has collapsed onto a few people. These signals justify a merge:
| Signal | What it indicates | Why merging helps |
|---|---|---|
| Services always deploy together | The boundary is not independent in practice | One build removes coordination overhead |
| Synchronous call chains between services owned by the same team | A network hop stands where a function call would do | In-process calls remove latency and failure modes |
| Shared or duplicated data models | The services are one domain split artificially | One schema owner ends drift between copies |
| Infrastructure and on-call costs out of proportion to team size | Operational load exceeds the team's capacity | Fewer runtimes mean fewer things to monitor and patch |
One signal alone is rarely decisive. Several together, concentrated in the same team's services, make a strong case.
When to avoid merging, and the alternatives
Some signals point the other way, and they tend to be structural rather than cosmetic.
Very different scaling profiles. If one service handles bursty, CPU-heavy work like image processing and the rest serve steady CRUD traffic, a single deployable forces you to scale everything to the hungriest component's needs. You pay for idle capacity, and a spike in one workload can starve the others of memory or threads.
Different languages or frameworks. Merging a Go service into a Java codebase is not consolidation. It is a rewrite, with all the risk of reimplementing behavior that may be poorly documented.
Teams organized around autonomous deploys. If each team ships on its own schedule and owns its on-call rotation, a shared release train adds coordination cost. That cost can outweigh the operational savings.
When these apply, three alternatives are usually cheaper:
| Option | What changes | Best when | Main limitation |
|---|---|---|---|
| Partial merge | Only a few tightly coupled services combine | A small cluster always deploys together or chats synchronously | The rest of the system keeps its overhead |
| Shared platform or library layer | Common logging, auth, config and deploy tooling | Overhead comes from duplicated boilerplate, not service count | Shared libraries create version-skew and upgrade work |
| Keep architecture, fix call chains | Remove sequential hops, add batching or async messaging | Latency and fragility come from a few hot request paths | Runtime and monitoring footprint stays the same |
A partial merge is often the right first step even when full consolidation is the goal. It tests your process on a small scope before you commit further.
Mapping the current system before changing it
Start with runtime evidence, not the architecture diagram. Aggregate a few weeks of distributed traces into a service-to-service graph: each edge is a parent-child span pair that crosses a service boundary, weighted by call volume and latency. Traces miss anything uninstrumented, so add edges from message broker topic subscriptions, scheduled jobs and service configuration. The edges you find only in config are often the ones that break during a merge.
Next, list the data each service owns: tables, buckets, queues and caches, and for each one, which services write it and which read it. A table written by two services means the boundary already leaks, and those services likely belong in the same module.
Group services into candidate modules using two signals together. Domain cohesion tells you what belongs together conceptually. Co-change frequency, taken from version control history, tells you what actually changes together. When the two disagree, trust co-change and investigate why.
| Signal | Source | Suggests |
|---|---|---|
| High synchronous call volume between two services | Traces | Merge early; removes network hops |
| Shared writable tables | Schema and access logs | Same module |
| Frequent joint commits or releases | Version control | Same module |
| Distinct scaling profile or runtime | Metrics, build config | Keep separate |
| Compliance or security isolation | Audit requirements | Keep separate |
For merge order, pick leaf services first: few dependents, one chatty partner, low traffic. They give you a low-risk rehearsal of the process before you touch heavily connected nodes.
How the target modular monolith works
The target is one deployable built from modules that map one-to-one to the former services, or to bounded contexts if some services were split too finely. Each module keeps the boundary its service had over the network, but the boundary is now enforced by the compiler and the build.
+--------------------------------------------------+
| single deployable |
| +------------+ +------------+ +----------+ |
| | orders | | billing | | catalog | |
| | [api]------+-->| [api] |<--+ [api] | |
| | internal/ | | internal/ | | internal/| |
| +-----+------+ +-----+------+ +----+-----+ |
+--------|----------------|---------------|--------+
v v v
schema: orders schema: billing schema: catalog
(one database server, one schema per module)
Each module exposes a small public interface. For example, billing publishes chargeOrder(orderId, amount) -> ChargeResult and a ChargeCompleted event. Its repositories, entities and helpers stay internal. Callers depend on that contract the same way they once depended on the HTTP API, so the contract stays the main design artifact.
The core rule is that no module imports another module's internal package or queries another module's schema. Express that rule as a build-time check, not a code-review convention. One option is an architecture test that fails when anything under orders references billing.internal. Another is per-schema database credentials, so a cross-schema join fails at runtime in staging.
Automated checks matter because in-process calls remove the friction that used to protect the boundary. A shortcut import compiles cleanly and ships the same day. Without a failing build, those shortcuts pile up, and the codebase decays into a big ball of mud.
// File 1: billing/api/BillingApi.java (the module's public contract)
package com.example.billing.api;
public interface BillingApi {
// Same contract the old HTTP endpoint exposed, now an in-process call
ChargeResult chargeOrder(String orderId, long amountCents);
record ChargeResult(String chargeId, boolean succeeded) {}
record ChargeCompleted(String orderId, String chargeId) {} // published event
}
// File 2: src/test/java/.../ModuleBoundaryTest.java (runs on every build)
package com.example.architecture;
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;
@AnalyzeClasses(packages = "com.example")
class ModuleBoundaryTest {
// Nothing outside billing may touch billing's internals; use billing.api instead
@ArchTest
static final ArchRule billingInternalsArePrivate =
noClasses().that().resideOutsideOfPackage("com.example.billing..")
.should().dependOnClassesThat()
.resideInAPackage("com.example.billing.internal..");
// Same rule for orders; repeat per module or generate the rules in a loop
@ArchTest
static final ArchRule ordersInternalsArePrivate =
noClasses().that().resideOutsideOfPackage("com.example.orders..")
.should().dependOnClassesThat()
.resideInAPackage("com.example.orders.internal..");
}
A billing module's public interface and an ArchUnit test that fails the build when any other module depends on a module's internal package.
Implementing the migration one service at a time
That is why each service moves in a fixed order, with the boundary kept explicit at every step.
Move the code, keep the contract
Copy the service's code into its own module and expose the same operations it served over the network. Then write an adapter that implements the old client interface by calling the module directly. Callers keep using the interface they already know, so the only thing that changes is the transport. If request and response shapes stay identical, any regression you see after the switch comes from the in-process path and not from a contract change.
Switch the call behind a flag
Route between the remote client and the in-process adapter with a feature flag or routing switch, and ramp traffic gradually. Keep the remote service deployed until the in-process path has handled real load. Rolling back should mean flipping the switch, not redeploying.
Collapse distributed workflows
Once the participating modules share a database, a saga's compensating steps can become a single local transaction. That removes the partial-failure states the saga had to handle. Events consumed only inside the monolith can become direct calls or go through an in-process event bus. Keep the message queue for any consumer that needs durability across restarts, retries, or load leveling during traffic spikes, because an in-process bus loses its pending events when the process dies.
Don't stall in the hybrid state
While some services are merged and others are still remote, you run both architectures and pay for both. Give each service a removal date for its remote path, and delete the flag and the old client once that date passes.
// Contract callers already depend on: unchanged by the migration.
export interface InventoryClient {
reserve(req: { sku: string; qty: number }): Promise<{ reservationId: string }>;
}
// Old transport: HTTP call to the still-deployed remote service.
export class RemoteInventoryClient implements InventoryClient {
constructor(private baseUrl: string) {}
async reserve(req: { sku: string; qty: number }) {
const res = await fetch(`${this.baseUrl}/reservations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`inventory: HTTP ${res.status}`);
return (await res.json()) as { reservationId: string };
}
}
// New transport: adapter over the in-process module, same shapes in and out.
import { InventoryModule } from "./modules/inventory";
export class InProcessInventoryClient implements InventoryClient {
constructor(private mod: InventoryModule) {}
reserve(req: { sku: string; qty: number }) {
return this.mod.reserve(req);
}
}
// Routing switch: percentage ramp read from config, so rollback is a config change.
// Remove this class, the flag, and RemoteInventoryClient on the agreed removal date.
export class RoutedInventoryClient implements InventoryClient {
constructor(
private remote: InventoryClient,
private local: InventoryClient,
private inProcessPercent: () => number, // 0 = all remote, 100 = all in-process
) {}
reserve(req: { sku: string; qty: number }) {
const useLocal = Math.random() * 100 < this.inProcessPercent();
return (useLocal ? this.local : this.remote).reserve(req);
}
}
An adapter keeps the existing client interface while a percentage-based routing switch ramps calls from the remote service to the in-process module, and setting the percentage back to 0 rolls the change back.
Consolidating data and validating each cutover
Code moves cheaply. Data is where a consolidation can go wrong in ways you cannot easily undo. Start by moving each service's database into its own schema on a single shared instance. Do not merge tables yet. Each module connects with its own role, and that role is granted privileges only on its own schema, for example GRANT USAGE ON SCHEMA billing TO billing_module, plus table-level grants inside it. The database then enforces ownership, so module boundaries hold even when someone is in a hurry.
Forbid cross-module joins at first. A join across schemas couples two modules at the storage layer, which is the coupling the modular monolith is meant to prevent. Read through the owning module's interface instead. Later, decide case by case whether to merge tables. Merge only when two tables are always written together and a single transaction would remove reconciliation code you currently maintain.
Plan the move as three jobs
- Migration. Create the schema and roles.
- Backfill. Copy historical data in idempotent batches, so a failed batch can be rerun safely.
- Verification. Compare row counts and per-partition checksums between source and target before any reads switch over.
Validate each cutover
Compare both behavior and latency. Run the existing contract tests against the in-process path. Mirror read traffic to it and diff the responses. For writes, run both paths in parallel and reconcile the results.
Write the rollback criteria before you start, for example a response mismatch rate or a p99 latency regression above an agreed threshold. Keep the old service and its data writable until the new path has stayed within those thresholds for the full observation window. Decommission the old service only after that.
-- PostgreSQL. One instance, one schema per module.
-- Owner roles run migrations (DDL). Module roles are what the app connects as.
CREATE ROLE billing_owner NOLOGIN;
CREATE ROLE orders_owner NOLOGIN;
CREATE ROLE billing_module LOGIN PASSWORD 'placeholder-billing-pw';
CREATE ROLE orders_module LOGIN PASSWORD 'placeholder-orders-pw';
CREATE SCHEMA billing AUTHORIZATION billing_owner;
CREATE SCHEMA orders AUTHORIZATION orders_owner;
-- Remove implicit access so the only way in is an explicit grant.
REVOKE ALL ON SCHEMA billing FROM PUBLIC;
REVOKE ALL ON SCHEMA orders FROM PUBLIC;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
-- billing_module can read and write billing data, and nothing else.
GRANT USAGE ON SCHEMA billing TO billing_module;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA billing TO billing_module;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA billing TO billing_module;
-- Tables that migrations create later get the same grants automatically.
ALTER DEFAULT PRIVILEGES FOR ROLE billing_owner IN SCHEMA billing
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO billing_module;
ALTER DEFAULT PRIVILEGES FOR ROLE billing_owner IN SCHEMA billing
GRANT USAGE, SELECT ON SEQUENCES TO billing_module;
-- Same pattern for orders.
GRANT USAGE ON SCHEMA orders TO orders_module;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA orders TO orders_module;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA orders TO orders_module;
ALTER DEFAULT PRIVILEGES FOR ROLE orders_owner IN SCHEMA orders
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO orders_module;
ALTER DEFAULT PRIVILEGES FOR ROLE orders_owner IN SCHEMA orders
GRANT USAGE, SELECT ON SEQUENCES TO orders_module;
-- Unqualified table names resolve only to the module's own schema.
ALTER ROLE billing_module SET search_path = billing;
ALTER ROLE orders_module SET search_path = orders;
-- Check: connected as orders_module, this should fail with
-- "permission denied for schema billing". That failure enforces the no-cross-module-joins rule.
-- SELECT o.id FROM orders.orders o JOIN billing.invoices i ON i.order_id = o.id;
A PostgreSQL schema-per-module setup in which each module's login role can use only its own schema, so the database rejects cross-module joins.
Impact on operations, cost and engineering time
Once the last service is gone, the operating model changes more than the code. Separate pipelines become one build and release pipeline, so every deploy ships every module. Per-service autoscaling no longer exists. You scale the whole process on the busiest module's signal, which means a CPU-heavy report job and a latency-sensitive API now share replicas.
Resource isolation therefore has to move inside the process. Use bulkheads, such as a separate thread pool or connection pool per module, so a slow dependency in billing cannot exhaust the threads that serve checkout. A crash still takes down everything, which makes the blast radius larger.
When you merge dashboards and alerts, keep a module label on every metric so each alert still identifies an owner. On-call moves from per-service rotations to one rotation with module-based routing.
| Gets cheaper | Gets more expensive |
|---|---|
| Baseline infrastructure per service | Independent scaling of hot paths |
| Network hops and serialization | Blast radius of a crash or bad deploy |
| Cross-team API and version coordination | Build and test-suite duration |
| Distributed tracing and debugging | Merge conflicts in a shared repository |
| Pipeline maintenance | Team boundaries, enforced through code ownership |
Illustrative estimate (assumed numbers, not a benchmark)
Net monthly saving = (S − 1) × B + H × R − (C + M × R)
Assume the following inputs:
- S = 8 services
- B = $400 baseline cost per service per month
- H = 30 coordination hours saved per month
- R = $100 per hour
- C = $500 in extra CI cost per month
- M = 15 hours per month spent resolving conflicts
The calculation is 7 × 400 + 30 × 100 − (500 + 1,500) = $3,800 per month. Replace these assumptions with your own figures. Engineering time often outweighs the infrastructure line.
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.