architecture
article··updated ·8 min read

The hidden cost of microservice boundaries: how to price a boundary before you draw it

#architecture #organisation #platform #devex

Splitting a system into services moves complexity around without reducing it. Code that used to call a function now sends a request over the network, and every change that touches both sides of that call becomes a change to two codebases, two deploy pipelines, and one contract between them. Later on, the boundary is one of the most expensive lines in the system to move, since moving it means migrating data and rewriting the contract.

Before drawing one, ask: what is the smallest change that will have to cross this boundary, and how often will it happen? If the answer is "most features, in lockstep", the line is in the wrong place.

What a boundary costs

Inside a monolith, changing a method signature and its callers is one commit, checked by the compiler or static analysis, deployed atomically. Across a service boundary, the same change gets a lot more expensive.

The producer has to support the old and the new contract at the same time, because the two services don't deploy at the same instant. Deploys have to happen in order: expand the producer, deploy, migrate consumers, deploy, contract the producer, deploy again. One logical change, three releases.

Static analysis no longer sees both sides, so something else (contract tests, an integration environment) has to notice that a field was renamed.

Failures get harder. A function call either returns or throws. A network call can also time out after the other side has already committed, so every cross-service write needs a decision about retries, idempotency keys and what happens to partial state.

A report that joined two tables now needs API calls (one per row in the worst case, or a batch endpoint), a replicated read model, or an event stream feeding a separate store. A request that crosses three services is much slower to debug without distributed tracing, from logs stitched together by correlation ID.

Each of these is small on its own. You pay all of them on every change that crosses the line, so the total depends on how often that happens.

A cost model

cost(boundary) =
      f_change  * cost_per_change      # coordinated changes across the line
    + f_failure * blast_radius         # how often and how badly one side breaks the other
    - autonomy_gained                  # independent deploys, scaling, ownership

# first term dominates   -> the boundary is in the wrong place
# second term dominates  -> the boundary may be right; invest in isolation (timeouts, queues, fallbacks)
# third term dominates   -> the boundary pays for itself

Nobody computes this to a number. The point is to estimate the terms roughly, in writing, before the decision:

  • f_change: how many changes per month need both sides modified.
  • cost_per_change: the overhead described above, in engineer hours per coordinated change.
  • f_failure and blast_radius: how often one side being down or slow affects the other, and how far the effect spreads.
  • autonomy_gained: what each side can now do on its own that it couldn't before. When the same team owns both services, the ownership part is close to zero, because the team still has to coordinate with itself. What remains are the operational reasons described below.

Take two services owned by one team, with no operational differences, where most features touch both. f_change is high and autonomy_gained is near zero. The first term dominates: the boundary adds versioning and ordered deploys to most features.

Measuring change coupling before drawing the line

f_change can be measured from version control instead of guessed. If the system is still a monolith with directories per module, count how often commits touch both candidate sides.

#!/usr/bin/env bash
# Change coupling between two directories over a time window.
# Usage: ./coupling.sh src/Billing src/Fraud '6 months ago'
a="$1"; b="$2"; since="${3:-6 months ago}"

total_a=$(git log --no-merges --since="$since" --format='%H' -- "$a" | wc -l)
both=0
for h in $(git log --no-merges --since="$since" --format='%H' -- "$a"); do
  if [ -n "$(git show --name-only --format= "$h" -- "$b")" ]; then
    both=$((both + 1))
  fi
done

echo "commits touching $a: $total_a"
echo "of which also touch $b: $both"

Commit-level coupling understates the real number when one feature is spread across several commits. If commits reference a ticket ID, group by ticket. If PRs land as merge commits, group by merge commit. Squash-merged PRs are already one commit per change and need no grouping. For larger histories, code-maat and similar tools compute coupling across all file pairs from git log output.

A rough reading to start from: if more than a third of the changes on one side also touch the other, the two are one unit of change and should stay in one deployable.

When a boundary pays for itself

A boundary earns its cost when the two sides differ in something that matters operationally.

Deploy cadence is the clearest case. A payments service ships several times a week, a fraud-scoring model ships only after a validation cycle of several weeks. Kept separate, payments can release without waiting for model validation.

Ownership is what makes autonomy_gained real: different teams, different on-call rotations, different priorities.

Scaling profile and failure isolation are the operational reasons. A CPU-heavy document parser and a latency-sensitive API need different resources, and in separate processes each can scale and fail on its own. A component that calls an unreliable third party can sit behind a queue, so its outages don't block the request path.

Technology is sometimes enough by itself. A model served from Python next to a PHP application is a natural process boundary, because the alternative is embedding one runtime in the other.

If none of this applies, the candidate boundary is a module boundary and belongs inside one deployable.

Start with modules

A modular monolith keeps the boundaries in the code without paying the network cost. Each module owns its tables, exposes an interface, and is forbidden from reaching into another module's internals. In PHP this can be enforced in CI with deptrac:

# deptrac.yaml
deptrac:
  paths:
    - ./src
  layers:
    - name: Billing
      collectors:
        - type: directory
          value: src/Billing/.*
    - name: Fraud
      collectors:
        - type: directory
          value: src/Fraud/.*
    - name: Shared
      collectors:
        - type: directory
          value: src/Shared/.*
  ruleset:
    Billing: [Shared]
    Fraud: [Shared]

With this in place, Billing and Fraud can only depend on Shared, and a pull request that imports a Fraud class from Billing fails the build.

Later extraction stays cheap if modules don't join across each other's tables. A module reads another module's data only through its public interface, and when the module gets extracted, that interface becomes the API. Side effects across modules go through events. If Billing needs Fraud to react to a payment, it dispatches an event and Fraud listens. Moving that event to a message broker later is then a transport change plus handling duplicates and delay, without rewriting both modules. An in-process event usually runs synchronously, inside the same transaction, while a broker delivers it asynchronously, at least once and outside that transaction, so the consumer has to be idempotent and the producer needs an outbox.

Extraction from a well-separated module is a one-time cost. Merging two services that should never have been split is also possible, but by then each side has usually built its own version of the shared domain model, and reconciling them takes a data migration plus a rewrite of every place where the two models disagree.

Platform before services

Each new service needs a build pipeline, deploy configuration, logging, metrics, tracing, health checks, secrets, and alerting. If these are not shared and templated, every service reinvents them slightly differently, and the operational cost grows with the number of services, whatever the amount of business logic. Before splitting, have in place one CI/CD template that a new service adopts without modification, structured logs with a correlation ID passed across calls, distributed tracing, and a standard for timeouts, retries and idempotency keys on every cross-service call.

I got a small taste of this with a single extra process. In a Laravel game with online duels, WebSockets run through Reverb, which is a separate process. When it wasn't running, "online duel" returned 500; once it ran but the vhost had no proxy, the guest joined over HTTP while the presence channel over WebSocket got a 404. After a deploy that changed the events, Reverb kept the old code until I restarted it by hand. Debugging had its own trap: curl through Cloudflare went over HTTP/2 and lost the Upgrade header, so I got a false 404 and had to use --http1.1.

Of all the inputs to the decision, change frequency is the easiest to measure before the split, with the script above, on the last six months of history. The others (ownership, cadence, scaling, platform readiness) are largely judgment calls, and if the measured coupling says "most features", they rarely change the answer.

end of node