What splitting a monolith actually solves

Splitting a monolith means taking one part of it, such as billing, search, or notifications, and running it as a separate service. That service has its own deploy pipeline, its own runtime, and its own data store. The last part matters most. If the new service still reads and writes the monolith's tables, you have added a network hop and kept the coupling.

Treat a split as a tool for removing one specific constraint. It is not a sign of engineering maturity, and it is not a default end state. Teams usually split for one of three reasons:

  • Independent scaling. One component needs far more CPU, memory, or replicas than the rest. Scaling the whole monolith to serve it wastes resources.
  • Separate ownership. A team needs to own a part of the system end to end, without negotiating every change with the people who share the codebase.
  • Independent releases. One area needs to ship on its own schedule, and the shared release train keeps blocking it.

Each of these benefits comes with costs: network failures, distributed data, and more operational surface. That is why the rest of this guide uses a simple decision rule for when to split a monolith. First, name the pain you have measured. Then ask whether a service boundary would actually remove it.

This guide is for backend and platform teams facing a named, persistent constraint. It is not for teams looking for a general modernization project.

The three signals: scaling bottlenecks, team contention, and deploy coupling

Each signal below has a cheaper explanation that you should rule out before you treat it as a case for a service boundary.

Scaling bottlenecks

The signal is a component whose resource profile differs clearly from the rest of the application. It might be CPU-heavy image processing, a memory-hungry report builder, bursty webhook ingestion, or a latency-sensitive checkout path. In a single process, you scale everything to serve that one component, and you pay for idle capacity everywhere else. To confirm it, break down latency, CPU and memory by endpoint or module, and check whether one area drives replica count.

Team contention

Look for repeated merge conflicts in the same modules, review queues that wait on another team, and shared code that nobody clearly owns. Version control history shows this. Measure change frequency and conflict counts per directory, and count how many teams touch each one.

Deploy coupling

Unrelated changes ship together. One team's risky migration holds back everyone else, a rollback reverts several teams' work, and the slowest area sets the release cadence. Release and rollback records show how often that happens.

Signal Evidence Data source Often confused with
Scaling bottleneck Per-endpoint latency, CPU and memory APM, metrics, profiler Unindexed query, N+1 calls, missing cache
Team contention Conflicts and change frequency per directory Version control history Poor module boundaries, unclear code ownership
Deploy coupling Bundled releases, multi-team rollbacks Release and incident logs Slow test suite, manual release steps, no feature flags
#!/usr/bin/env bash
# Rank directories by how often they change and how many people change them.
# Hotspots with many commits AND many distinct authors are candidates for
# team contention. Map authors to teams afterwards (e.g. via your ownership file).
set -euo pipefail

SINCE="${1:-6 months ago}"   # time window to analyse

# Each commit prints a marker line "@<hash> <author-email>" followed by the
# files it touched. Merge commits are skipped so changes are not double counted.
git log --since="$SINCE" --no-merges --pretty=format:'@%H %ae' --name-only |
awk '
  # Collapse a file path to a two-level directory, e.g. src/billing/x.py -> src/billing
  function dir(p,   n, a) {
    n = split(p, a, "/")
    if (n >= 3) return a[1] "/" a[2]
    if (n == 2) return a[1]
    return "(root)"
  }
  /^@/ { split(substr($0, 2), h, " "); commit = h[1]; author = h[2]; next }
  NF == 0 { next }
  {
    d = dir($0)
    # Count each commit once per directory, not once per file.
    if (!((commit, d) in seen)) { seen[commit, d] = 1; commits[d]++ }
    # Count distinct authors per directory.
    if (!((author, d) in who))  { who[author, d] = 1; authors[d]++ }
  }
  END {
    for (d in commits)
      printf "%6d commits  %4d authors  %s\n", commits[d], authors[d], d
  }
' | sort -rn | head -20

A git log and awk script that ranks directories by commit frequency and distinct author count over a time window, which gives you the per-directory change data used to confirm team contention.

Is the domain and data ready for a boundary?

A strong signal tells you there is pressure on the system, but not where the boundary should go. A service only makes sense when it owns a coherent part of the domain and the data behind it. If it has to reach into another module's tables to do its job, you have created a distributed monolith: the coupling is still there, and now there is a network hop in the middle of it.

Map who reads and writes what

You need to know which tables each module reads and which it writes. There are three practical ways to find out:

  • Query logging tagged by module. Prepend a comment to every query, such as /* module=billing */, either in a database middleware or an ORM hook. Then aggregate the slow-query or statement logs by tag and table. This shows how the system behaves in production, including code paths that are rarely exercised.
  • Static search. Grep each module's directory for table names. It is fast but it misses dynamically built queries.
  • ORM model ownership. Record which module defines each model, then list every import of that model from other modules.

Warning signs

  • Cross-module joins. A single query that joins tables owned by different modules turns into an API call plus an in-memory join once you split.
  • Shared transactions. If one transaction writes data belonging to two modules, the split replaces that atomic commit with a saga or eventual consistency.
  • Tables written by many modules. When nobody clearly owns a table, nobody can own the service that would hold it.

If the access map is heavily shared, the boundary isn't ready yet. Start by consolidating access inside the monolith: route every read and write for a table through the owning module's interface, and remove cross-module joins. Extract the service once those numbers come down.

import contextvars
import re
from collections import defaultdict
from sqlalchemy import create_engine, event

# Set at each module's entry points (request middleware, job runner, etc.).
current_module = contextvars.ContextVar("current_module", default="unknown")

engine = create_engine("postgresql://app:example-password@db.internal/app")

@event.listens_for(engine, "before_cursor_execute", retval=True)
def tag_query(conn, cursor, statement, parameters, context, executemany):
    # Prefix every statement with its caller so the DB's statement log records ownership.
    return f"/* module={current_module.get()} */ {statement}", parameters

# Rough regex heuristics; good enough to surface shared tables, not a SQL parser.
TAG = re.compile(r"/\* module=(\w+) \*/")
WRITE = re.compile(r"\b(?:INSERT INTO|UPDATE|DELETE FROM)\s+(\w+)", re.I)
READ = re.compile(r"\b(?:FROM|JOIN)\s+(\w+)", re.I)

def access_map(log_lines):
    # table -> modules that read it and modules that write it
    tables = defaultdict(lambda: {"read": set(), "write": set()})
    for line in log_lines:
        tag = TAG.search(line)
        if not tag:
            continue
        module = tag.group(1)
        for t in WRITE.findall(line):
            tables[t]["write"].add(module)
        for t in READ.findall(line):
            tables[t]["read"].add(module)
    return tables

with open("postgresql-statements.log") as f:
    for table, access in sorted(access_map(f).items()):
        # Tables written by more than one module have no clear owner yet.
        if len(access["write"]) > 1:
            print(f"{table}: written by {sorted(access['write'])}")

A SQLAlchemy hook tags each query with the calling module, and a small log analyzer builds a table access map that flags tables written by more than one module.

Alternatives to try inside the monolith first

Each signal from section 2 has a cheaper fix that works inside the monolith. Try the one that matches your signal before you take on network calls and distributed data.

Team contention. Split the code into modules that can only call each other through declared public interfaces. Enforce this with build-time dependency rules, not convention, because unenforced boundaries wear away under deadline pressure. Then add code ownership rules so changes to a module need review from the team that owns it. This gives teams most of the autonomy a service boundary would, and refactoring across modules stays cheap.

Scaling bottlenecks. Send read-heavy traffic to read replicas. Cache hot, rarely changing data. Move slow or bursty work, such as report generation, image processing and outbound email, to background jobs. Run those jobs in separate worker processes built from the same codebase. You can then scale workers on their own, and you still have one artifact to build and version.

Deploy coupling. Use feature flags so that deploying code and releasing behaviour are separate steps. Unfinished work can ship while it is turned off, so one team's half-built feature no longer blocks another team's deploy.

Slow delivery. Parallelize and trim the test suite, and cache build outputs. A pipeline that takes too long is often the real reason teams want independent deploys.

Alternative Signal addressed Rough effort Limits
Enforced modules + ownership Team contention Medium–high One shared deploy and one shared runtime
Read replicas Read scaling Low–medium Replication lag; does not help write load
Caching Read latency Low–medium Invalidation bugs; stale reads
Background workers Uneven compute load Medium Shared schema and shared release cadence
Feature flags Deploy coupling Low–medium Flag debt; one bad deploy still affects everyone
Faster tests, build caching Slow delivery Medium Gains level off as the codebase grows

If you have applied the right fix and the limit in its row is still what blocks you, that is a sign you are ready to extract a service.

# app/main.py: one codebase and one build artifact, run in different roles.
# Web and worker processes come from the same image, so you can scale them
# separately without versioning two services.
import argparse
import time

from app import jobs, web  # shared modules: same domain code, same models


def run_web() -> None:
    # Handles HTTP requests. Slow work is enqueued, not done inline,
    # so request latency stays flat when report or email volume spikes.
    web.serve(host="0.0.0.0", port=8000)


def run_worker(queue_name: str) -> None:
    # Pulls jobs from the shared queue and runs them in a separate process.
    # Add worker replicas to absorb bursty load. Web capacity is unaffected.
    while True:
        job = jobs.claim_next(queue_name)  # e.g. a row locked with SKIP LOCKED
        if job is None:
            time.sleep(1.0)  # idle backoff; avoids hammering the database
            continue
        try:
            jobs.handlers[job.kind](job.payload)  # same code the web tier imports
            jobs.mark_done(job)
        except Exception as exc:
            jobs.mark_failed(job, error=str(exc))  # retry policy lives in one place


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("role", choices=["web", "worker"])
    parser.add_argument("--queue", default="default")  # e.g. "reports", "email"
    args = parser.parse_args()

    # Deploy example: `python -m app.main web` x 3, `python -m app.main worker --queue reports` x 6
    run_web() if args.role == "web" else run_worker(args.queue)

One codebase starts as either a web process or a background worker, so you can scale slow, bursty work on its own while still building and versioning a single artifact.

When to avoid splitting, and what it costs

Splitting turns in-process calls into network calls. Any of these calls can be slow, fail partway, or time out. Callers therefore need timeouts and retries, and retries are only safe when handlers are idempotent. ACID transactions that span modules are gone. You replace them with sagas or the outbox pattern and accept eventual consistency. Each service also needs its own pipeline, dashboards, alerts and on-call rotation.

If you put a boundary in the wrong place, you get a distributed monolith: services that still have to deploy together, now with network hops between them. Contention between teams can come back as negotiation over API contracts. Tracing a bug means following it across processes, and running the system locally takes more setup. Extractions take months, and many stall halfway, which leaves two systems to maintain.

Illustrative estimate (assumed numbers, not a benchmark)

Net hours per month = hours recovered − (number of services × overhead hours per service)

Assume a split saves 12 engineers 4 hours each per month that they currently spend waiting on merges and deploy queues. That is 48 hours. Assume each new service costs 20 hours per month for pipeline upkeep, dependency upgrades, alert tuning and on-call. With three services: 48 − (3 × 20) = −12. The split costs engineering time until the recovered hours exceed the overhead.

When to hold off

  • Small teams: One or two teams have little contention to remove.
  • Undiagnosed root cause: If you haven't profiled the slow path or found out why deploys break, a split only moves the problem to a new place.
  • Heavily shared data: If most features read and write the same tables, every boundary you draw will cut through transactions.

Prerequisites before the first extraction

Once a service runs on its own, every gap in your platform becomes a production incident with no obvious owner. Put these pieces in place while you still have one deployable.

  • CI/CD per service: Each service needs its own pipeline that builds, tests and deploys it alone. If extracted code still ships through the monolith's release train, you pay for the network hop and keep all of the deploy coupling.
  • Centralized logging: Once a request spans two processes, grepping one host's logs no longer explains a failure. Send logs to one searchable place with consistent fields such as service name and request ID.
  • Distributed tracing: Logs show what each service did. Traces show where the latency went. Without trace context passed across calls, a slow endpoint turns into an argument between teams.
  • On-call ownership: Name the team that gets paged for the new service before it takes traffic. Otherwise alerts reach whoever owned the monolith, and that team now lacks the context to fix them.
  • Contracts and versioning: Agree on how APIs and event schemas change. That means additive changes by default, explicit versions for breaking ones, and consumer-driven contract tests where they fit. Without this, a field rename breaks a consumer you didn't know you had.
  • Local development and integration testing: Decide how engineers will run or stub the new service on their machines and where cross-service tests will run. Without a plan, people stop testing the integration and find the breaks in staging.

To check readiness, walk through one realistic incident on paper, such as a failed request in the candidate service. Every step that depends on a person's memory instead of a tool or a named owner is a prerequisite you still have to build.

How to extract the first service with the strangler fig pattern

Start with the batch-job runner. It is close to a leaf: few modules call into it, one team can own it, and its pain is easy to measure. Before moving anything, write down the success criterion: API latency during batch windows should match API latency outside them. Take both numbers from your own dashboards over a few representative weeks, and set the target against that baseline rather than a figure borrowed from another system.

Put a routing layer in front of the job triggers:

scheduler/clients → router → { monolith: batch endpoints (old path) | batch-service (new path) }

A minimal route needs four parts: a path prefix match on the batch endpoints, two upstreams, a weight per upstream, and a health check on the new service. Shift the weight a step at a time. Leave the monolith path deployed and routable, so rolling back is a weight change and not a redeploy.

Data ownership moves next. The new service becomes the only writer of job state. When the monolith still needs a change recorded, it writes an outbox row in the same transaction, and a relay publishes that row as an event. This avoids dual writes, which can partially fail. Delete the old code only after the old path has gone a full batch cycle with no traffic.

To judge whether the work is paying off, track lead time for batch changes and how often a batch release needs another team to coordinate. If the router sits at a partial weight for weeks, or someone is still patching the old code, the extraction has stalled. Finish it or roll it back.

# Router in front of batch job triggers (open-source nginx).
# Rollback = edit weights, then `nginx -s reload`. No redeploy of either app.

upstream monolith {
    server monolith.internal:8080;
}

upstream batch_backends {
    # Old path: monolith batch endpoints. Keep it deployed and routable.
    server monolith.internal:8080      weight=9 max_fails=3 fail_timeout=30s;

    # New path: extracted batch-service. Raise this weight one step at a time
    # (1 -> 3 -> 5 -> 9). At 100%, mark the monolith line `down` instead of
    # deleting it, until a full batch cycle passes with no traffic on it.
    server batch-service.internal:9090 weight=1 max_fails=3 fail_timeout=30s;

    # Health check: these are passive checks. After 3 failed attempts within
    # 30s, nginx stops sending requests to that server for 30s.
}

server {
    listen 80;

    # Path prefix match: only batch endpoints are split between the two upstreams.
    location /api/batch/ {
        proxy_pass

        # On connection errors or 502/503 from one upstream, try the other once.
        # nginx does not retry non-idempotent requests (e.g. POST) unless
        # told to, so job submissions are not duplicated here.
        proxy_next_upstream       error timeout http_502 http_503;
        proxy_next_upstream_tries 2;
    }

    # Everything else still goes only to the monolith.
    location / {
        proxy_pass
    }
}

A minimal nginx routing layer that matches the batch endpoint prefix and splits traffic by weight between the monolith and the new batch-service, using passive health checks, so rollback is a weight edit and a reload.

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 when to split a 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 .env by 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.