Skip to content
academia.sh

Lesson 18 / 18

Contract Evolution

Adding a contract field, removing it, renaming it, and making it required: how many consumers each step breaks in each direction, whether the break is silent or noisy, which direction a guarded read rescues, and the rollout order measured in deployment steps, broken calls, and extra fields carried.

Contents

The previous lesson made a promise: the publisher would publish an increasing version field on every event, and the consumer would read it. The same kind of promise built up across the course: message_id stays permanent, days always means the same thing. These fields are a contract that separate deployment units hold in common.

The problem is not the contract’s existence, it is its changing. Because the services are separate units, the contract cannot change at one instant: producer and consumers ship at different times, opening a window where two versions run together. M21/K03 established the testing side of this contract, and M19/K03 established the expand–contract pattern; neither is repeated here. This lesson covers the evolution steps themselves: which change breaks whom, whether it breaks silently, and what order lets the window pass harmlessly.

Mechanism

DC9. Producer and consumers are in-process functions; what gets measured is the contract’s shape, not the delivery mechanism.

DC10. The break criterion: the consumer’s computed result differs from the value correct before the change. A value mismatch counts as silent wrong, a thrown exception as error.

DC11. The window is fixed at 50 messages flowing between two deployment steps. Real length depends on deployment speed; fixing it is what makes the plans comparable.

Four Steps, Two Directions

The loan service publishes the loan_issued message, and the fee service computes the late charge. Each of the four change types is tested both ways: old consumer reading a new message, new consumer reading an old one.

// compatibility.mjs — four evolution steps, two directions: old consumer reads the new message, new consumer reads the old one
const base = { message_id: 1, book: 101, member: 4 };

const steps = [
  { name: "add field     ", correct: 28,
    oldMessage: { ...base, days: 14 },
    newMessage: { ...base, days: 14, dailyFee: 2 },
    oldConsumer: (i) => 2 * i.days,
    newConsumer: (i) => i.dailyFee * i.days,
    guardedConsumer: (i) => (i.dailyFee ?? 2) * i.days },
  { name: "remove field  ", correct: 336,
    oldMessage: { ...base, days: 14, hours: 336 },
    newMessage: { ...base, days: 14 },
    oldConsumer: (i) => i.hours,
    newConsumer: (i) => i.days * 24,
    guardedConsumer: (i) => i.hours ?? i.days * 24 },
  { name: "rename field  ", correct: 28,
    oldMessage: { ...base, days: 14 },
    newMessage: { ...base, durationDays: 14 },
    oldConsumer: (i) => 2 * i.days,
    newConsumer: (i) => 2 * i.durationDays,
    guardedConsumer: (i) => 2 * (i.durationDays ?? i.days) },
  { name: "make required ", correct: 28,
    oldMessage: { ...base, days: 14 },
    newMessage: { ...base, days: 14, dailyFee: 2 },
    oldConsumer: (i) => 2 * i.days,
    newConsumer: (i) => { if (i.dailyFee === undefined) throw new Error("dailyFee required");
                          return i.dailyFee * i.days; },
    guardedConsumer: (i) => { if (i.dailyFee === undefined) throw new Error("dailyFee required");
                              return i.dailyFee * i.days; } },
];

const attempt = (f, message, correct) => {
  try { return f(message) === correct ? "ok" : "silent wrong"; }
  catch { return "error"; }
};
const pad = (s) => s.padEnd(13);

const counts = { ok: 0, silent: 0, error: 0 }, guardedCounts = { ok: 0, silent: 0, error: 0 };
const tally = (k, s) => { k[s === "ok" ? "ok" : s === "error" ? "error" : "silent"] += 1; };

console.log("step            | old consumer <- new message | new consumer <- old message | guarded consumer <- old message");
for (const s of steps) {
  const r1 = attempt(s.oldConsumer, s.newMessage, s.correct);
  const r2 = attempt(s.newConsumer, s.oldMessage, s.correct);
  const r3 = attempt(s.guardedConsumer, s.oldMessage, s.correct);
  tally(counts, r1); tally(counts, r2); tally(guardedCounts, r1); tally(guardedCounts, r3);
  console.log(`${s.name} | ${pad(r1)}        | ${pad(r2)}        | ${pad(r3)}`);
}
console.log(`plain consumer: ok=${counts.ok} silent wrong=${counts.silent} error=${counts.error} (8 combinations)`);
console.log(`guarded       : ok=${guardedCounts.ok} silent wrong=${guardedCounts.silent} error=${guardedCounts.error} (8 combinations)`);
node compatibility.mjs
step            | old consumer <- new message | new consumer <- old message | guarded consumer <- old message
add field      | ok                   | silent wrong         | ok           
remove field   | silent wrong         | ok                   | ok           
rename field   | silent wrong         | silent wrong         | ok           
make required  | ok                   | error                | error        
plain consumer: ok=3 silent wrong=4 error=1 (8 combinations)
guarded       : ok=5 silent wrong=2 error=1 (8 combinations)

Only 3 of eight combinations are clean. The real finding is how the remaining 5 break down: 4 silent wrong, 1 error. A missing field becomes undefined, enters the computation, and the result breaks silently; no error is logged anywhere. Most contract-breaking changes make no noise.

The one noisy row is “make required,” and the noise comes not from the change type but from the consumer explicitly checking the field. The silence is a consequence of missing validation, not of the contract itself.

The ordering rule reads directly off this table. In field addition the broken direction is “new consumer ← old message,” so the producer ships first. In field removal the broken direction is “old consumer ← new message,” so the consumer ships first. Renaming is the sum of both, and both directions break: no single order can make it safe.

Consumer Protection

The last column is the new consumer rewritten to also read the old field name. Silent wrong drops from 4 to 2, ok rises from 3 to 5. All three rescued combinations share one direction: new consumer ← old message. The remaining 2 silent-wrong cases sit in the other direction, which consumer code cannot rescue — the consumer reading that message is already-shipped old code and cannot be touched.

From this comes the tightest rule in the course: consumer protection closes only one direction; closing the other is the producer’s job, done by continuing to send the old field name for a while longer.

Rollout Order

The rename is run through three plans. Two of the four consumers read the field, two do not.

// rollout.mjs — same field rename (days -> durationDays), three rollout plans; windows are counted
const WINDOW = 50;                       // messages flowing between two deployment steps

const message = {
  v1:         (n) => ({ message_id: n, book: 101, member: 4, days: 14 }),
  transition: (n) => ({ message_id: n, book: 101, member: 4, days: 14, durationDays: 14 }),
  v2:         (n) => ({ message_id: n, book: 101, member: 4, durationDays: 14 }),
};
const consumers = {                       // the first two never read the field, the last two do
  membership:   { correct: 4,   v1: (i) => i.member, v2: (i) => i.member },
  notification: { correct: 101, v1: (i) => i.book,   v2: (i) => i.book },
  fee:          { correct: 28,  v1: (i) => 2 * i.days, v2: (i) => 2 * i.durationDays },
  view:         { correct: 14,  v1: (i) => i.days,     v2: (i) => i.durationDays },
};
const plans = {
  "single step, producer first ": [["producer", "v2"], ["fee", "v2"], ["view", "v2"]],
  "single step, consumer first ": [["fee", "v2"], ["view", "v2"], ["producer", "v2"]],
  "expand-contract             ": [["producer", "transition"], ["fee", "v2"], ["view", "v2"],
                                    ["producer", "v2"]],
};

for (const [name, plan] of Object.entries(plans)) {
  const state = { producer: "v1", membership: "v1", notification: "v1", fee: "v1", view: "v1" };
  let broken = 0, calls = 0, extraFields = 0, extraBytes = 0;
  for (const [unit, version] of plan) {
    state[unit] = version;                // one deployment step
    for (let n = 1; n <= WINDOW; n++) {
      const i = message[state.producer](n);
      if (state.producer === "transition") {          // both field names carried together
        extraFields += Object.keys(i).length - Object.keys(message.v2(n)).length;
        extraBytes += JSON.stringify(i).length - JSON.stringify(message.v2(n)).length;
      }
      for (const [t, k] of Object.entries(consumers)) {
        calls += 1;
        let s; try { s = k[state[t]](i); } catch { s = null; }
        if (s !== k.correct) broken += 1;
      }
    }
  }
  console.log(`${name} | deployment steps=${plan.length} consumer calls=${calls}` +
    ` | broken calls=${broken} | extra fields carried=${extraFields} extra bytes=${extraBytes}`);
}
const readers = Object.values(consumers).filter((k) => String(k.v1).includes("days")).length;
console.log(`messages per window=${WINDOW} | consumers=${Object.keys(consumers).length}` +
  ` | consumers reading the field=${readers}` +
  ` | extra field in the transition version=${Object.keys(message.transition(1)).length - Object.keys(message.v2(1)).length}`);
node rollout.mjs
single step, producer first  | deployment steps=3 consumer calls=600 | broken calls=150 | extra fields carried=0 extra bytes=0
single step, consumer first  | deployment steps=3 consumer calls=600 | broken calls=150 | extra fields carried=0 extra bytes=0
expand-contract              | deployment steps=4 consumer calls=800 | broken calls=0 | extra fields carried=150 extra bytes=1500
messages per window=50 | consumers=4 | consumers reading the field=2 | extra field in the transition version=1

The two single-step plans gave the same number: 150 of 600 consumer calls broke. Order moves the break’s location, not its amount — a rename breaks both directions, so whichever side ships first, the other reads wrong for that entire span.

The expand–contract plan brings broken calls to 0. The cost is three items: deployment steps rose from 3 to 4, 150 messages carried an extra field across the window (1500 bytes, in this run), and the producer had to produce the same information under two names for that whole span. Where the duplicate code sits is a choice: kept in the producer, 1 unit carries both names; kept in the consumers, the 2 reading units each carry a guarded-read line. The measurement says the second option costs more — the gap grows with the number of reading consumers.

The fourth step — the contract — looks skippable. Skip it and the system keeps working; a dead field stays behind, and no one knows who reads it.

Summary

  • Of the eight change–direction combinations, 3 were clean, 4 came out silent wrong, and 1 errored; most changes that break a contract make no noise.
  • The one noisy row is the one where the consumer explicitly checks the field; the silence is a consequence of missing validation, not of the contract.
  • The ordering rule reads off the measurement: for adding a field the producer ships first, for removing one the consumer ships first. Renaming is the sum of both, and no single order makes it safe.
  • A guarded read brought silent wrong from 4 to 2; all three rescued combinations are in the “new consumer ← old message” direction. The other direction closes only by the producer continuing to send the old name for a while.
  • For the rename, both single-step plans broke 150 of 600 calls; expand–contract broke 0. Its cost is 1 extra deployment step, 150 extra fields, and 1500 extra bytes (in this run).
  • Skipping the contract step breaks nothing; a dead field stays in the contract permanently.

Course Wrap-Up

Across the course, the same library loan system was built eighteen times, in eighteen forms. Every lesson wrote the same three columns.

Lesson What it made cheaper What it made more expensive The failure mode born
Monolithic Application Local calls, a shared transaction boundary, one deployment step Scaling unit is the whole codebase; a one-line change republishes 7 modules A leak or infinite loop in one module stops every domain together
Modular Monolith A domain’s internals changing touched files dropped from 3 to 1 Public surface rose from 7 to 11; every cross-domain need adds indirection The rule holds only while the linter runs; a one-line import silently rolls back the boundary
Service-Oriented Architecture Units stopped importing each other; adding a unit leaves existing ones unchanged Units bound to the shared model rose from 1 to 3; a field-name change forces 3 files to ship together No error when the owner updates and the consumer does not; the boundary check silently disappears
Microservices A rate change restarted 1 of 5 deployment units 9 network hops per request, 7 sequential rounds; startup went from 1 step to 6 A mid-chain break leaves a half-finished state; the failure is partial
Serverless Approach Processes kept running dropped from 5 to 1; the deployment unit is a single file 4 new processes per request; store calls rose from 2 to 9 The time limit cuts work off mid-way: no error, an incomplete result, a partial effect stays in the store
Architectural Migration Strategies Rollback is 1 step, redeployment 0; the new form is tested on a live path The same rule lives in 2 codebases; every change is written to 2 files Updating only one codebase makes the same request give two different results
Boundary-Drawing Criteria A well-drawn boundary keeps a change inside one deployment unit: one deploy, one rollback A poorly-drawn one spreads the same change across 2 units; 1 import edge becomes a network call, 2 writes become boundary violations A window opens between two deploys: the new rate is live in one unit while the other still sends the old field
Synchronous Service Calls Four work steps finish in three rounds; one service going down leaves three endpoints up 3 network hops per request, 4 processes, requests processed 4 times over Partial failure; an unchecked downstream status code returns success for incomplete work
Asynchronous Integration Response rounds dropped from 2 to 1, units on the response path from 3 to 1 The contract spread across 3 deployment units; 2.17 units bound to each field on average A consistency window, and a consumer failure invisible on the response path
API Gateway Duplicated edge-concern lines dropped from 15 to 6; touched files and client calls from 3 to 1 Hops per request rose from 3 to 4; a new deployment unit; an endpoint path change still touches 2 files The gateway is the dependency closure of all outside traffic; bare services no longer defend themselves
Backend for Frontend Requests dropped to 1, extra bytes to 0; downstream field names the client reads went from 6 to 0 2 new deployment units, 13 of 38 lines identical; the request reaching downstream does not change A downstream field-name change with only one layer updated leaves clients silently drifting
Service Mesh Application code dropped from 38 lines to 13; 18 lines of network concerns left the application Processes rose from 2 to 3, local hops from 1 to 2, requests reaching the fee service from 4 to 5 A status-code-only retry repeated a non-idempotent charge: 2 charges
Service Discovery A new replica touched 3 configs versus 0; once the lease expired, recovery returned to 6/6 1 extra process and a single point of failure, 1 extra resolution request per call The registry holds a delayed copy of the truth; in the discovery window, 2 of 6 requests hit a dead address
Distributed Transaction Alternatives The partial outcome disappeared; no compensating logic in the application code Rounds went from 1 to 2, messages from n to 2n; the lock held 459.5 ms, 6 of 40 requests blocked Indeterminate state: a participant that voted but never got the decision holds the lock indefinitely
Saga Pattern The lock window was zeroed out; no resource stays held awaiting another service Every step needs a compensation; order bound to compensability; choreography spread flow information from 1 file to 4 A half-finished flow: a failure after the uncompensable step left 2 unrecoverable deliveries
Outbox Pattern “Done but not announced” disappeared: 22 losses dropped to 0, and the loss stopped being silent Every request writes two tables; a new process and polling interval; the box grows if never pruned A second delivery (24 messages) and relay lag: no error, just falling behind
Event Ordering and Duplication Correct final state from a corrupted stream: 4 wrong books to 0, extra notifications to 0 A column in the consumer schema, one read per message (+1471 queries), a version obligation on the publisher Silent discarding: 585 real events swallowed without a single error
Contract Evolution Expand–contract brought broken calls from 150 to 0; a guarded read brought silent wrong from 4 to 2 4 deployment steps instead of 3; 150 extra fields and 1500 extra bytes through the window Skipping the contract breaks nothing; a dead field stays in the contract permanently

The table carries the course’s rule: an architecture is defended only with these three columns. “More scalable” is not a decision; a sentence that omits which unit scales, how many hops and deployment steps get added in exchange, and which new failure mode is born does not count as an architectural justification. In none of the eighteen rows above is the third column empty: every boundary traded one problem for a new one.

One thing stayed missing. Most of these failure modes are silent: a silently rolled-back boundary, silently drifting clients, a silently swallowed event, a silently misread field. The system now runs across multiple processes, and every boundary gave birth to a new failure mode; but no tool was ever built for seeing where a request slows down or gets lost. Every lesson measured with a counter it wrote itself; the running system carries none of these counters on its own. The next course, M16/K07 Observability and Reliability, takes up exactly this gap.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close