What merging microservices into a monolith means and when it makes sense
To merge microservices into a monolith is to collapse several independently deployed services into one deployable unit while keeping their internal boundaries as modules. The target is a modular monolith, not the codebase you had before the split. Each former service becomes a module with its own public interface and owned data. Network calls become in-process calls, and you deploy one process instead of many.
Teams usually do this because they are paying the costs of distribution without getting its benefits. The common signs are services that always ship together, synchronous call chains on latency-sensitive paths, and one team owning many services. Sagas or outbox workarounds that exist only to fake a transaction across a boundary are another sign. In each case the network adds failure modes, and the boundary buys nothing.
| Signal | Points toward |
|---|---|
| Services always deploy in lockstep | Merge |
| Hot path spans several synchronous hops | Merge |
| One team owns most services | Merge |
| Distributed transactions cover one business operation | Merge |
| Components scale on very different load profiles | Keep split |
| Strict security or compliance isolation | Keep split |
| Independent release cadences across teams | Keep split |
When not to merge
Avoid merging when the pain comes from weak contracts, poor observability or immature CI rather than from the boundaries themselves. You can fix those in place. Often the better move is partial service consolidation, where you merge only the tightly coupled cluster and leave independent services alone. If the system works and the cost is tolerable, leaving it as it is remains a legitimate choice.
Mapping the current system before touching code
Before moving any code, build a picture of how the services actually interact at runtime, not how the architecture diagram says they do.
Build the dependency graph from evidence
Start with synchronous calls. Distributed traces, gateway logs, or service-mesh telemetry show which service calls which, how often, and on which request paths. Then add async edges: for every topic or queue, record the producers and consumers. Async edges are the ones teams forget, and they cause the most surprises later because they don't fail loudly when broken. For each service, also record which tables or stores it writes to and which it only reads, plus the shared internal libraries it depends on and at which versions.
| Signal | What it tells you | Consolidation implication |
|---|---|---|
| Chatty synchronous calls on one request path | Latency and failure coupling | Strong merge candidate |
| Shared writes to the same data | Ownership is already blurred | Merge together, or not at all |
| Async-only, low-volume edges | Loose coupling | Can stay separate longer |
| Shared library pinned at diverging versions | Hidden build coupling | Align versions before merging |
Pick clusters and define stopping points
Clusters with dense call graphs and shared data are the first candidates. Merging them removes the most network hops and coordination for the least effort.
Next, turn each cluster merge into a stopping point you would be content to keep running for a year. A valid stopping point has one deployable unit per cluster, a clear data owner, and no traffic split between old and new paths. If the migration stalls there, you have a smaller distributed system. You do not have a half-migrated hybrid that pays for both the network and the monolith's coordination.
Designing the target: a modular monolith with enforced boundaries
Each former service becomes one module in the monolith. Its old API contract maps to the module's public interface: a small set of exported types and functions, or a facade class, that other modules may call. Everything else, including persistence code, domain logic, and helpers, goes into internal packages that no other module can import. This keeps the boundaries that made the services independently understandable, without the network hop between them.
Enforce boundaries mechanically
Code review alone will not hold the line. Once modules share a process, one module calling another's repository directly is a one-line change, and under deadline pressure someone will make it. Each shortcut couples modules more tightly. After enough of them, the monolith is a big ball of mud, which is harder to split again than the services you started with.
Make violations fail the build:
- Language visibility. Go refuses to compile imports of an
internaldirectory from outside its parent. Java's package-private access and module system can hide implementation packages. - Build tool dependency rules. Declare each module as its own build target or subproject, and list its allowed dependencies explicitly.
- Architecture tests. These run in the normal test suite. A typical rule says: classes in
billing.internalmay only be accessed by classes inbilling. The test scans the compiled code and fails if any other module references those classes.
Align ownership with modules
Conway's law still applies in a single repository. If a team owns several modules, or several teams share one, the boundaries will drift toward how people actually communicate. Assign exactly one owning team to each module and record it in a code-owners file. Changes to a module's public interface then need that team's approval, so the interface stays deliberate instead of growing by accident.
// Architecture test using ArchUnit (JUnit 5). Runs with the normal test suite.
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.classes;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
// Scan the production code of the whole monolith, not the tests.
@AnalyzeClasses(
packages = "com.example.shop",
importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {
// The billing module's internals (repositories, domain logic, helpers)
// may only be used by code inside the billing module itself.
// Other modules must go through billing's public facade instead.
@ArchTest
static final ArchRule billingInternalsStayPrivate =
classes().that().resideInAPackage("com.example.shop.billing.internal..")
.should().onlyBeAccessed().byAnyPackage("com.example.shop.billing..");
// Same rule for the orders module. Add one rule per former service.
@ArchTest
static final ArchRule ordersInternalsStayPrivate =
classes().that().resideInAPackage("com.example.shop.orders.internal..")
.should().onlyBeAccessed().byAnyPackage("com.example.shop.orders..");
// Treat each top-level package as a module and forbid dependency cycles
// between them, so modules can still be extracted again later.
@ArchTest
static final ArchRule modulesAreFreeOfCycles =
slices().matching("com.example.shop.(*)..")
.should().beFreeOfCycles();
}
An ArchUnit test fails the build if another module reaches into a module's internal package. It also fails if the modules form a dependency cycle.
How the incremental reverse migration works, step by step
Run the migration as a series of small, reversible moves. Each step should change one thing, so a regression points to a single cause.
1. One repository, one build
Move each service's code into the shared repository as its own module. Keep its entry point unchanged. Nothing runs differently yet. The gain is that you can make atomic changes across modules and catch dependency conflicts at build time rather than in production.
2. One process, same network interfaces
Start every module inside a single process, but leave their HTTP or RPC listeners in place. Callers still use the network, now over loopback. This step tests startup order, shared configuration, and memory headroom on their own, before any call semantics change.
3. Collapse calls one edge at a time
For each caller-callee pair, define an interface with two implementations: the existing remote client and a direct in-process adapter. A flag or routing rule picks which one runs, as in the sketch that follows. If behavior changes, flip the flag back without redeploying.
Replacing network contracts
- DTOs: Keep the request and response DTOs at module boundaries at first. Passing internal domain objects across the boundary would quietly couple the two modules.
- Retries and timeouts: Remove retries and timeouts from in-process paths. A function call doesn't fail the way a network hop does, so retrying it usually just repeats a bug.
- Async events: Keep an event on the queue if it needs durability, replay, or backpressure. Turn fire-and-forget notifications into in-process events. Turn request-reply exchanges that were only async to tolerate the network into direct calls.
// Boundary DTOs stay the same on both paths, so callers can't tell which one runs.
export interface GetInvoiceRequest { invoiceId: string }
export interface InvoiceDto { id: string; totalCents: number; status: string }
export interface BillingClient {
getInvoice(req: GetInvoiceRequest): Promise<InvoiceDto>;
}
// Existing path: a network hop, so it keeps its timeout.
export class RemoteBillingClient implements BillingClient {
constructor(private baseUrl: string) {}
async getInvoice(req: GetInvoiceRequest): Promise<InvoiceDto> {
const res = await fetch(`${this.baseUrl}/invoices/${encodeURIComponent(req.invoiceId)}`, {
signal: AbortSignal.timeout(2000),
});
if (!res.ok) throw new Error(`billing returned ${res.status}`);
return (await res.json()) as InvoiceDto;
}
}
// New path: a plain function call into the billing module. No retries, no timeout.
export class InProcessBillingClient implements BillingClient {
constructor(private billing: { findInvoice(id: string): Promise<{ id: string; totalCents: number; status: string }> }) {}
async getInvoice(req: GetInvoiceRequest): Promise<InvoiceDto> {
const inv = await this.billing.findInvoice(req.invoiceId);
// Map to the DTO rather than passing billing's domain object across the boundary.
return { id: inv.id, totalCents: inv.totalCents, status: inv.status };
}
}
// One flag per edge. Read it per call so flipping it back doesn't need a redeploy.
export function billingClientFor(flags: { isOn(name: string): boolean },
remote: BillingClient, local: BillingClient): BillingClient {
return {
getInvoice: (req) =>
(flags.isOn("orders-to-billing.in-process") ? local : remote).getInvoice(req),
};
}
A single caller-callee edge exposed through one interface, with a remote HTTP client and an in-process adapter that share the same DTOs and are chosen at call time by a feature flag.
Staging the database merge last
Move the data only after the code runs as one process. Changing code and storage at the same time makes it hard to tell which change caused a regression.
Co-locate first, keep ownership
Start by moving each service's database onto one server while each keeps its own schema. Give every module a role that can read and write only its own schema, for example CREATE SCHEMA billing; followed by GRANT USAGE ON SCHEMA billing TO billing_app;. Do not grant other modules' roles access. The grants enforce the module boundaries from the previous section at the storage layer, so a careless query fails instead of silently creating a dependency.
Joins and transactions, case by case
Once the tables share a server, cross-schema joins and single transactions become possible. Allow them only where the business genuinely requires atomicity, such as debiting a balance and recording the order together. Every join couples one module to another's table layout, and every shared transaction couples their failure modes and lock contention. Record each exception so it stays visible.
Moving live data
- Copy a snapshot.
- Replicate changes, or dual-write, until the copy catches up.
- Verify with row counts and per-partition checksums.
- Cut over during a short write freeze.
The risks are writes that land during the gap, sequences and identity values that collide, and timestamps that drift between sources. Keep the old databases read-only until reconciliation passes.
-- PostgreSQL: two modules co-located on one server, one schema each.
-- Owner roles hold DDL rights; app roles get only DML on their own schema.
CREATE ROLE billing_owner NOLOGIN;
CREATE ROLE orders_owner NOLOGIN;
CREATE ROLE billing_app LOGIN PASSWORD 'placeholder-change-me';
CREATE ROLE orders_app LOGIN PASSWORD 'placeholder-change-me';
-- Stop anyone from creating tables in the shared public schema.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
CREATE SCHEMA billing AUTHORIZATION billing_owner;
CREATE SCHEMA orders AUTHORIZATION orders_owner;
-- Each app role can reach only its own schema.
GRANT USAGE ON SCHEMA billing TO billing_app;
GRANT USAGE ON SCHEMA orders TO orders_app;
-- Tables and sequences created later by each owner are covered automatically.
ALTER DEFAULT PRIVILEGES FOR ROLE billing_owner IN SCHEMA billing
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO billing_app;
ALTER DEFAULT PRIVILEGES FOR ROLE billing_owner IN SCHEMA billing
GRANT USAGE, SELECT ON SEQUENCES TO billing_app;
ALTER DEFAULT PRIVILEGES FOR ROLE orders_owner IN SCHEMA orders
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO orders_app;
ALTER DEFAULT PRIVILEGES FOR ROLE orders_owner IN SCHEMA orders
GRANT USAGE, SELECT ON SEQUENCES TO orders_app;
-- Without further grants, this fails for orders_app with a permission error:
-- SELECT * FROM billing.invoices;
-- Documented exception (recorded in the coupling log): billing may read
-- order totals. Grant one table, read-only, never the whole schema.
CREATE TABLE orders.orders (id bigint PRIMARY KEY, total_cents bigint NOT NULL);
GRANT USAGE ON SCHEMA orders TO billing_app;
GRANT SELECT ON orders.orders TO billing_app;
PostgreSQL roles, schemas and grants that keep each module confined to its own schema on a shared server, with one explicit, read-only cross-module exception.
Deployment simplification and operating a single process
Once the services ship as one artifact, collapse their pipelines into one. Fold the separate configuration files and secret stores into a single namespaced scheme, such as billing.* and orders.*, so ownership stays visible and two modules cannot silently collide on a key like TIMEOUT_MS.
A shared pipeline creates coordination costs that did not exist before. Merge conflicts rise, one flaky test blocks every team, and deploys tend to drift into weekly release trains. To keep deploys fast:
- Cache dependencies and build outputs.
- Run only the tests affected by the changed modules.
- Give each module its own build target, so a change to billing does not rebuild everything.
On the operations side, merge dashboards and log streams, but tag every log line, metric and span with the module name. Without that tag, incidents are hard to attribute. Re-size the process from measured peak usage, not from the sum of the old service limits, because the sum usually overstates what you need. Then update on-call rotations so each module still has a named owner.
The real loss is failure isolation. A slow dependency can now exhaust threads that the whole process shares. You can restore isolation in three ways:
| Technique | Isolates | Cost |
|---|---|---|
| Bulkheads (per-module connection and concurrency limits) | Resource exhaustion | Tuning effort |
| Separate thread pools | Slow I/O from blocking unrelated work | Memory, context switches |
| Same artifact deployed in differently configured groups | Crashes and load spikes; allows independent scaling | More deployments to operate |
The third option recovers most of what independent services gave you, while keeping a single codebase and a single build.
Verifying each step and the impact on cost and engineering time
Before a merged module takes live traffic, run it alongside the remote service it replaces. For read paths, mirror production requests to the in-process implementation, discard its responses, and diff them against the real ones. Ignore fields that legitimately differ, such as timestamps and generated IDs. For write paths, mirroring would apply the same side effects twice. Use a parallel run instead: a feature flag routes a small slice of traffic to the new path while you compare outcomes in the data store.
Write the rollback criteria down before cutover. Examples include a response-mismatch rate above an agreed threshold, a p99 latency regression, or a rise in error rate. Teams that set criteria afterward tend to explain away bad results. Because the flag controls routing, rolling back is a config change rather than a redeploy.
Illustrative estimate (assumed numbers, not a benchmark). The latency saved per request is roughly:
saved ≈ sequential hops × (network round trip + serialization + deserialization)
Assume a request makes 3 sequential service calls, with a 1.5 ms round trip and 0.5 ms of encoding work each. That gives 3 × 2 ms = 6 ms per request. Calls that ran concurrently save only the longest one, not the sum.
| Engineering-time effect | Gains | Costs |
|---|---|---|
| Delivery | Fewer pipelines and API contracts to version | Longer builds and test runs |
| Debugging | One stack trace instead of distributed traces | A bad module can affect the whole process |
| Effort | Less infrastructure to maintain | The migration itself takes months of work |
Record build time, deploy frequency and incident resolution time before you start. That baseline is how you check whether the trade paid off.
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.
- 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.