Lesson 0057 · System Architect · Module 3
Design Contracts: Internal and External
There are two kinds of contracts — what a module's code promises, and what a system's API promises. Both are change agreements; both deserve the same care.
Mission tie-in: every seam from lesson 0053 and 0056 is a contract. Naming them, versioning them, and deciding who may break them is the discipline that keeps systems replaceable.
Knowledge: internal language, external vocabulary
The internal contract is what one module's code promises another: the function or protocol signature, its meaning, and its failure behavior. It should speak the ubiquitous language — assess(return_) → EligibilityDecision — and be shaped by depth, per lesson 0056. A Python type checker can verify much of the signature but not its meaning, so an internal contract also lives in documented promises and tests that pin behavior.
The external contract is what one independently changing system or process promises another — through a service, API, or event. This may also cross a trust boundary, but deployment and trust boundaries are not the same thing. Treat the contract as a product when consumers cannot change atomically with the producer. The API Design Patterns vocabulary offers one useful shape:
| Question | Designed answer |
|---|---|
| What is the resource? | A return; the decision is a child resource of it. |
| What names identify it? | POST /returns/{id}:assess — a custom method that reads like the domain verb. |
| How are retries deduplicated? | An idempotency key per assess intent (lesson 0045). |
| What does failure mean? | Every error carries a code, a machine-readable shape, and a retry hint. |
| How does it evolve? | The compatibility policy defines safe additions, semantic changes, migration support, and when a new version is required. |
A contract is a change agreement, and its cost is set by one fact: how many parties must change at the same instant. Versioning exists because atomic multi-party deployment does not. That is why the same edit — adding a field, tightening a type, renaming an enum value — is a refactor on one side of a deployment boundary and a breaking change on the other. Compatibility is a property of your deployment topology, not of the payload.
Which gives you the operational rule: the slowest consumer sets the length of every migration. Not the hardest one, not the loudest — the slowest to ship. Everything else about compatibility policy follows from measuring that number honestly.
So the principle relaxes exactly where the topology collapses. If the producer and every consumer live in one artifact, built and deployed together, a version negotiation is pure overhead: change both sides in the same commit and let the type checker be the contract. It tightens where the topology stretches: a mobile app you cannot force-update, firmware on devices in a warehouse, an analytics extract owned by another team. There, every field you publish is close to permanent, and the design question becomes what you are willing to promise for years.
Three rules govern the difference. First, naming is the contract: a method called refuse(reason) promises a decision; one called update_status(4) promises nothing. Second, validation and authorization are different edges: the schema edge rejects malformed input; the domain edge refuses valid-but-forbidden behavior (the state machine's guards from 0054). Keep them separate so error codes tell consumers which one they hit. Third, compatibility is a delivery feature: decide what consumers may assume, what you may add freely, and what counts as breaking — then record it as a policy, not a hope.
Consumers read tolerantly where the media type permits it: require only the fields you use and ignore unknown additions (tolerant reader). But additions are not automatically safe: closed schemas, exhaustive enums, signatures, payload limits, and changed semantics can still break consumers. Define compatibility per contract and verify representative consumers. Producers evolve deliberately: expand/contract keeps old and new forms compatible until migration evidence makes contraction safe.
# Internal contract: a protocol in the domain's language, plus tests.
from typing import Protocol
class Assessable(Protocol):
"""Promises: one decision per return, rules enforced inside."""
def assess(self, return_) -> "EligibilityDecision": ...
# External contract: a contract test pins the promises consumers rely on.
def test_assess_is_idempotent():
client = ApiClient()
key = "intent-42"
first = client.post("/returns/r-1:assess", idempotency_key=key)
second = client.post("/returns/r-1:assess", idempotency_key=key)
assert first.status == second.status == 200
assert first.body["decision"] == second.body["decision"]
Worked: what a new refusal reason costs
The policy owner wants a new refusal reason — condition_disputed — added to the decision payload. It is one enum value. The architect's first question was not about JSON. It was: who consumes this, and how fast can each of them ship?
| Consumer | Owner | Ships | Behaviour on an unknown reason |
|---|---|---|---|
| Returns portal (web) | Our team, same artifact | With us | Renders the human-readable text the payload carries. |
| Support tool | Another team | Weekly | Ignores unknown fields; displays the raw code. |
| Warehouse scanners (140 devices) | Logistics | Twice a year | Exhaustive match — an unknown value throws and the scan fails. |
| Analytics extract | Data team | Monthly | Unknown values land in an “other” bucket; reports stay valid. |
Three of four consumers absorb the new value without a release. The fourth cannot, and it sets the calendar: a change that requires the scanners to ship first costs six months of dual support, minimum. That number — not tidiness — is what makes this a design decision rather than a ticket.
The team's first instinct was to publish /v2/returns so the scanners could stay on v1 indefinitely. The architect rejected it, and the reasoning generalizes: a version is a fork of your maintenance, paid on every future change, to solve a problem that exists for one consumer and one field. The cheaper design is to make the payload's promise explicit — “new reason codes may appear at any time; treat unknown codes as other and show the accompanying text” — publish it as part of the contract, and fix the one consumer that cannot honour it. The scanner team added a tolerant fallback in their next scheduled release; the enum value shipped the following week to everyone else.
# The producer's promise, and the reader that survives it.
from dataclasses import dataclass
from enum import Enum
class RefusalReason(Enum):
POLICY_WINDOW_PASSED = "policy_window_passed"
EVIDENCE_MISSING = "evidence_missing"
OTHER = "other" # the declared landing place for codes we do not know
@dataclass(frozen=True)
class Decision:
"""Contract v1. Additive reason codes are allowed; the shape is not."""
accepted: bool
reason: RefusalReason
reason_code: str # kept verbatim, so support can still read it
reason_text: str # human-readable, supplied by the producer
def read_decision(payload: dict) -> Decision:
"""A tolerant reader: require what we use, ignore what we do not."""
code = payload.get("reason_code", "") # absent only on an acceptance
try:
reason = RefusalReason(code)
except ValueError: # an unknown code is not an error
reason = RefusalReason.OTHER
return Decision(
accepted=payload["accepted"],
reason=reason,
reason_code=code,
reason_text=payload.get("reason_text", code), # optional — degrade, do not fail
)
# The contract test that pins the promise, run against every producer build.
def test_unknown_reason_code_does_not_break_the_consumer():
decision = read_decision({"accepted": False, "reason_code": "condition_disputed",
"reason_text": "Condition disputed by the warehouse",
"reviewed_by": "policy_owner"}) # a field we ignore
assert decision.reason is RefusalReason.OTHER
assert decision.reason_code == "condition_disputed"
Over the following two years the contract stayed at v1 through nine additive changes. One change was genuinely breaking — the return identifier moved from an internal sequence to an opaque string — and it got its own version and a seven-month overlap, because that is what the slowest consumer costs. The discipline is not “never version”. It is: version when the change is really breaking, and spend the rest of your effort making sure most changes are not.
| Question | Bad default | Designed answer |
|---|---|---|
| What does this method promise? | Its return type. | Meaning, failure, and idempotence. |
| Who may break the contract? | Whoever deploys first. | The versioning policy says. |
| What may be added? | Whatever fits. | Only changes verified compatible with consumers. |
| What is a breaking change? | Undefined. | Written down and announced. |
- “Adding a field is always backward compatible.”
- Not against a closed schema, an exhaustive enum match, a signed or size-limited payload, or a strict deserializer that rejects unknown keys. “Additive” is a claim about your consumers' parsers, so verify it against representative consumers instead of asserting it.
- “Semantic versioning on the API tells consumers what they need.”
- The number is bookkeeping. The contract is the sentence: what may change without notice, what triggers a new version, how long the old one is supported, and how consumers will be told. A major-version bump with no support window is a break with a nicer label.
- “Internal contracts do not need this — we can just refactor.”
- True only while every consumer deploys atomically with you. A message sitting in a queue, a row a background worker will read tomorrow, or a cached payload is already a versioned contract, even inside a single deployable, because the producer and the consumer are separated in time.
- “Tolerant reader means accept anything.”
- It means: require the fields you act on, ignore the ones you do not, and have a declared policy for unknown values in the fields you do act on. Silently accepting a malformed payload is not tolerance; it is a corrupted record discovered three weeks later.
When this is the wrong move. One repository, one artifact, one team, one deploy — a compatibility policy there is ceremony, and the type checker plus a rename is the whole migration. Two services owned by the same team and released together need their failure semantics written down but not a version negotiation. And a contract nobody consumes yet is not a contract: publishing v1 of an API with zero external consumers buys you the obligations of stability with none of its benefits. Wait for the second consumer; that is when the agreement starts to mean something.
Skill: separate the two contracts
An internal contract expresses…
A versioning policy must say…
A producer adds a new enum value. A robust tolerant reader should…
Practice: write both contracts for one seam
Choose the riskiest seam on your boundary map, or use eligibility → return journey in the supplied case. Write the internal contract: protocol, meaning, failure behavior, and one contract test. Then design its external form: resource or event, naming, identity, idempotency, errors, and a compatibility policy. Test the policy against three changes — an optional field, a new enum value, and changed semantics — and state which consumers would break.
In the lab and the capstone: the companion lab's phase-5 checkpoint exposes a payload with no stated compatibility policy. Write the policy the code already implies, then try the three changes above against it and see which consumer would break — the exercise usually finds one assumption nobody knew they had made. The worked capstone records the contracts and their evolution rules in its state and contracts artifact, and the brownfield case works the same problem against a legacy producer in contracts at the seam.
Reveal: the producer-consumer interview
Ask the producer and a consumer separately: what does timeout mean, what happens on duplicate, what may a new field contain, and when may a field disappear? If the two answers differ anywhere, you have already found the next production incident — before it ships.
- Explain to a senior engineer, in two sentences, why compatibility is a property of the deployment topology rather than of the payload.
- Take one interface you own this week: name its slowest-shipping consumer, and say how long a breaking change would actually cost you.
- Predict the damage: if a producer treats every addition as safe for a year, name the consumer that breaks first and what the failure looks like to a user.
Your win
You can design internal contracts in the domain's language and external contracts with naming, idempotency, and an explicit evolution policy — and test both before they ship.
Read and watch deeper
- A Philosophy of Software Design, Ousterhout — ch. 4–6: the internal interface as a change agreement.
- “Tolerant Reader”, Martin Fowler (bliki entry) — the consumer-side half of compatibility, stated in two pages.
- API Design Patterns, JJ Geewax — ch. 3–9, 24, 27–29: resource naming, standard and custom methods, versioning, validation, retries, and revisions.
- Related lessons: 0045 (state and data contracts), 0033 (the trade-off); glossary tolerant reader, expand/contract.
Ask your agent-teacher to draft the endpoint surface from your contract policy, then act as a hostile consumer trying to break the idempotency or compatibility promises.