---
title: 'Contract Evolution'
source: 'https://academia.sh/en/courses/service-architectures/contract-evolution'
course: 'Service Architectures'
language: en
updated: '2026-08-23T07:00:30+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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)`);
```

```sh
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.

```js
// 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}`);
```

```sh
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.
