Skip to content
Engineering

The compounding cost of a badly drawn service boundary

A boundary in the wrong place does not announce itself. It bills you slowly, in coordinated deploys and distributed transactions, until a feature that should take a week takes a quarter.

4 min read

Splitting a system into services is usually described as a scaling decision. In practice it is a coupling decision, and the scaling part is the easy half. Where the lines fall determines which changes are cheap forever and which are expensive forever, and that verdict is delivered years after the diagram was drawn.

The symptom is recognisable long before anyone names the cause. A feature that touches one concept requires changes in four repositories. Deploys have to be ordered. There is a shared spreadsheet for release coordination. Nobody can explain why a small change is expensive, only that it is.

Split on what changes together

The most common mistake is drawing boundaries around nouns. Orders, Users, Products, Payments — each becomes a service because each is a table, and the resulting architecture is a distributed schema with network calls where the joins used to be.

The better question is which things change at the same time and for the same reason. Two concepts that are always modified in the same pull request belong on the same side of a boundary, whatever the entity diagram says. Two that change for unrelated reasons on unrelated schedules can be separated cheaply.

Every boundary you cross is a transaction you lose

Inside one database, making three changes atomically is a transaction. Across two services it is a saga: a state machine, a compensating action for each step, an idempotency key on every handler, and a reconciliation job for the cases the compensations do not cover.

typescript
// One service. Atomic, and correct by construction.
await db.transaction(async (tx) => {
  await tx.insert(orders).values(order);
  await tx.update(inventory).set({ reserved: sql`reserved + ${qty}` });
  await tx.insert(ledgerEntries).values(entry);
});

// Three services. Now every line below is a failure mode you own.
const order = await orders.create(payload);           // may succeed then time out
try {
  await inventory.reserve(order.id, qty);             // may double-apply on retry
  await ledger.record(order.id, entry);               // may land before the reserve
} catch (err) {
  await orders.markFailed(order.id);                  // may itself fail
  throw err;
}
The same operation, before and after a boundary is drawn through it.

None of that is unreasonable when the boundary earns it. A payment processor and an order service genuinely are separate concerns with separate failure modes, and the saga is the honest representation of that. What is unreasonable is paying it because Orders and Inventory ended up in different repositories for organisational reasons.

OperationIn one serviceAcross a boundary
ConsistencyA transactionA saga with compensations
Failure handlingRollbackIdempotency keys and reconciliation
Refactoring a shared conceptOne pull requestCoordinated, versioned, ordered
Debugging one requestA stack traceDistributed tracing across hops
LatencyMicrosecondsMilliseconds, plus tail latency

Start with fewer services than you think

A boundary is far cheaper to add than to move. Inside a single deployable, an incorrect module boundary is a refactor an IDE can mostly perform. Once it is a network boundary with its own datastore, moving it is a migration, a deprecation window, and a coordinated release.

So the asymmetry argues for restraint. Build the modular monolith first, with genuine internal boundaries — separate schemas, no cross-module table access, communication through explicit interfaces. Extract a service when there is a concrete reason: an independent scaling profile, a compliance boundary, a team that needs to deploy on its own cadence.

  1. Enforce module boundaries in the codebase before enforcing them over a network.
  2. Give each module its own schema and forbid cross-schema queries in review.
  3. Let the interfaces stabilise under real feature work for a few months.
  4. Extract the module whose scaling, compliance, or release cadence genuinely differs.
  5. Leave the rest alone until they give you the same reason.
You can always cut a monolith along a seam that has proven itself. You cannot easily re-join two services that should never have been separated.

When the boundary is right

A well-placed boundary is quiet. Its interface changes rarely. Its team ships without asking anyone's permission. Incidents on one side do not page the other. Most features live entirely within one side of it.

If a split has been in place for six months and none of that is true, the honest conclusion is that the line is in the wrong place. That is worth acting on early, while moving it is still a refactor rather than a programme of work.

  • Architecture
  • Microservices
  • Domain modelling
ShareXLinkedIn

Related capability

Custom Software Development

Let's build something that lasts

Tell us about your project and we'll get back to you within 12 hours with next steps.