Every monolith eventually gets a meeting about “breaking it into services.” Someone draws boxes on a whiteboard — Users, Orders, Products, Payments — and it looks clean. Six months later, shipping a feature means changing four services, coordinating three deploys and debugging a timeout that only happens when two of them are slow at once.

That’s the distributed monolith: all the coupling of the original, plus a network in the middle. It’s the most common outcome of a decomposition, and it almost always comes from the same mistake — drawing boundaries around nouns instead of around change.

This is how I decide where the seams go, and how I cut them without betting the system on a rewrite.

Cut along the change, not the nouns

A boundary is only useful if most changes stay on one side of it. So the question isn’t “what are the entities in our domain?” It’s “what tends to change together, and what changes independently?”

Entity-shaped services fail this test almost by construction. A CustomerService, an OrderService and a ProductService look independent on a diagram, but a feature like “show customers their recently bought products with current prices” touches all three. Every feature that spans entities becomes a cross-service change, and most interesting features span entities.

The boundaries that hold up follow a business capability instead — pricing, checkout, fulfilment, billing — and own everything needed to do that job, including their own view of the entities involved. Billing has a notion of “customer” (an account with a payment method and an invoice address). Fulfilment has one too (a delivery address and a phone number). They’re not the same thing, and forcing them into one shared Customer model is how you couple two teams that should never need to talk.

A good boundary is one you rarely have to cross to get work done.

Let the evidence draw the first lines

You don’t have to guess where change clusters. Your version-control history already knows. Files that are consistently committed together are coupled, whatever the architecture diagram says; modules that rarely appear in the same commit are candidates for separation.

A few lines of scripting over the git log will show you co-change between top-level modules:

import itertools, subprocess
from collections import Counter

log = subprocess.run(
    ["git", "log", "--since=12 months ago", "--name-only", "--pretty=format:--"],
    capture_output=True, text=True,
).stdout

pairs = Counter()
for commit in log.split("--"):
    modules = {path.split("/")[1] for path in commit.split() if path.startswith("src/")}
    pairs.update(itertools.combinations(sorted(modules), 2))

for (a, b), n in pairs.most_common(15):
    print(f"{n:4}  {a} <-> {b}")

The output is rarely surprising to the people who work in the code, and that’s the point. It turns a debate about taste into a conversation about data. If checkout and pricing changed together in 140 commits last year, splitting them is a promise to make 140 cross-service changes next year.

Other signals worth weighing alongside it:

  • Ownership. Code owned by different teams with different release rhythms wants a boundary between it. Code one team changes every day doesn’t benefit from being split among itself.
  • Data. If two parts of the system write the same tables, they’re one service, whatever the code layout says.
  • Operational profile. A component that needs to scale very differently, or has very different availability or compliance requirements — card processing, say — is a strong candidate to stand alone.
  • Language. When the same word means different things in two parts of the business, you’ve probably found the edge of a bounded context.

Rules for the seams themselves

Finding the boundary is half the work. The other half is keeping it a boundary once there’s a network across it.

Each service owns its data. No other service reads its tables. The moment two services share a database, a schema change in one is a production incident in the other, and you’ve lost the independent deployability that justified the split. Data other services need is exposed through an API or published as events.

Prefer events for facts, calls for questions. When something has happened — an order was placed, a payment settled — publish it as an event, and let interested services keep their own copy of what they need. Reserve synchronous calls for questions that genuinely need a live answer. A system built on chains of synchronous calls multiplies its failure modes: five services in a request path, each with 99.9% availability, give the whole path roughly 99.5% — about three and a half hours of extra downtime a month, before a single bug.

Contracts are products. An API or event schema between teams is a published interface. Version it, change it additively, and keep old versions alive until consumers have moved. Consumer-driven contract tests — where each consumer states what it relies on and the provider’s build verifies it — catch breaking changes before deploy instead of in production.

Chatty is a smell. If rendering one page requires fifteen calls between two services, the boundary is in the wrong place, or one service needs its own copy of the other’s data.

Migrate with a strangler, not a rewrite

Big-bang rewrites of working systems fail for a predictable reason: the old system encodes years of edge cases nobody wrote down, and the new one has to rediscover them all before it can replace anything. The alternative is to grow the new system around the old one, a slice at a time.

clientsrouting facadebilling service (new)monolith/billing/*everything elseGROWSSHRINKS
The strangler pattern: route one capability at a time to the new service, and let the monolith shrink behind a stable front door.

The sequence I follow:

  1. Put a routing facade in front. A gateway or reverse proxy that, on day one, sends everything to the monolith. Clients never need to know a migration is happening.
  2. Modularise inside the monolith first. Before extracting anything, enforce the boundary in-process: the future service’s code only talks to the rest through an explicit interface, and owns its tables. Architecture tests in CI can fail the build when something reaches across. If you can’t hold the boundary inside one codebase, a network won’t hold it for you.
  3. Extract the capability with the most independent change rate. The co-change data tells you which one. Early wins matter — pick something valuable but not the most tangled part of the system.
  4. Move the data, not just the code. Migrate the capability’s tables to its own store, with the monolith reading through the new service’s API or from events during the transition.
  5. Shift traffic gradually. Route a percentage, compare results (shadow traffic is ideal for read paths), then move the rest. Keep the old path available until you’re confident, then delete it.

Then repeat. Each slice leaves the system working, which means you can stop at any point — including the point where the remaining monolith is small, stable and simply isn’t worth splitting further. That’s a perfectly good outcome. The goal was never “no monolith”; it was independent teams shipping independently.

When not to split at all

A service boundary is a cost you pay forever: network failures, distributed tracing, versioned contracts, deployment pipelines, on-call rotations. It’s worth paying when it buys independence you actually need. It isn’t worth paying for a system one team owns, that deploys comfortably as one unit, or whose domain you don’t understand well enough yet to know where the seams are.

In those cases, a well-structured modular monolith — clear internal boundaries, enforced in CI — gives you most of the benefits at a fraction of the cost, and keeps the option to extract services later, once the evidence says where they are.


Before drawing a new service boundary, I check:

  • Co-change data supports it: the two sides rarely change in the same commit.
  • It follows a business capability, not an entity, and one team can own it.
  • The service will own its data outright — no shared tables.
  • Cross-boundary communication is mostly events; synchronous calls are the exception.
  • Contracts are versioned and verified with consumer-driven tests.
  • There’s a strangler path to get there, with each step shippable and reversible.
  • The independence it buys is worth a network, and a pager, in the middle.

Draw the seams where the change is, and services let teams move on their own. Draw them anywhere else and you’ve built the same monolith, just harder to debug.