Lesson 04 / 15
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.
Contents
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.
// 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.
// 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:
stateis read by three,zone,updatedAt, androuteby 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.