ReliabilityGENERALSCALE-SPECIFICCONTESTED

What Changes at the Network Boundary

A function call becomes a message that may be lost, delayed, duplicated or half-processed. "We can split it later" underestimates this, because the cost is not the transport — it is every interface that was designed as if calls always return.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

What exactly changes about an interface when the call it makes stops being local?

The requirement

A modular monolith is to be split: the inventory module becomes a service. The estimate is two weeks and covers extracting the code, adding HTTP handlers and deploying. It does not mention any of the interfaces.

The obvious build

The interface stays the same; only the implementation changes. Replace the method body with an HTTP call and everything above it is untouched — that is what the interface was for.

Why it breaks

The interface encoded assumptions the transport just invalidated: that a return means it happened, that failure means it did not, that latency is negligible, that a loop of calls is free, and that the caller's transaction covers it.

How it breaks as requirements change
  • The interface encoded assumptions the transport just invalidated: that a return means it happened, that failure means it did not, that latency is negligible, that a loop of calls is free, and that the caller's transaction covers it.
  • Loops become the dominant cost. for (line of order) inventory.check(line) was microseconds and is now forty round trips, and no signature changed to warn anyone (N+1 as a Design Problem).
  • Shared transactions are gone. Code that decremented stock and created an order atomically now does two things that can disagree (Partial Failure).
  • Argument passing changes meaning: objects become serialised copies, so mutation no longer propagates, identity is lost, and anything not serialisable — a callback, a lazy field, an open handle — silently stops working (Leaky Abstractions).
  • Versioning becomes permanent. Both sides deploy independently, so every change to the interface needs a compatible path forever (Versioned Interfaces).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The call sites number in the dozens and were written assuming a call returns or throws (Designing for Failure).
  • Several operations currently share a transaction with the caller, and after the split they cannot (Partial Failure).
  • The two sides will release independently within a quarter, so compatibility becomes a permanent obligation (Backward Compatibility as a Constraint).
Invariants

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
Boundaries
  • The boundary should already exist as a module boundary before it becomes a network one. Extracting a module that had no clear interface means designing the interface under deployment pressure, which is the worst time (Internal Module Contracts).
  • It should fall where the invariants do not cross. If an invariant spans both sides, the split has put a transaction boundary through the middle of a rule (Consistency Boundaries).
  • Its shape must change with the transport: chatty interfaces are fine in-process and wrong across a network, so the extracted interface is genuinely a different design and not the same one relocated.

The eight things that change

The estimate that says "two weeks" is pricing the transport. The transport is the easy part — an HTTP handler and a client are a day. What costs is that eight properties of every call change at once, and the interfaces above them were designed while all eight held.

This is the concrete content of "we can split it later". Not that splitting is hard, but that the interfaces you write today decide how hard it will be, and most of them are decided by accident.

Extract the inventory module into a service
The change

Inventory becomes a separately deployed service. The code moves unchanged; only the transport is new.

Interfaces written as local calls: per-item operations, exceptions for failure, shared transaction, objects passed by reference
CheckoutServiceOrderServiceRestockJobAdminAdjustReportBuilderPricingServiceReturnsHandler
testscheckout_testorder_testrestock_testadmin_testreport_testpricing_testreturns_test+ new contract tests
7 modules · 8 test files

Every call site changes: loops become batches, catch blocks become three-way branches, two operations that shared a transaction become an outbox flow with a compensating path, and every mutating call needs an id threaded from wherever intent forms. The report builder alone turns one query into 900 round trips.

Interfaces already coarse, failure-explicit and idempotent before the split
InventoryClient (new)InventoryService (moved)
testsinventory_contract_test (new)existing tests unchanged
2 modules · 2 test files

Call sites are untouched because they already passed command ids, already handled Unknown, and already called batch operations. The work that remains is genuinely transport plus contract tests.

what it cost Getting to the "after" state means the in-process module carried ceremony it did not need for years: command ids on operations that could not be duplicated, three-valued outcomes where a local call could only return or throw, and batch operations where a loop would have read better. If inventory had never been extracted, all of that would have been pure cost — which is exactly the bet in The Cost of Change, and it is a bet that can lose.

A split that does not surprise anyone

The order matters more than any individual step. Every step before the transport change is reversible and can be done under normal conditions; the transport change is the irreversible one and should be the smallest.

Teams that do this in the opposite order — deploy first, discover the interface problems in production — spend the difference between the two estimates above.

Extracting a module, in an order that keeps each step reversible
  1. 1
    Establish the module boundary in-process

    One entry point, no other module touching its internals, its own tests at that surface.

    fails by Extracting something whose boundary has never been enforced, so the interface gets designed under deployment pressure (Internal Module Contracts)

  2. 2
    Check where the invariants fall

    List every rule that spans both sides. If one does, either move the boundary or accept eventual consistency deliberately.

    fails by Splitting through a rule and discovering afterwards that nothing can enforce it (Consistency Boundaries)

  3. 3
    Coarsen the interface

    Replace per-item operations with batch ones while everything is still local and cheap to change.

    fails by Leaving a chatty interface and finding the latency in production (N+1 as a Design Problem)

  4. 4
    Make failure and identity explicit

    Three-valued outcomes and a command id on every mutating operation — still in-process, still one deploy.

    fails by Retrofitting ids afterwards, which means touching every caller and every stored record (Idempotency by Design)

  5. 5
    Break the shared transaction

    Move cross-boundary work onto an outbox and add the compensating path, verified while both sides are still in one process.

    fails by Discovering the dual write after the split, when it is a production incident rather than a test (Partial Failure)

  6. 6
    Add contract tests, then the transport

    Contracts first so the interface is pinned, then swap the in-process call for a client with a deadline.

    fails by Doing the transport first, so every subsequent problem is diagnosed in a distributed system instead of a local one (Contract Tests)

Steps one to five are all reversible and all improve the code even if the split is cancelled — which is the real argument for this order. If the project is abandoned after step four, the module is better than it was (Reversible and Irreversible Decisions).

Is the network buying anything?

Distribution buys independent deployment, independent scaling and failure containment. It does not buy modularity — a module boundary already gives that, for free, with a compiler enforcing it.

The scores below compare the options for isolating a subsystem. Read them as a shape: the interesting column is what each option costs in failure modes, which is the column teams skip.

Four degrees of separation for one subsystem
OptionSimplicityFlexibilityOperationalMigration costNote
A module in the same processCompiler-enforced boundary, one transaction, no partial failure, no versioning. Cannot be deployed or scaled independently (The Modular Monolith).
A module with a remote-shaped interfaceCoarse, failure-explicit, idempotent — but still a local call. Buys optionality cheaply; costs ceremony that is wasted if the split never comes (Speculative Generality).
A separate service, synchronousIndependent deploy and scale. Acquires latency, partial failure, versioning and the caller's availability now depending on yours (Cascading Failure: When the Response to Failure Causes More Failure in Distributed Systems).
A separate service, event-drivenStrongest failure isolation; the caller does not wait. Costs eventual consistency everywhere and makes every flow harder to follow (Debuggability by Design).

caveat The scores cannot express the variable that actually decides this, which is organisational rather than technical: whether separate teams are blocking each other on a shared release. If they are, row three pays for itself regardless of how the technical scores look; if they are not, row one wins on every axis that matters and rows three and four are cost with no return. No property of the code tells you which situation you are in.

How to build it

Most important first.

  • Design the module interface as if it were remote *before* deciding whether it will be — coarse operations, no shared mutable objects, explicit failure, an id per operation. That costs little in-process and removes most of the split cost later (Designing a Module Interface).
  • Make it coarse. Replace per-item calls with batch operations, so a loop becomes one round trip (Cost-Aware Interfaces).
  • Return three-valued outcomes at the boundary and make callers handle unknown (Designing for Failure).
  • Put a command id on every mutating operation before the split, not after (Idempotency by Design).
  • Move anything that shared a transaction with the other side onto an eventual mechanism — an outbox, an event — and accept the consistency change explicitly rather than discovering it (Partial Failure).
  • Give every call a deadline derived from the caller's remaining budget (Pass the Remaining Budget Down, Not a Fresh One in Distributed Systems).
  • Ask whether it needs to be remote at all. A module boundary gives most of the isolation for none of the failure modes; distribution is a cost you pay for independent deployment and scaling, not for cleanliness (When Not to Distribute in Distributed Systems).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Splitting a module that already had a coarse, failure-aware, idempotent interface: mostly transport work plus contract tests. The call sites barely change, because they already handled three outcomes and already passed ids.
  • Splitting one designed as a local call: every call site changes, every loop becomes a batch, every shared transaction becomes an eventual flow with compensation, and every operation needs an id retrofitted through its callers. That is the difference between two weeks and a quarter, and it is where "we can split it later" underestimates.
  • Afterwards, every interface change costs a compatible-change dance: add, migrate consumers, remove. Permanently (Expand and Contract).
  • What gets cheaper: independent deployment, independent scaling, and a hard limit on how far a bad change can spread. Those are the things you are buying, and they are worth naming explicitly so the trade is a decision rather than a drift.
What the recommended approach costs
  • Independent deployment and scaling are real and valuable, and they are bought with latency, partial failure, versioning and operational surface. The trade is usually worth it at the right size and almost never worth it early.
  • Coarse interfaces are less expressive. Batch operations have awkward partial-failure semantics — what does it mean when three of ten items fail? — and someone has to design that (Partial Failure).
  • Designing every module interface as if it might become remote imposes ceremony on modules that never will, which is a real cost paid for optionality that sometimes goes unused (Speculative Generality).

What can go wrong

Failure modes
  • The chatty interface ships and latency multiplies by the number of items, discovered in production because the staging dataset had three rows.
  • The split proceeds where an invariant spans both sides, so it can no longer be enforced anywhere and drifts silently — the characteristic failure of a badly-placed service boundary (The Distributed Monolith: All of the Cost, None of the Autonomy in Distributed Systems).
  • Both sides must be deployed together to work, so all the failure modes of distribution were acquired and none of the independence (The Distributed Monolith: All of the Cost, None of the Autonomy in Distributed Systems).
  • Timeouts are left at library defaults — often minutes — so a slow dependency exhausts the caller's connection pool and the outage propagates outward (Connection Pool Exhaustion in Backend).
  • The estimate covers the transport and not the interfaces, so the two-week project becomes a quarter and the extra time is spent on exactly the things this lesson lists.
Dependencies, and their direction
  • The caller gains dependencies on the network, on a serialisation format, on the other side's deploy schedule and on its capacity.
  • The two sides depend on a contract that must now be versioned and verified, because the compiler no longer checks it (Contract Tests).
  • The transaction dependency is removed — which sounds like decoupling and is actually the loss of a guarantee you were relying on (Consistency Boundaries).
Misreads
  • "The interface hides the transport." It hides the syntax. Latency, partial failure, serialisation and versioning all come straight through, and pretending otherwise is the distributed-computing fallacy in its original form (Leaky Abstractions).
  • "We can split it later." You can — but the cost is in the interfaces, not the transport, and it is paid at exactly the moment you are under pressure to split (The Cost of Change).
  • "Services give better separation." A module boundary gives separation. Services give independent deployment and independent failure, and you pay for both (The Modular Monolith).
  • "Add a timeout and it is handled." A timeout converts an unbounded wait into an ambiguous outcome, which still has to be modelled (Designing for Failure).
Smells this explains
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • Contract tests on both sides, since the compiler no longer checks the interface (Contract Tests).
  • A latency test with realistic collection sizes, asserting a bound on round trips per operation (Cost-Aware Interfaces).
  • Failure injection at the boundary: timeout, 500, slow response, connection reset — and an assertion about the caller's behaviour in each (Designing for Failure).
  • A test that the invariant formerly held by the transaction still holds under the new mechanism, including when the second step fails (Property-Based Testing).
How this design ages
  • The interface coarsens over time as round trips are discovered, which is a good sign — and each coarsening is a compatible-change exercise.
  • The compatibility burden accumulates: fields that must stay because someone might read them, versions that must be supported because a consumer has not upgraded (Deprecation).
  • Occasionally the right answer is to merge two services back. That is a legitimate outcome and much rarer than it should be, because merging is politically harder than splitting (Refactor or Rewrite).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThe eight properties that change — latency, partial failure, duplication, ordering, serialisation, versioning, capacity and transactions — follow from the medium and hold for HTTP, gRPC, queues and any RPC framework, whatever it claims about transparency.
  • SCALE-SPECIFICBelow roughly a few teams, a modular monolith gives nearly all the isolation with none of these failure modes, and splitting is a cost with no matching benefit. Above the point where teams block each other on deploys, independent deployment is worth the whole list — and that threshold is about organisation and release cadence, not about lines of code.
  • CONTESTEDThe strongest opposing view is that designing module interfaces as if they might become remote is speculative generality: most modules never move, and the ceremony — command ids, three-valued outcomes, batch operations — is paid by every reader of code that will always be a local call. That is a fair charge, and the honest answer is that only some of the list is cheap in-process. Coarse interfaces and explicit failure are nearly free and improve local code anyway; command ids and outboxes are not free, and buying them for a module with no plausible path to distribution is over-design.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — whether a subsystem should be a service at all, and what scale and reliability goals justify it, is that domain's decision. This lesson only prices what it does to the interfaces you already have.
  • Testing & Reliability Engineering — verifying behaviour across a boundary under injected latency and partial failure is how you find out whether the split was survivable.