---
title: 'Inter-Service Contracts'
source: 'https://academia.sh/en/courses/application-layer/inter-service-contracts'
course: 'The Application Layer and Service Interaction'
language: en
updated: '2026-08-23T07:01:23+00:00'
license: 'CC BY-SA 4.0'
---

# Inter-Service Contracts

Measuring the cost of a schema change by the number of broken consumers: counting how many services a single-step release forces to publish together across five internal consumers' read sets, splitting the same change into an expand–contract sequence to bring broken consumers to zero, and computing what carrying two names at once during the transition window costs in record bytes and internal boundary bandwidth.

The previous lesson counted the names the caller has to know and showed the count moves between
six and eight. The number alone says nothing; its meaning surfaces once one of those names
changes. The gateway is not the only caller either — the same shipment record is also read by the
end-of-day billing job, the interface built for the front end, the reporting stream, and the
delivery planner, and each of them looks at a different subset of the domain.

The definition of a breaking change, the schema's two directions, and deriving the version number
from the difference were established in the Web API Design course; they are not retold here. That
course measured the cost on **a single old client**. The measure changes at the service boundary:
the consumers are our own services, their count is known, and their read sets can be read. What
this lesson measures is **how many consumers break**; what it measures in exchange is **how many
units are forced to publish together**.

## Schema, Consumers, and the Change

The schema under discussion is the previous lesson's shipment record. The proposed change corrects
four names and adds a field: the zone field did not say which zone it was, the weight was in
grams, the route carried only zone codes, and the carrier field's being a code was not clear from
its name.

```js
// contract/schema.mjs — two schemas for the shipment record, five internal consumers, and the breaking rule
export const V1 = { trackingNo: "string", state: "string", zone: "string", updatedAt: "string",
  route: "array-of-string", carrier: "string", weightGrams: "integer", volumeDm3: "integer",
  contractNo: "string", originBranch: "string", destinationBranch: "string" };

// V2 renames four names and adds a field; zone->destinationZone, weightGrams->weightKg,
// route->routeSteps (the type also changes), carrier->carrierCode.
export const V2 = { trackingNo: "string", state: "string", destinationZone: "string", updatedAt: "string",
  routeSteps: "array-of-object", carrierCode: "string", weightKg: "number", volumeDm3: "integer",
  contractNo: "string", originBranch: "string", destinationBranch: "string", estimatedDelivery: "string" };

export const MAPPING = { zone: "destinationZone", weightGrams: "weightKg",
  route: "routeSteps", carrier: "carrierCode" };

export const CONSUMER = {
  gateway: ["state", "zone", "updatedAt", "route"],
  "front-end-ui": ["state", "updatedAt", "destinationBranch"],
  "report-stream": ["trackingNo", "state", "zone", "carrier"],
  "batch-billing": ["trackingNo", "contractNo", "weightGrams", "volumeDm3"],
  "delivery-planner": ["route", "carrier", "originBranch", "destinationBranch"],
};

// A consumer breaks if a name it reads is missing from the schema, or its type changed.
export const broken = (schema, reads) => Object.entries(reads)
  .filter(([, fields]) => fields.some((f) => schema[f] === undefined || schema[f] !== V1[f]))
  .map(([name]) => name);

export const EXAMPLE = {
  trackingNo: "TR-4821", state: "out-for-delivery", zone: "35", updatedAt: "2026-03-11T08:24:00Z",
  route: ["34", "41", "35"], carrier: "T3", weightGrams: 2400, volumeDm3: 12,
  contractNo: "S7", originBranch: "34-021", destinationBranch: "35-114",
  destinationZone: "35", routeSteps: [{ zone: "34", at: "T08" }, { zone: "41", at: "T14" },
    { zone: "35", at: "T20" }], carrierCode: "T3", weightKg: 2.4, estimatedDelivery: "2026-03-12",
};
export const record = (schema) => Object.fromEntries(Object.keys(schema).map((f) => [f, EXAMPLE[f]]));
```

**Expand–contract** is a release sequence that splits the same change into three steps. In the
first step, the provider adds the new names **next to** the old ones and fills both. In the second
step, consumers move to the new names one by one. In the third step, the provider removes the old
names. The run below compares the two release arrangements on the same change.

```js
// contract/measure.mjs — the same change under two release arrangements: in one step, and in expand-contract sequence
import { V1, V2, MAPPING, CONSUMER, broken, record } from "./schema.mjs";

const TRANSITION = { ...V1, ...V2 };                  // expand step: old and new names together
const migrated = Object.fromEntries(Object.entries(CONSUMER)
  .map(([name, f]) => [name, f.map((x) => MAPPING[x] ?? x)]));
const consumers = Object.keys(CONSUMER).length;

console.log(`schema fields V1 ${Object.keys(V1).length}, transition ${Object.keys(TRANSITION).length}, ` +
  `V2 ${Object.keys(V2).length}; internal consumers ${consumers}`);
const count = {};
for (const fields of Object.values(CONSUMER)) for (const f of fields) count[f] = (count[f] ?? 0) + 1;
const shared = Object.entries(count).sort((x, y) => y[1] - x[1]).slice(0, 4);
console.log(`most-read fields: ${shared.map(([f, n]) => `${f}=${n}`).join(", ")}\n`);

const broken1 = broken(V2, CONSUMER);
console.log(`single-step release -> broken consumers ${broken1.length}/${consumers}: ${broken1.join(", ")}`);
console.log(`  units that must be published together = ${broken1.length + 1} (provider + broken)\n`);

const STEP = [
  ["1 expand (both names at once)", TRANSITION, CONSUMER, 1],
  ["2 consumers move to new name", TRANSITION, migrated, Object.entries(CONSUMER)
    .filter(([name, f]) => f.some((x) => MAPPING[x])).length],
  ["3 contract (old names drop)", V2, migrated, 1],
];
console.log(`${"step".padEnd(30)}${"fields in schema".padStart(18)}${"broken consumers".padStart(18)}` +
  `${"units published".padStart(18)}${"record bytes".padStart(14)}`);
let units = 0;
for (const [name, schema, reads, n] of STEP) {
  units += n;
  const brokenCount = Object.entries(reads).filter(([, fields]) =>
    fields.some((f) => schema[f] === undefined)).length;
  console.log(`${name.padEnd(30)}${String(Object.keys(schema).length).padStart(18)}` +
    `${`${brokenCount}/${consumers}`.padStart(18)}${String(n).padStart(18)}` +
    `${String(Buffer.byteLength(JSON.stringify(record(schema)))).padStart(14)}`);
}
console.log(`total: 3 release steps, ${units} unit releases, 0 broken consumers\n`);

// K01 Back-of-the-Envelope Estimation: peak read 416.67 req/s and read egress 1.60 Mbit/s
const PEAK_READ = 416.67, K01_EGRESS = 1.6;
const mbit = (bytes) => (PEAK_READ * bytes * 8) / 1e6;
console.log(`${"schema".padEnd(10)}${"record bytes".padStart(14)}${"internal boundary Mbit/s".padStart(27)}` +
  `${"ratio to K01 read egress".padStart(27)}`);
for (const [name, s] of [["V1", V1], ["transition", TRANSITION], ["V2", V2]]) {
  const b = Buffer.byteLength(JSON.stringify(record(s)));
  console.log(`${name.padEnd(10)}${String(b).padStart(14)}${mbit(b).toFixed(3).padStart(27)}` +
    `${(mbit(b) / K01_EGRESS).toFixed(2).padStart(27)}`);
}
const transitionBytes = Buffer.byteLength(JSON.stringify(record(TRANSITION)));
const v1Bytes = Buffer.byteLength(JSON.stringify(record(V1)));
console.log(`transition window cost = ${transitionBytes - v1Bytes} bytes/record, ` +
  `${(mbit(transitionBytes) - mbit(v1Bytes)).toFixed(3)} Mbit/s (record grows ${(transitionBytes / v1Bytes).toFixed(2)}x)`);
```

```
schema fields V1 11, transition 16, V2 12; internal consumers 5
most-read fields: state=3, zone=2, updatedAt=2, route=2

single-step release -> broken consumers 4/5: gateway, report-stream, batch-billing, delivery-planner
  units that must be published together = 5 (provider + broken)

step                            fields in schema  broken consumers   units published  record bytes
1 expand (both names at once)                 16               0/5                 1           423
2 consumers move to new name                  16               0/5                 4           423
3 contract (old names drop)                   12               0/5                 1           352
total: 3 release steps, 6 unit releases, 0 broken consumers

schema      record bytes   internal boundary Mbit/s   ratio to K01 read egress
V1                   243                      0.810                       0.51
transition           423                      1.410                       0.88
V2                   352                      1.173                       0.73
transition window cost = 180 bytes/record, 0.600 Mbit/s (record grows 1.74x)
```

## Number of Broken Consumers

These numbers are in the **computed value** class: they come out of the read sets and schema
definitions by arithmetic.

In a single-step release, four of five consumers break. The one consumer that does not break is
the front-end interface, which reads none of the four changed fields. This row's result is not a
version number, it is a **release constraint**: the provider and four consumers have to be
published **at the same time**. Five units moving together in a single release takes back the
reason for splitting into services — the point of the split was that units could be published
separately.

The second row shows where the cost comes from: `state` is read by three consumers, `zone`,
`updatedAt`, and `route` are each read by two. The cost of changing a field is the number of
consumers that read it. The previous lesson's distinction turns into a number here — a shared
record field is more expensive than a procedure name written for a single caller.

## The Result of Splitting Into a Sequence

The table below shows that splitting the same change into three steps keeps broken consumers at 0
in every step. In the first step, the schema grows from eleven fields to sixteen, and because the
old names stay in place, no consumer changes. In the second step, four consumers move to the new
names and can do it **separately**; their order does not matter, because both names are populated.
In the third step, the old names drop and the schema falls to twelve fields.

The two arrangements are compared with two numbers. Single-step release: 1 release step, 5 units
at once, 4 broken consumers. Expand–contract: 3 release steps, 6 unit releases, 0 broken consumers.
One extra unit gets published, and in exchange, no unit waits on another.

The sequence carries a rule that does not show up in the table: **once the first step is released,
someone has to know that consumers have actually migrated.** Otherwise the third step turns into
the single-step release on its own. Knowing this is the contract tests established in the Web API
Design course; if consumer expectations are written down, whether the third step is safe can be
tested before release.

## Back to the Calculation

The table below gives the bill for the transition. The shipment record is 243 bytes in V1; because
the transition window carries both names at once, it climbs to 423 bytes, a 1.74x increase.
Settling at V2 leaves it at 352 bytes — above V1, because the route steps now carry objects and a
field was added.

Multiplied by K01's peak read rate, the bandwidth crossing the internal boundary climbs from 0.810
Mbit/s to 1.410 Mbit/s: the transition window's cost is 0.600 Mbit/s. The comparison point is K01's
read egress (1.60 Mbit/s); during the transition window the internal boundary approaches 0.88x that
number, while in V1 it was at 0.51x.

The result that follows is that the transition window is a **duration decision**. As long as the
window stays open, every record carries seventy-four percent extra bytes; closing it requires
waiting for every consumer to migrate. A long window keeps the risk of breakage at zero and pays a
continuous bandwidth tax; a short window cuts the tax and, if a consumer has not finished migrating,
brings the breakage back. Which of the two extremes is chosen depends on the test that shows the
third step is safe.

## Summary

- At the service boundary, the measure of a schema change is the number of broken consumers and
  the number of units forced to publish together; the definition of breaking and the version
  derivation were established in the Web API Design course and are not repeated here.
- A proposal that changes four names breaks four of five internal consumers and requires
  publishing five units at the same time as the provider.
- The cost of changing a field is the number of consumers that read it: `state` is read by three,
  `zone`, `updatedAt`, and `route` by two each.
- The expand–contract sequence carries out the same change with 3 release steps and 6 unit
  releases, with 0 broken consumers; in the second step consumers migrate without waiting on each
  other.
- The safety of the sequence depends on the third step: if the old names are removed before the
  migration is verified as complete, the arrangement reverts to a single-step release.
- Back to K01: the record climbs from 243 bytes to 423 bytes in the transition window (a 1.74x
  increase), and the internal boundary rises from 0.810 to 1.410 Mbit/s — a 0.600 Mbit/s window
  tax, 0.88x K01's 1.60 Mbit/s read egress.

## Next Step

After four lessons, a call can be made: the services are stateless, their addresses are found, the
conversation style is chosen, and their contracts can evolve without breaking. One question is
still open, and it returns to this topic's very first measure. When the gateway calls two services
and one of them does not answer, how long does it wait, how many times does it retry, and what does
that waiting add to the end-to-end response. K01 set a 200-millisecond threshold for the tracking
response, and the third lesson measured the number of steps in the chain at 2. The next lesson
takes up that threshold as a budget: it measures what the product of timeout and retry count gives
in the worst case, how the budget is split across the steps, and how much the internal request rate
multiplies by when no retry budget is set.
