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 internal directory 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.internal may only be accessed by classes in billing. 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

  1. Copy a snapshot.
  2. Replicate changes, or dual-write, until the copy catches up.
  3. Verify with row counts and per-partition checksums.
  4. 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.