What a modular monolith is and why teams merge microservices into one

A modular monolith is a single deployable unit split into modules with explicit boundaries. Each module exposes a small public interface, which other modules call in-process. Everything else stays private: domain classes, repositories and helpers. Each module also owns its tables. No other module reads or writes them directly, even though they may sit in the same database. That last rule is what lets you later extract a module, or reason about one, without auditing the whole codebase.

Property Layered monolith Modular monolith Distributed monolith
Deployable units One One Many, released in lockstep
Primary split Technical layers (controllers, services, data) Business capabilities Business capabilities, on paper
Cross-boundary calls Any class can call any other Through public interfaces only Synchronous network calls
Data access Shared schema, any table Each module owns its tables Separate stores, but often shared or tightly coupled
Typical failure mode Tangled dependencies across layers Boundary erosion if rules are not enforced Cascading latency and partial failures

Merging services into a modular monolith is targeted refactoring. It is not a microservices rollback, and it does not mean the original split was a mistake. Some services change together, deploy together and call each other synchronously on every request. For those services, the network boundary adds latency, retries, serialization and failure handling but buys no independence. Moving them into one process removes that cost. Keeping the module boundaries keeps the separation that was worth having.

Who should consolidate and how to find candidates from evidence

The best candidates sit with backend and platform teams where one team owns several services that are tightly coupled. If the same five engineers run the order, pricing and inventory services, the split gives them no organisational benefit. It only gives them more pipelines, dashboards and on-call alerts. Don't decide from intuition. Look at the data you already have.

Trace data. Look for synchronous fan-out on hot paths: a single user request that makes several serial calls between the same group of services. Every hop adds latency and is one more place where a timeout can occur.

Version control and deploy history. Count how often services change in the same pull request, or are released within minutes of each other. When services always ship together, their boundary is not providing independent deployability.

Ownership. Check your service catalogue or CODEOWNERS files. A single owning team means a merge requires no negotiation between teams and does not change who is accountable.

Compensating logic. Find sagas, outbox relays or two-phase workflows that exist only because the data was split. Inside one process, a local database transaction could replace them.

Score each cluster of services against this checklist:

Signal Evidence source Score 1 if…
Hot-path fan-out Distributed traces Three or more synchronous hops within the cluster per request
Co-change VCS history Most changes touch more than one service in the cluster
Co-deploy CI/CD logs Releases are routinely coordinated
Single owner Service catalogue One team owns every service
Split-only sagas Code review Compensation logic guards data that one transaction could cover

A cluster that scores four or five is a strong candidate. A score of two or less suggests the boundary is doing real work and should stay.

When to avoid a merge and what alternatives to try first

A low score is the first warning sign, but some situations rule out a merge even when call volume is high.

Cases where consolidation is the wrong tool

  • Different owners, different change rates. If one team ships daily and another quarterly, a shared deployable forces them onto a single release train. The slower cadence usually wins, and the fast team loses throughput.
  • Stack mismatches. Merging a JVM service with a Python or Go service means rewriting one of them. That cost is rarely justified by removing network hops.
  • Independent scaling or isolation. A memory-hungry batch worker or a service handling untrusted input should not share a process with latency-sensitive request paths. A leak or crash in one would take down the other.
  • Autonomy as a requirement. When separate on-call rotations, compliance scopes or deployment approvals exist for good reasons, a merge removes them.

Cheaper alternatives

Alternative Solves chattiness when Leaves unsolved
Batching Callers loop over IDs and make one request per item Calls that must be sequential
Caching Reads dominate and data tolerates bounded staleness Write-heavy or strongly consistent paths
Coarser-grained APIs Clients stitch several fine-grained calls into one use case Boundaries that are wrong at the domain level
Co-location (same host or pod) Latency comes from network distance, not call count Serialization cost and distributed failure modes

Try these first and measure again. If the cluster still scores high after batching and API redesign, the boundary itself is probably wrong, and merging becomes the better option.

How the incremental migration works

The safe path changes one thing at a time and keeps the network route working until nothing depends on it. Each step can be reversed with a configuration change instead of a redeploy of several services.

Step 1: Put a module interface in front of the service logic

Inside the service you plan to absorb, separate the business logic from the HTTP or RPC handler. Define an interface such as InventoryModule.reserve(order) that the handler calls. The service behaves exactly as before. You now have a seam you can call from either side of the network.

Step 2: Run the module in-process behind the existing contract

Package that module into the host application, and keep its public API endpoint serving the same contract. External consumers and dashboards see no change. Now the logic runs in two places, so you can compare their outputs before anything depends on the new path.

Step 3: Move callers one at a time

For each caller, write two implementations of the interface. One is a remote client that wraps the existing network call. The other calls the in-process module directly. A configuration switch picks between them for each caller. Flip one caller, then watch its error rates and latency. If something looks wrong, flip it back.

Step 4: Retire the endpoint

When traffic logs show no remaining callers, delete the remote client, the endpoint and the deployment.

The three states:

  • Before: Orders → network → Inventory service.
  • During: Orders → switch → either the network or the in-process module.
  • After: Orders → in-process module only.
import json
import os
import urllib.request
from typing import Protocol


class InventoryModule(Protocol):
    # The seam from Step 1: callers depend on this, not on the transport.
    def reserve(self, order: dict) -> dict: ...


class InProcessInventory:
    # The extracted business logic, packaged inside the host application.
    def reserve(self, order: dict) -> dict:
        return {"order_id": order["id"], "status": "reserved"}


class RemoteInventory:
    # Wraps the existing network call; deleted only in Step 4.
    def __init__(self, base_url: str):
        self.base_url = base_url

    def reserve(self, order: dict) -> dict:
        req = urllib.request.Request(
            f"{self.base_url}/reservations",
            data=json.dumps(order).encode(),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=2) as resp:
            return json.load(resp)


def inventory_for(caller: str) -> InventoryModule:
    # One switch per caller, e.g. INVENTORY_MODE_ORDERS=inprocess.
    # The default stays "remote", so rolling back is a config change, not a redeploy.
    mode = os.environ.get(f"INVENTORY_MODE_{caller.upper()}", "remote")
    if mode == "inprocess":
        return InProcessInventory()
    return RemoteInventory("")

One InventoryModule interface has two implementations, a remote client that wraps the existing HTTP call and an in-process module, and a per-caller configuration switch selects between them.

Enforcing module boundaries and replacing distributed patterns

Once the network hop is gone, nothing stops Orders from reaching into Inventory's internals except discipline, and discipline erodes. Make the boundary mechanical. Expose each module through one public API package and mark everything else internal using your language's visibility rules. Declare allowed module dependencies in the build so a forbidden import fails compilation, not code review. Add an architecture test in CI that asserts rules like "nothing outside inventory may import inventory.internal" and "inventory must not depend on orders." Give each module exactly one owning team. Shared ownership is how exceptions accumulate.

Replacing distributed patterns

Sagas existed because you could not commit across two databases. If both modules now share one database and the business operation must be all-or-nothing, replace the saga and its compensations with a single local transaction. Keep compensations only where the steps are genuinely separate in time, such as a refund issued days after shipment.

Synchronous RPC becomes a function call on the module's public interface. Asynchronous events become in-process events. If external consumers still subscribe, write events to an outbox table in the same transaction and publish them from there.

Retries and timeouts that guarded in-process calls against network failure can go. Keep them at real I/O edges: third-party APIs, the database, the message broker. Keep idempotency on anything an external caller can retry and on every outbox consumer, because delivery is still at-least-once.

# tests/test_architecture.py: runs in CI and fails the build on forbidden imports.
import ast
from pathlib import Path

SRC = Path(__file__).resolve().parent.parent / "src"

# Allowed direction only: orders may call inventory, never the reverse.
FORBIDDEN_DEPS = [("shop.inventory", "shop.orders")]

def module_of(path):
    return ".".join(path.relative_to(SRC).with_suffix("").parts)

def imports_of(path):
    tree = ast.parse(path.read_text(), filename=str(path))
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            yield from (a.name for a in node.names)
        elif isinstance(node, ast.ImportFrom):
            # Ban relative imports so every dependency is visible to this test.
            assert node.level == 0, f"{path}: use absolute imports"
            yield from (f"{node.module}.{a.name}" for a in node.names)

def in_pkg(name, pkg):
    return name == pkg or name.startswith(pkg + ".")

def test_module_boundaries():
    errors = []
    for path in SRC.rglob("*.py"):
        src = module_of(path)
        for imp in imports_of(path):
            parts = imp.split(".")
            # Rule 1: X.internal is private to module X.
            if "internal" in parts:
                owner = ".".join(parts[: parts.index("internal")])
                if not in_pkg(src, owner):
                    errors.append(f"{src} -> {imp} (internal)")
            # Rule 2: declared one-way dependencies between modules.
            for frm, to in FORBIDDEN_DEPS:
                if in_pkg(src, frm) and in_pkg(imp, to):
                    errors.append(f"{src} -> {imp} (forbidden direction)")
    assert not errors, "Boundary violations:\n" + "\n".join(errors)

A dependency-free architecture test that parses every source file and fails CI when code imports another module's internal package or depends on a module in a forbidden direction.

Consolidating data and running the merged system

Move data last, and move it conservatively. Put every module's tables into one database but keep a separate schema per module, or at least strict table ownership, and grant each module's database role access only to its own schema. One physical database makes cross-module joins easy, and a shortcut join is the quickest way to undo the boundaries you built in code.

Backfill before you cut over. Copy historical rows while the old service still owns writes, compare row counts and checksums, then switch writes one module at a time. Start with modules that have the fewest downstream readers. When a saga collapses into a single transaction, check the locking. Steps that used to commit separately now hold row locks until the whole unit finishes. Long transactions that touch hot rows, such as inventory counts and account balances, will serialize under load.

On the operations side, one pipeline now ships everything, so a broken test in any module blocks every release. Size the combined process from measured peak memory and CPU of the merged workload, not by adding up the old containers. Shared runtime overhead shrinks, but heaps and caches compete.

Process isolation is gone, so add bulkheads yourself: separate thread or connection pools per module, per-module timeouts, and concurrency limits. A memory leak or a bad deploy in one module now takes down all of them. Tag every metric and log line with the module name. Emit in-process spans at module boundaries so traces still show where time goes.

-- PostgreSQL: one database, one schema per module, one role per module.
-- Each module connects as its own role and can only see its own schema.

-- Stop every role from creating objects in the shared public schema.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

-- Roles for each module (placeholder passwords; load real ones from a secret store).
CREATE ROLE orders_app    LOGIN PASSWORD 'change-me-orders';
CREATE ROLE inventory_app LOGIN PASSWORD 'change-me-inventory';

-- Each module owns its schema, so it can manage its own tables and nothing else.
CREATE SCHEMA orders    AUTHORIZATION orders_app;
CREATE SCHEMA inventory AUTHORIZATION inventory_app;

-- Unqualified table names resolve to the module's own schema.
ALTER ROLE orders_app    SET search_path = orders;
ALTER ROLE inventory_app SET search_path = inventory;

-- A shortcut join from orders into inventory fails with a permission error,
-- because orders_app has no USAGE on the inventory schema.

-- Backfill check: run this against the old service's database and the merged
-- database, then compare the results before switching writes for this module.
SELECT count(*) AS row_count,
       md5(string_agg(o::text, '|' ORDER BY o.id)) AS content_checksum
FROM orders.orders AS o;

Sets up PostgreSQL schema-per-module ownership with separate roles so cross-module joins fail by default, then runs a row-count and checksum query to compare the backfilled data with the source before cutover.

Impact on cost, engineering time and team ownership

Judge the merge against a baseline you capture before the first service moves. Track hot-path latency at p50, p95 and p99, error rates from timeouts and connection resets, infrastructure cost per request, deploy frequency, and lead time for changes. Without the "before" numbers, any improvement is only an anecdote.

Illustrative estimate (assumed numbers, not a benchmark): saved latency ≈ N × (per-hop network and serialization time − in-process call time).

Assume a request makes N = 4 sequential hops at 3 ms each, and an in-process call costs close to zero. That gives 4 × 3 = 12 ms saved at the median. Tail savings can be larger because slow hops compound, so measure p99 separately.

You gain You pay
Less retry, timeout and saga compensation code Longer builds and test runs
Fewer pipelines and deploy configs One shared release train
Single-process stack traces for debugging More coordination before merging

The costs are real. Use incremental builds and run tests only for the modules a change affects. Otherwise lead time regresses and cancels out the gains.

Conway's law still applies: the code will mirror your team structure, so assign ownership deliberately.

  • Module ownership: each module gets one owning team, recorded in an ownership file that the review tooling enforces.
  • Review rules: changes to a module's public interface need approval from its owners. Changes to a module's internals stay within the owning team.
  • Shared concerns: name an explicit owner for the build pipeline, third-party dependency upgrades and runtime upgrades. This can be a platform team or a rotation. Without a named owner, these tasks drift until something breaks.

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.