Offline package · generated by tools/build_print.py
The complete course, on one page
Every lesson and every reference document in reading order, with the quizzes answered and the interactive parts removed. Print it, save it, or read it with no network. This file is generated — edit the individual pages, then re-run python3 tools/build_print.py.
60 lessons and 6 reference documents. Progress tracking, the search box and the navigation drawer are deliberately absent here: this page has no JavaScript, so it keeps working when nothing else does.
Lesson 0001 · Core Designer · Module 1
Seeing Coupling & Cohesion
Before you can fix structure, you have to see it. This lesson trains your eye to measure the two forces every structure is made of.
Mission tie-in: code that "works" but isn't maintainable is usually failing on exactly these two forces. Every later lesson — patterns, ports, domain protection — is a tool for steering them.
Knowledge: the two forces
Coupling measures how much one module depends on another. Low coupling means a module can change, be tested, or be replaced without its neighbors noticing. Cohesion measures how strongly the elements inside a module belong together — whether it has one purpose.
Look at this function. It works. It also does three different jobs:
def process_order(order_id: str, db: Database) -> None:
order = db.fetch_order(order_id)
total = 0.0
for line in order["lines"]:
total += line["price"] * line["qty"]
order["total"] = round(total, 2)
db.save_order(order)
EmailClient().send_receipt(order_id, order["total"])
logger.info("Order %s processed", order_id)
Ask two questions. Cohesion: how many jobs is this one function doing? It computes a business total, persists it, sends email, and logs — four. Coupling: what does the order flow depend on? It is now fused to the email client and the logging framework; change either and order processing breaks.
Notice what you didn't need: a diagram, a framework, or a tool. You saw the problem by asking how many reasons does this have to change, and who is it joined to. That is the skill of this module: not applying rules, but seeing.
† A function named with "and" ("process and notify", "validate and send") is a confession of low cohesion written into the name.Coupling is not expensive because a book says so. It is expensive because of an arithmetic you can run against your own repository. The cost a structural defect charges you over a year is roughly change propagation × change frequency × cost of understanding: how many modules one edit forces you to touch, how often that edit happens, and how long it takes to load each touched module into your head well enough to change it safely.
Put numbers on it. A checkout file on a six-engineer team gets edited around forty times a year. If each edit stays inside one module and costs twenty minutes of reading, that is roughly thirteen hours a year. Now fuse the same file to the mail client, the logging framework, and the reporting schema: the average edit touches four modules, so eighty minutes of reading — and three modules that had no reason to change are now inside the blast radius of a regression. Same feature work, four times the reading, and a test suite that has to cover neighbours you never intended to disturb.
Cohesion is the other half of the same arithmetic; it sets the denominator. It decides how much of what you read is relevant to the change you came to make. A module with one job means every line you read is on topic. A module with four jobs means three quarters of your reading is tax you pay to reach the quarter you needed.
Where it stops applying. Multiply by a change frequency of zero and the product is zero: a tightly coupled import script written for one migration and deleted afterwards costs nothing, because nothing propagates through code nobody edits. The same holds below a size threshold — in a two-hundred-line script the cost of understanding is small enough that structure buys less than it costs. And coupling between two things that genuinely always change together is not a defect at all. Separating those only means every change now needs two commits, two reviews, and a release order.
How a senior designer reads that function
Watch the order of operations, because it is not "read the code and dislike it".
They look at the history before the code. git log --oneline -- orders.py over six months returns eighteen commits: "add VAT for IE", "new receipt template", "switch to structured logging", "retry failed sends", "fix rounding on discounts". Five different parts of the world edit one file. That list is the cohesion measurement; the function body only confirms what the history already said.
Then they ask one question: if the mail vendor changes next month, what has to be re-tested? The honest answer is order pricing — because the send is inline, the pricing path and the send path share a failure surface. That sentence is what the coupling costs, stated in a form a product manager can act on.
Then they reject the obvious refactor. The tempting move is to lift the last two lines into a notification class that takes the order dictionary:
class NotificationService:
def send_receipt(self, order: dict) -> None:
EmailClient().send_receipt(order["id"], order["total"])
logger.info("Order %s processed", order["id"])
This looks like progress and is not. process_order still builds the dependency at call time, still fails when SMTP fails, and the new class now depends on the order dictionary's key names as well — a second module that breaks when total is renamed. The edit moved code across a file boundary without turning a single arrow. The tell for this mistake: after the refactor, is the list of things you must re-test any shorter? Here it is not, so nothing was decoupled; something was relocated.
The move that works changes who depends on whom. Order processing computes and saves, then returns a result; something outside it decides whether a receipt is sent. The receipt becomes a consequence of a completed order rather than a step inside one, and the order flow stops importing anything about mail at all.
The consequence over a year. In the coupled version, a marketing tweak to a receipt template ships through the order-processing test suite and the order-processing deploy. That is the version where, on the busiest trading day of the year, a template that raises on a missing field takes checkout down with it — the classic incident shape, in which the blast radius is set by structure rather than by the size of the change. In the decoupled version the same edit touches one module, and a failing receipt leaves an order that is still saved and still correct.
Cohesion and coupling trade against each other across a boundary: to lower coupling between two modules you often raise the cohesion of each — you pull each job into its own module and leave each module with one job.
| You see this | What it costs | The move |
|---|---|---|
def process_and_save(...) | The "and" in a name is two change sources admitted out loud. | Split at the “and”; each half gets one job. |
| import smtplib in a pricing module | Pricing redeploys when the mail vendor changes. | Inject a Notifier port; keep the vendor at the edge. |
| 4 mocks to test 1 assertion | Mock count tracks neighbour count — the unit has too many. | Pass collaborators in; delete the ones the job never uses. |
| one file in 8 of your last 10 PRs | It is the junction where unrelated changes meet. | List its jobs; give each one its own module. |
When this is the wrong move
Do not split a function merely because it does more than one thing. Leave it alone when the code is a spike or a migration script whose lifetime is shorter than the next change to it; when the two jobs have always changed together and there is no plausible world in which one changes alone; or when the split would replace one readable function with three modules plus an orchestration layer you now have to hold in your head at once. Structure is bought with reading cost, and a boundary nothing ever crosses is a boundary you paid for and never used. The question to answer honestly is not "does this have one job?" but "will anything ever want to change one of these jobs without the other?"
- Less coupling is always better, so aim for zero.
- Zero coupling means no system — modules that depend on nothing also do nothing together. The goal is few, explicit, and stable dependencies. Depending on
Decimalis coupling too, and it is free, becauseDecimaldoes not change. What costs you is depending on something volatile. - Moving code into a new file reduces coupling.
- Coupling lives in what one module must know about another, not in where the text sits. If the extracted class still reads the caller's dictionary keys, you have added an import and kept every dependency. The measurement is whether the set of modules a change forces you to touch got smaller.
- Cohesive means small.
- Cohesion is measured in reasons to change, not in lines. A three-hundred-line parser with one job is more cohesive than three twenty-line classes that must be edited together in every commit — and the second arrangement is strictly worse to work in.
- A metrics tool can tell me where the problem is.
- Import-graph metrics find candidates. They cannot see change frequency or volatility, which is where the cost actually comes from. A file with thirty imports that nobody has edited in two years is not your problem; the file with four imports that appears in every second pull request is.
Skill: point at the problem
Read each snippet below and name the force that is failing. Answer before checking — the act of deciding is the practice.
The function above computes totals, sends email, and logs. The primary problem is:
Low cohesion: four separate jobs
High coupling to the database
A name that misleads readers
Too many lines of code
Correct. Exactly. Pricing, persistence, notification, and logging each have their own reason to change; only one of them is order processing.
Which change best raises cohesion without breaking the order flow?
Extract email and logging
Add comments to function
Split lines across files
Rename it to process_and_notify
Correct. Each job — pricing, receipt, logging — becomes its own module with one purpose, and order processing calls them through narrow interfaces.
Two modules are tightly coupled when:
One module imports the other
They share a common folder
One change forces the other
They both import one library
Correct. That's the definition of coupling: a change in one forces a change in the other. Count those forced edits and you've measured it.
Practice on your own code
Take the messiest function in a file you own. Write down (in your head or a note): its jobs, and every neighbor it touches. If the list has more than one job or more than two neighbors, you have found this lesson's win. Then run the history check — git log --oneline -- <that file> — and see whether the commit subjects name more than one part of the world.
In the lab: the companion returns-resolution service carries this lesson's problem at working scale. Its phase-1 checkpoint asks you to find the coupling hot-spots in the resolution flow — the places where one edit forces several — and cut exactly one, then show that the list of modules a notification change touches actually got shorter.
Reveal: the refactored version of the example
class Order:
def __init__(self, order_id: str, lines: list[Line]) -> None:
self.order_id = order_id
self.lines = lines
def total(self, pricing: Pricing) -> Money:
return pricing.total(self.lines) # domain knows money, not email
# one job: order flow
def process_order(order_id: str, orders: OrderRepository, pricing: Pricing) -> Money:
order = orders.get(order_id)
return order.total(pricing) # persistence, email, logging live elsewhere
Order flow now depends on a repository and a pricing strategy — not on email or logging. Those became separate modules with one job each.
- Explain coupling and cohesion to a junior developer in two sentences, without using either word.
- Find one function in your own code this week that fails on one of the two forces, and name its cost in a unit you could put in a ticket: modules touched per edit, edits per month, or minutes of reading per edit.
- Predict what breaks in a year if nobody fixes it — specifically, which unrelated change will be the one that takes something down.
Your win
You can now look at any function and name which force is failing: too many jobs inside (cohesion) or too many neighbors outside (coupling). That single judgment is the basis of every other lesson in this course.
Read and watch deeper
- A Philosophy of Software Design, Ousterhout — ch. 4 "Modules Should Be Deep": why module shape, not size, determines whether code survives.
- Code Complete, McConnell — ch. 5 "Design in Construction": the deepest treatment of cohesion levels and coupling types.
- David Parnas, "On the Criteria To Be Used in Decomposing Systems into Modules" (Communications of the ACM, 1972) — the primary source. Its argument is not "split by processing step" but "split by the decision each module hides", which is where the change-propagation reasoning above comes from.
- Watch: search "coupling" and "cohesion" on ArjanCodes YouTube — the channel's videos on these are the primary source for this lesson.
- Reference: Design Principles — and the glossary on coupling, cohesion, and change amplification.
Anything unclear? Ask your agent-teacher — re-explain any part of this lesson, or bring your own code and have it pointed at together.
Source page: lessons/0001-seeing-coupling-and-cohesion.html
Lesson 0002 · Core Designer · Module 1
SRP, DRY, KISS, YAGNI in Context
These four are quoted as rules and violated as rules. The skill is reading them as forces: each names a failure mode, and each can be wrong.
Mission tie-in: AI will happily generate code that "follows the rules" while violating the point. Understanding the forces is what lets you keep control of AI output instead of accepting its letter.
Knowledge: forces, not rules
SRP — one reason to change
The unit is the reason to change, not the method. "We must edit this class because tax rules changed" and "…because we switched databases" — two reasons in one class is the failure. A class with twenty methods but one change source is fine; a class with two methods and two change sources is not.
DRY — once and only once, per unit of knowledge
DRY targets knowledge, not text. These two functions are identical today — and encode different knowledge:
def fee_for_bank_transfer(amount: Money) -> Money:
return amount * Decimal("0.01") # bank fee, set by our bank
def fee_for_wire_transfer(amount: Money) -> Money:
return amount * Decimal("0.01") # wire fee, set by the clearing house
Both compute 1% today. Merge them into one fee(amount) and the next time one fee changes, you will silently charge the other's customers the wrong amount. That is false DRY — deduplication that creates coupling between things that will diverge. The two fees changing together twice is what earns the merge (the rule of three).
KISS and YAGNI — the cost of complexity
KISS: prefer the least complex solution that meets the requirement. YAGNI: do not build capability with no evidence of need. Both are about unpaid tax: every generality, hook, and abstraction layer is paid for on every future read of the code, whether it's used or not.
† The expensive combination: YAGNI in the present ("too complex to build now") + KISS misread as "shortest code". Shortest is not simplest — a clear twenty-line function beats a clever five-liner.The context is the whole story: a hook is YAGNI in a two-month project and common sense in a platform other teams will build on. The same line of code can be a violation and a virtue in two codebases.
All four are heuristics about the same hidden quantity: the cost of being wrong, and how expensive the mistake is to undo. Once you see that, they stop competing and start dividing the space.
DRY's asymmetry. Un-merging two things that turned out to be different is cheap — copy the function, give each half its own name, done in an afternoon. Un-coupling two things that were merged years ago is expensive: you must find every caller and decide, per call site, which of the two meanings it wanted, usually without the person who wrote it. Worse, the merge fails silently: nothing crashes when one policy diverges, you just charge the wrong number until someone notices in a reconciliation. Cheap to undo in one direction, expensive and silent in the other — which is why the rule of three says wait.
YAGNI's asymmetry. An unused abstraction is paid on every read by every reader for the life of the code; the same abstraction added later is paid once, by one person, with better information about what it actually needs to do. That is a straightforward win for waiting — provided the later change is local.
Where they stop applying. That proviso is the boundary condition, and it is the one people miss. When the later change is not local — a published API with external consumers, a wire format shared by four teams, a schema with ten million rows already written — the "add it later" cost is a migration, a dual-write window, and a coordination meeting, not an edit. YAGNI weakens exactly in proportion to how irreversible the decision is. Similarly, SRP's unit — the reason to change — is only knowable if you know who edits the code and why; in a domain you have worked in for two weeks you do not know that yet, so a confident split is a guess wearing a principle's clothes.
The practical rule that falls out: be greedy about simplicity where change is cheap, and thoughtful about extension points where change is expensive. Code is cheap. Data, contracts, and other people's calendars are not.
How a senior designer decides the fee question
Return to the two 1% fees. Here is the reasoning, not the verdict.
First they look for the owner, not the code. Who decides each number? One is set in the bank's commercial agreement; the other is published by the clearing house. Two documents, two counterparties, two renewal dates. That answer arrives before anyone opens the function bodies, and it is already most of the decision.
Then the question: when one of these changes, does the other change in the same commit? Not "could they" — has it happened? Check the history of the constants. If the two numbers have moved independently even once, they are two facts and the merge is a bug waiting for a date.
Then they reject the compromise. The tempting middle path is to keep one function and pass a discriminator:
def fee(amount: Money, kind: str) -> Money:
rate = Decimal("0.01") if kind == "bank" else Decimal("0.01")
return amount * rate
This is worse than either honest option. It is control coupling: the caller now passes a flag whose only job is to select which of two policies runs, so every caller must know the taxonomy, and the function's signature promises one behaviour while delivering two. It also creates the illusion of having deduplicated — the rate literal is written twice anyway. A senior designer's tell: if the parameter exists only to choose between branches, you have not shared knowledge, you have shared a switchboard.
The consequence over eighteen months. On a four-team payments platform, the wire fee went tiered — 1% up to €10,000, then 0.6%. In the merged version, that change edits the function both teams depend on; the bank team's regression suite starts failing for reasons that have nothing to do with their product, and the review needs someone from each team, so a two-line change waits three days for a quorum. In the kept-apart version it is one file, one reviewer, one afternoon. The duplication that looked like sloppiness was the thing that let two teams move at different speeds.
| You see this | What it costs | The move |
|---|---|---|
def fee(amount, kind) | A kind flag is two facts pressed into one function. | Keep two functions until a third change moves both. |
| OrderData + OrderLogic | SRP applied by shape, not by change source — one force, two files. | Merge them back; split only where the forces differ. |
| **kwargs three layers deep | KISS misread as “less typing”: the contract disappeared. | Name the parameters at every layer. |
| strategy= with one implementation | YAGNI: a hook paid for on every read, used by nobody. | Delete the hook; add it when case two arrives. |
When this is the wrong move
- Do not wait for the rule of three when the duplicated thing is a security or correctness check. Three copies of an authorisation test means two of them will drift, and the failure mode is not a wrong number, it is an open door. Centralise on the first copy.
- Do not apply YAGNI to stored data or published contracts. A version field on an event, a nullable column reserved for a known-coming attribute, an
extramap on a public payload — these are cheap now and cost a migration later. The principle is about capability, and its strength scales with reversibility. - Do not split for SRP in an unfamiliar domain. You cannot name change sources you have never seen. Keep it in one module, watch the edits for a quarter, and split when the history tells you where the line is.
- Do not read KISS as "fewer abstractions". The simplest thing for the reader is sometimes one more named concept, not one fewer. A clear twenty-line function with two well-named helpers beats a clever five-liner that needs a paragraph of explanation.
- DRY means no duplicated text.
- DRY is about duplicated knowledge — a fact that would have to be changed in two places if it changed at all. Two functions with identical bodies and different owners are not a DRY violation; one fact spread across a config file, a constant, and a comment is, even though the three look nothing alike.
- YAGNI means never design ahead.
- YAGNI is about building capability with no evidence of need. Thinking ahead is free and mandatory; the discipline is not shipping the machinery until the evidence arrives, except where the later fix is a migration rather than an edit.
- KISS means fewer lines.
- Simplicity is measured in the reader's head, not the file's byte count. Density and simplicity are frequently opposites: a comprehension nested three deep is short and hard, an explicit loop is longer and obvious.
- These principles conflict, so you have to pick a favourite.
- They govern different axes. DRY is about who owns a fact; SRP about what forces edit a module; KISS about the reader's load; YAGNI about the evidence for a capability. A concrete decision usually only triggers one of them, and when two seem to fight, you have mis-stated at least one.
Skill: judge the application, not the principle
Two systems charge the same 5% fee today. Extracting one shared function is:
False DRY coupling two facts
True DRY removing a repeat
It is a YAGNI violation
It is a naming problem
Correct. The fee is one number but two facts (two contracts). Merging them bets on the future; the rule of three says wait for a third change.
A class has ten methods but exactly one reason to change. SRP says:
Split it by each method
Leave it as it is
Split its data from behaviour
Use one class per method
Correct. Right: one change source, one module. Ten methods serving that source are cohesive.
A team adds a version field to an event format that is written to durable storage, before any second version exists. This is:
Sound, because migration later is expensive
Speculative, because no second version exists
Simplest, because one field costs nothing
Wrong, because YAGNI forbids unused fields
Correct. Right. YAGNI weighs the cost of adding later. For code, later is cheap and local; for rows already written to disk, later means a migration and a dual-read window. Irreversibility is what flips the call.
Practice on your own code
Find one place where a principle was applied as a rule — a shared helper that two things were forced into, a class split "to follow SRP", a generality built "just in case". Name the force it ignored, and then name the reversibility: if this call was wrong, what would undoing it cost in hours and in coordination?
In the lab: the returns-resolution service contains at least one shared helper that two policies were pressed into. The phase-1 checkpoint asks you to decide whether it is true DRY or false DRY using the ownership test — who sets each number, and have they ever moved independently — and to write the one-line justification for whichever way you call it.
Reveal: a worked judgment
An AI assistant refactors a file and reports: "Extracted get_price() shared by sales and returns, following DRY." Look closer: sales prices include VAT; return prices are ex-VAT. The shared helper is false DRY — the moment VAT rules change, returns pricing changes too. The right move is to keep two functions and one documented fact: these rates are set by different policies.
- Explain to a junior why two identical functions can be correct design, in two sentences, without using the word "DRY".
- Find one abstraction in your own code this week that was added "just in case", and name its cost: how many readers have paid for it, and how many callers have used it.
- Predict what breaks in a year if a fact your system owns stays merged with a fact someone else owns — and say who will be the first to notice.
Your win
You can now apply the four principles as a judge, not an executor: for any "violation," name the force it actually threatens — and spot the rule-following that quietly does damage.
Read and watch deeper
- Clean Architecture, Martin — ch. 7 "SRP: The Single Responsibility Principle": the reason-to-change definition.
- The Pragmatic Programmer, Hunt & Thomas — "DRY: The Evils of Duplication", where DRY is defined over knowledge rather than text.
- Refactoring, Fowler — ch. 2, where the Rule of Three is stated ("three strikes and you refactor"). It belongs to Fowler, not to DRY, and the distinction matters: DRY says what to unify, the rule of three says when.
- "Yagni", Martin Fowler — the clearest primary statement of the cost model: presumptive features cost build, carry, repair, and delay.
- A Philosophy of Software Design, Ousterhout — ch. 11 "Design It Twice" and the discussion of complexity in ch. 2: KISS made concrete.
- Watch: ArjanCodes YouTube — search "7 principles of software design" for the source framing of these four.
- Reference: Design Principles — all nineteen, each with the way it fails when followed as a rule; and the glossary on false DRY and the rule of three.
Stuck on a judgment call? Ask your agent-teacher: paste the code and the claimed principle, and it will help you name the force actually at stake.
Source page: lessons/0002-principles-in-context.html
Lesson 0003 · Core Designer · Module 1
The Law of Demeter
A method should only talk to its immediate neighbors. When code walks through strangers, it learns their geography — and every geography change breaks it.
Mission tie-in: chain-walking is one of the most common structural defects in AI-generated code. Master the tell, and you can spot it in any output in seconds.
Knowledge: no talking to strangers
The Law of Demeter limits a method's acquaintances to: itself, its own fields, its arguments, objects it creates, and the direct members of its own collections. Everything else is a stranger.
This is the failure it prevents — a "train wreck":
def is_free_shipping(order: Order) -> bool:
city = order.customer.address.city
return city in FREE_SHIPPING_CITIES
is_free_shipping reaches through order into customer, then address, then city. The caller now knows the internal geography of three objects it doesn't own. Change customer.address to customer.delivery_address and this breaks even though nothing about shipping changed.
The fix is tell, don't ask: move the behavior to the object that owns the data.
def is_free_shipping(order: Order) -> bool:
return order.has_free_shipping_to(FREE_SHIPPING_CITIES)
# inside Order:
def has_free_shipping_to(self, cities: set[str]) -> bool:
return self.customer.delivery_city() in cities
The caller talks to one neighbor (order). The chain still exists — but it lives inside the object that owns the data, where geography changes are contained. The Law of Demeter is not "no chains ever"; it is "nobody walks through strangers."
The reason a chain is dangerous is that the signature lies. is_free_shipping(order: Order) declares one dependency. The body has four: Order, Customer, Address, and the fact that a city is a plain string that can be compared to a set. Every reviewer, every type checker, and every dependency-graph tool reasons about the declared dependency. Only the runtime knows about the other three. Structural defects that are invisible to your tools are the ones that survive longest.
The second mechanism is multiplication. Each hop is an independent assumption about someone else's internals, so the chance the line breaks is one minus the chance none of them changes. If each of three types has a modest 20% chance of being restructured in a year, the line has roughly a 49% chance of breaking within twelve months — for a reason that has nothing to do with shipping. Depth is not a style preference; it is compounding risk.
The third mechanism is ownership. A hop that stays inside one module, owned by one team, on one release train, is cheap: the person who renames the field can fix the caller in the same commit. A hop that crosses into a shared library owned by another team is expensive: the rename lands in their release, your code breaks in yours, and neither pull request contains both halves of the story. Count the ownership boundaries a chain crosses, not the dots.
Where it stops applying. Chains through stable, structural things are fine and forcing them through methods makes code worse. datetime.now().date().isoformat() is three hops into a standard library that will outlive your company. Walking a parsed JSON document, a configuration tree, or an abstract syntax tree is normal, because those are data structures, not objects with behaviour to ask — there is nobody to tell. And fluent interfaces — query builders, test assertions, dataframe pipelines — are chains by design: the chain is the interface, not a walk through someone's privates.
How a senior designer judges a chain
The first question is not "how many dots" but "how many owners". Open the three types in the chain. If Order, Customer, and Address all live in domain/orders/, maintained by the same four people, the chain is a mild readability issue and probably not worth a refactor. If Address comes from a shared identity package on its own release cadence, every hop into it is a dependency on another team's calendar, and the same three dots are a real liability.
The second question is what kind of thing you are extracting. There is a real difference between pulling out a value and moving a decision. Both look like refactors; only one helps.
The rejected alternative. The reflex fix is to add a forwarding accessor:
# inside Order — looks like the law, isn't
def customer_city(self) -> str:
return self.customer.address.city
The caller now says order.customer_city() and the reviewer nods. But nothing was decided anywhere new: Order has acquired an opinion about address geography, the chain still exists one frame down, and if Address gains a second delivery address, Order is the thing that breaks. This is Fowler's middle man: a class whose methods mostly delegate. The test that catches it: does the new method express something the object is responsible for, or is it a tunnel dug for one caller's convenience? "What city does this customer receive deliveries in?" is Customer's business. "Does this order ship free?" is Order's business. "What is the string inside my customer's address?" is nobody's business.
The consequence at scale. An identity team changes Customer.address to Customer.addresses, a list, to support a separate billing address. In a 200,000-line codebase, grep -rn "\.address\." returns something in the hundreds across a dozen modules, and each hit has to be judged individually: which address did this caller mean? Nobody can answer that from the call site, because the call site never said. Where the question had been moved into Customer, the same migration is one file and one decision made once, by the team that actually knows the answer. That asymmetry — one deliberate decision versus hundreds of undocumented ones — is the whole cost of the law, and it only shows up on the day someone restructures.
| You see this | What it costs | The move |
|---|---|---|
a.b.c.d | The caller depends on the shape of three objects it does not own. | Ask the owner one question: a.answer(). |
get_x().get_y().get_z() | The same wreck in getter clothing — chained accessors still walk strangers. | Move the question into the first object. |
| a mock returning a mock returning a mock | The mock chain mirrors the call chain: proof of a train wreck. | Flatten the interface first, then rewrite the test. |
| self.customer.address inside Order | Legal: customer is Order’s own field, a direct neighbour. | Leave it — the law limits callers, not owners. |
When this is the wrong move
Leave the chain alone when it walks a data structure rather than an object: parsed JSON, a frozen configuration tree, an AST node. There is no owner to ask, and inventing one produces a wrapper class per node type for no benefit. Leave it alone when the chain is the published interface — query builders, assertion libraries, and pipeline APIs are designed to be chained, and "fixing" them destroys the thing you paid for. Leave it alone inside an aggregate root reaching into its own parts, where the root is the owner (lesson 0012). And resist the fix when it would push a foreign concern into a domain object: adding Order.to_html_row() to spare a template three dots trades a small coupling for a large one, because now presentation lives in the domain.
- The Law of Demeter means "at most one dot".
- Dots are not the unit; strangers are.
self._lines.append(item)has two dots and is entirely legal —_linesis your own field andappendis its own method. Meanwhilea.b()can be a violation ifb()hands you an object you then interrogate. - Adding a delegating getter fixes it.
- A forwarding accessor keeps the dependency and adds a method to the wrong class. You have paid for a new API surface and bought nothing: when the far end restructures, the delegator breaks instead of the caller. Move the decision, not the value.
- It applies to data transfer objects and records too.
- A record with no behaviour has nobody to tell. Walking a response payload or a config tree is reading a structure, not violating an encapsulation — the structure is the contract. The law is about objects that could answer a question and were asked for their insides instead.
- It is really about avoiding
Noneerrors. - Null-safety is a side effect, not the point. A chain of non-nullable fields still couples you to three shapes. Conversely, optional-chaining syntax makes the crash go away while leaving every coupling exactly where it was.
Skill: spot the stranger
Which snippet walks through a stranger?
self.customer.name
arg.quantity
payment.invoice.date
self._new_logger()
Correct. Exactly: payment is an argument, but invoice and date are strangers reached through it.
Why does a train wreck hurt maintainability?
It lowers the coupling
It knows their geography
It returns wrong values
It slows down code
Correct. Correct: the caller depends on the internal shape of objects it doesn't own, so any restructuring breaks it.
The best fix for order.customer.address.city is to:
Extract a mediator object
Ask the owner directly
Collapse to one line
Pass the city in
Correct. Right: the object that owns the data should answer the question itself, so the chain is contained where it belongs.
Practice on your own code
Search a file you own (or an AI-generated one) for chains: patterns like a.b.c.d or repeated get_x().get_y(). Pick one and, before touching it, answer the two senior questions: how many owners does this chain cross, and what decision is the caller really trying to make? Then move the decision to the object that owns the data — and check you did not just build a middle man.
In the lab: the returns-resolution service reaches through several objects to decide whether a return is eligible. The phase-1 checkpoint asks you to find the deepest chain in that decision path, name the owner that should be answering it, and move the question there — then confirm the test for that rule no longer needs to build three nested objects to run.
Reveal: a worked example
# before
if booking.flight.airline.contact_email:
notify(booking.flight.airline.contact_email)
# after — the airline answers; Booking has no opinion on airline internals
if booking.flight.has_contact_email():
notify(booking.flight.contact_email())
- Explain to a junior why
a.b.cis riskier thana.answer()in two sentences, without mentioning dots. - Find one chain in your own code this week and name its cost concretely: how many teams' release schedules that one line depends on, and how many call sites would need judging if the far end were restructured.
- Predict what breaks in a year when the type at the end of that chain gains a second variant — a second address, a second contact, a second account.
Your win
You can spot a train wreck at a glance and know the one fix that works: hand the question to the owner of the data. Add this to your code-review checklist and your AI-review checklist.
Read and watch deeper
- Lieberherr & Holland, "Assuring Good Style for Object-Oriented Programs" (IEEE Software, 1989) — the primary source, where the law is stated as a constraint on which objects a method may send messages to.
- The Pragmatic Programmer, Hunt & Thomas — the "Decoupling and the Law of Demeter" topic: the treatment that popularised it, including its cost in coupled deployments.
- Refactoring, Fowler — the "Middle Man" smell in the smells catalogue: the failure mode of over-applying the law by delegating everything.
- "Tell, Don't Ask", Martin Fowler — the companion principle that makes the law workable.
- Code Complete, McConnell — ch. 5: where coupling levels (including "content coupling" — reaching into internals) are ranked.
- Watch: ArjanCodes YouTube — search "Law of Demeter".
- Reference: Glossary — Law of Demeter, Tell, don't ask, train wreck.
Unsure whether a chain is a violation? Ask your agent-teacher to judge a snippet with you — the boundary cases are where the law becomes judgment.
Source page: lessons/0003-law-of-demeter.html
Lesson 0004 · Core Designer · Module 1
Recognizing When Strategy Is Emerging
Patterns are not applied — they are noticed. The Strategy pattern is already in your code the moment one behavior branches on a type. This lesson trains the noticing.
Mission tie-in: "recognize when patterns emerge naturally" is a core Designer skill; it's also the difference between AI that copies patterns and code that grows them from evidence.
Knowledge: the shape that already exists
The Strategy pattern's shape: a varying behavior extracted behind an interface, with implementations swapped at runtime without touching the caller. Before it is a pattern, it is a smell — an if/elif chain selecting behavior by a type field:
def discount(customer: Customer, total: Money) -> Money:
if customer.tier == "gold":
return total * Decimal("0.90")
elif customer.tier == "silver":
return total * Decimal("0.95")
else:
return total
Ask: what will change here? The set of tiers — new tiers appear, discounts change, and every edit lands in this one function. That is a variation point with a growing list of branches. The Strategy is emerging: the chain already plays the role of a strategy selector.
Making it explicit:
class DiscountStrategy(Protocol):
def apply(self, total: Money) -> Money: ...
class GoldDiscount:
def apply(self, total: Money) -> Money:
return total * Decimal("0.90")
class SilverDiscount:
def apply(self, total: Money) -> Money:
return total * Decimal("0.95")
class NoDiscount:
def apply(self, total: Money) -> Money:
return total
def discount(strategy: DiscountStrategy, total: Money) -> Money:
return strategy.apply(total)
Nothing about behavior changed — the chain was already a strategy; now it is a strategy with names and seams. New tiers become new classes instead of new branches.
† The discipline: extract only when the variation is real and growing. One branch is a choice; two branches are a choice; the third repetition is when the rule of three starts whispering "strategy."The branch chain and the strategy set compute the same answers. They are not better and worse code; they are two different bets about the rate at which variants arrive, and each has a price you can name.
A branch chain charges you at write time. Adding a variant means editing a function several people share, so the cost scales with the team: two engineers adding tiers in the same sprint get a merge conflict, and the diff touches a function whose other arms must all be re-tested because the reviewer cannot prove they were untouched. A strategy set charges you at read time. Every future reader pays one level of indirection to answer "what actually runs for a gold customer?" — a jump from the caller to the registry to the class. That toll is small, but it is levied on every reader, forever, whether variants arrive or not.
So the trade is: rate of new variants × cost of a shared edit against readers per year × cost of one indirection. Two variants that have not moved in a year: the chain wins easily. Six variants, one new per quarter, requested by three different stakeholders: the strategy wins easily. Most real cases sit near the crossover, which is exactly why "always extract" and "never extract" are both wrong and why the rule of three is a reasonable default — the third occurrence is the cheapest reliable evidence that the rate is non-zero.
One thing the pattern does not do: remove the conditional. Something still has to map "gold" to GoldDiscount. Strategy moves that decision into one named place — a registry, a factory, a composition root — where it is data rather than control flow. If after your refactor you cannot point at the single line where selection happens, you have not removed the branch; you have scattered it.
Where it stops applying. When the variants differ only in a number, a dictionary is the answer and a class per rate is ceremony. When variants must combine rather than exclude each other — a gold customer with a promo code and a regional surcharge — mutually exclusive strategies degenerate into a combinatorial matrix, and you want composition (a pipeline of adjustments) or a rules table instead. And when the arms share ninety per cent of their body, a set of strategies duplicates that ninety per cent; a parameterised function or a template method is the honest shape.
How a senior designer decides whether to extract
First they read the chain's history, not the chain. How many arms did it have six months ago? Three, and now five. That is a rate: roughly one new arm a quarter, and the trend is what the decision hinges on — a chain that has had two arms since 2021 is not a variation point, it is a boolean.
Then they look at who adds arms. If every new tier arrives as a ticket from a different stakeholder — growth wants a referral tier, finance wants a wholesale tier — the arms are not one team's implementation detail, they are a queue. Queues want a place to append. If instead one engineer has quietly added all five, the chain is fine and the cost of the pattern would be paid by readers who do not benefit.
Then they reject the lighter option, out loud. The cheapest possible fix is a lookup table:
RATES = {"gold": Decimal("0.90"), "silver": Decimal("0.95")}
def discount(customer: Customer, total: Money) -> Money:
return total * RATES.get(customer.tier, Decimal("1.00"))
If the variants differ only in a number, stop here — this is smaller, faster to read, and adding a tier is a data change. The reason to go further is that the variants have stopped being numbers. Gold has acquired a cap ("never more than €50 off"), silver applies only between November and January, and wholesale needs the customer's contract. Those are three different behaviours, and a dictionary of rates cannot hold them without growing a second dictionary of exceptions beside it. That is the moment the strategy earns its indirection: not when the list got long, but when the arms stopped being the same shape.
The consequence over a year. Left as a chain, the tier logic on a mid-sized commerce team reaches nine arms and about 140 lines, and becomes the file with the most merge conflicts in the repository; because every arm lives in one function, every discount change requires re-running and re-reading the whole test class, and the team develops a habit of adding an arm at the top with an early return "to be safe". Extracted, the same nine variants are nine small files, each with its own test, and the selection is a dictionary someone can read in five seconds. The second version is not more elegant — it is cheaper to change concurrently, which is what actually mattered.
| You see this | What it costs | The move |
|---|---|---|
| if kind == …: elif kind == … | Three or more arms: every new variant edits one shared function. | Extract one strategy per arm; the caller stops branching. |
| RATES = {"gold": 0.90, …} | Data-only variation — no behaviour differs, so no strategy is emerging. | Keep the lookup table; it is already the lighter answer. |
| two branches, unchanged for a year | Extracting now buys machinery for a variant that may never arrive. | Keep the branches; write the variation point in a note. |
| the same tier chain in three files | The variation point already leaked — three copies, one shape. | Extract now: the third occurrence is the evidence. |
When this is the wrong move
- Two stable arms. A boolean wearing a string. Extracting gives you two classes, an interface, and a factory to express
if. - Data-only variation. If the arms differ by a rate, a threshold, or a message, put them in a table. A strategy per row is a class hierarchy pretending to be a configuration file.
- Variants that compose. Strategy assumes exactly one implementation is chosen. When several adjustments can apply at once, you want a list you fold over, not a selection — reaching for Strategy here produces a class per combination.
- Selection driven by untrusted input. A registry keyed by a string from a request is a dispatch table an attacker can aim. Validate the key against a closed set before it reaches the lookup, and never build the class name from input.
- Strategy removes the conditional.
- It relocates it. The win is that the decision now happens once, in a named place, as data. If you cannot point at the line that maps a key to an implementation, the branch is still there — spread across the codebase instead of concentrated in one function, which is worse.
- Every if/elif chain is an emerging strategy.
- Only chains that select behaviour and are growing. A chain over three stable error codes, or one that validates input, is just a conditional doing its job. The signal is a variation point with arrivals, not a shape you dislike.
- Once extracted, the code is future-proof.
- It is open for extension along exactly one axis — the one you extracted. Add a second axis (tier × country) and four strategies become twelve. Open–closed is a direction, not a guarantee, and choosing the wrong axis is worse than not choosing one.
- A dictionary of functions is not a real Strategy.
- It is. Python's first-class functions make the class ceremony optional; the pattern is the seam and the swap, not the
classkeyword. Reach for classes when a variant needs its own state or several methods, and for functions when it does not.
Skill: see the strategy inside the code
Which signal says a Strategy is emerging?
One behavior branches on type
A long linear single function
Many small classes already exist
Many imports at the top
Correct. Exactly: a behavior with several variants selected by a field is the Strategy shape, already present in disguise.
Two branches exist today, with no sign of a third. The right move is:
Extract a strategy now
Keep the branches, watch
Replace with a dict
Move to a helper
Correct. Correct: two branches are a decision, not a pattern yet. Watch for the third; extract when the growth is real.
After extracting the strategy, a new tier arrives. You:
Add a new strategy class
Add a branch in caller
Edit the existing strategy classes
Create a brand new module
Correct. Right: the caller is untouched; a new class is added and wired where strategies are chosen.
Practice on your own code
Find an if/elif chain (or match) in your codebase that selects behavior by a field or string. Gather the two pieces of evidence before deciding: how many arms it had six months ago, and whether the arms differ by a value or by a behaviour. Then commit to a call — extract now, drop to a lookup table, or write the variation point down in a note and keep watching.
In the lab: the returns-resolution service branches on the reason a customer gives for a return, and the list has been growing. The phase-1 checkpoint asks you to decide whether that chain has earned a strategy or only a lookup table, and to justify the call with the arm-arrival rate rather than with taste.
Reveal: a worked judgment
A notification module branches on channel == "email" | "sms" | "push". Three channels exist and a fourth is planned. The branch is a variation point with growing list — extract NotificationChannel as a protocol with one send(). The caller selects by lookup, not by chain.
- Explain to a junior, in two sentences, why an
if/elifchain is sometimes better design than a set of strategy classes. - Find one branching chain in your own code this week and name its cost: how many arms arrived in the last two quarters, and how many people had to edit the same function to add them.
- Predict what happens in a year if it keeps growing untouched — how long the function gets, who ends up owning it, and which arm eventually gets added in the wrong place.
Your win
You now read code looking for variation points, not pattern names. When a chain selects behavior by type, you can name what's emerging — and, just as important, refuse to extract what isn't.
Read and watch deeper
- Head First Design Patterns, Freeman et al. — ch. 1: Strategy taught by noticing a problem, not by memorizing a diagram.
- Design Patterns, Gamma et al. — ch. 5, Strategy: the canonical reference.
- Refactoring Guru — Strategy: the "problem/solution" framing that matches this lesson's noticing-first approach.
- Refactoring, Fowler — "Replace Conditional with Polymorphism" in the refactoring catalogue: the mechanical steps for the transformation above, and the preconditions that tell you it is safe.
- Watch: ArjanCodes YouTube — search "strategy pattern python".
- Reference: Patterns Quick Reference — Strategy, with the shape and the “don't”.
Show your agent-teacher a branching snippet and compare judgments on whether a strategy is truly emerging.
Source page: lessons/0004-spotting-emerging-strategies.html
Lesson 0005 · Core Designer · Module 1
Structural Issues in AI-Generated Code
AI output is legacy code from an author who won't answer questions. This lesson turns your structural eye on the five defects AI code most reliably carries.
Mission tie-in: you'll generate more code with AI, not less. The skill that keeps you in control is the ability to review structure the way you'd review a stranger's pull request — this lesson is that review checklist.
Knowledge: the five recurring defects
AI produces fluent, plausible code — and fluently repeats the same structural defaults:
- God functions and helper sprawl. One function that does everything, or the opposite: twenty micro-helpers with no shape, built to keep each output chunk short.
- Hidden global state. Module-level mutable containers, caches, and singletons that make behavior depend on call order.
- No invariants. Data accepted and mutated freely; invalid states possible by construction.
- Bare exception handling.
except Exceptionwithpass, or errors converted toNone— failure becomes invisible and travels. - Missing seams. Hardcoded I/O, sleep, and environment access inside logic that should be pure and testable.
Treat AI output as legacy code from an unknown author. The discipline that applies is the one Michael Feathers wrote for legacy code: find the seam, lock the behavior with a characterization test, then restructure with confidence.
def process_all(users: list[dict]) -> None:
processed = []
cache = {} # rebuilt every call: dedup never spans batches
for u in users:
if u["email"] in cache:
continue
try:
resp = requests.post("https://api.example.com/notify", json=u)
if resp.status_code != 200:
return # silently stops mid-batch
cache[u["email"]] = True
processed.append(u)
except Exception:
pass # swallows everything
return processed # annotated -> None, yet returns a list on the happy path
Name what's wrong — and this snippet carries nearly every defect on the list: network I/O welded to logic (no seam, so no test runs without a network), a bare except Exception that swallows a typo as readily as a timeout, a mid-loop return that abandons the remaining users and hands back None, a return type that contradicts the annotation, and a cache rebuilt on every call — so the de-duplication it appears to buy never survives a second batch.
The five defects are not random. They follow from what a language model is doing: producing the most plausible continuation given your prompt. The corpus that defines "plausible" is dominated by short, self-contained, illustrative code — tutorials, documentation snippets, question-and-answer posts. That code has properties its authors were right to choose and you are wrong to inherit: no dependency injection, because a snippet has nobody to inject; a defensive try/except, because an example that raises looks broken; module-level state, because a snippet has no lifecycle to hang it on; and every step inline, because a reader wants one screen. Ask for a function and you get an excellent snippet. A snippet in a system is a defect.
Three consequences follow, and each changes how you review.
The defects are correlated, so a checklist works. With human code you need judgment, because the mistakes are idiosyncratic. With generated code the same five shapes recur, which means a fixed list catches most of it — a rare case where a checklist genuinely outperforms attention.
The code is locally correct and globally wrong. Every line survives scrutiny; the problem is the shape, and shape is invisible one line at a time. This is precisely the failure mode line-by-line review is worst at, which is why reviewers who are good at spotting off-by-one errors miss AI structure entirely.
Fluency inverts the trust signal. A junior's messy code triggers care; polished code with docstrings and type hints triggers a skim. The generator produces the surface features that humans use as a proxy for diligence, and it produces them regardless of whether the diligence happened. Your calibration was trained on a world where those two things travelled together, and it no longer holds.
There is also a throughput mechanism worth naming. Generation has become nearly free; review has not. On a team of five that merges thirty per cent more code per week with unchanged review capacity, the binding constraint quietly moves from writing to reviewing, and any process that assumed "code produced ≈ code read carefully" starts failing without announcing it. The response is not to review harder; it is to make structure checkable by a machine (lesson 0010) so human attention is spent on the things a machine cannot judge.
Where it stops applying. This checklist is about structure, not correctness — it will never tell you the discount rule is wrong; only an example-based test written from the business rule can. It is also waste on genuinely disposable code: a one-off analysis script that runs once and is deleted has no future readers to protect. And when the model is given a strong frame, the defect profile changes — a well-specified generation fails in different places, so the list should be revisited rather than recited.
How a senior designer reviews that snippet
They read the signature before the body. def process_all(users: list[dict]) -> None — and the function returns a list. That contradiction takes four seconds to find and tells you the annotation was decoration, not a claim anyone checked. Contracts are where lies are cheapest to detect, which is why they are read first.
Then one question about testability: what would I have to stand up to run this? A network. That single answer settles the seams question without reading the loop: any test of this function is an integration test, so in practice there will be no test.
Then they hunt for silent exits — every path on which the function ends without saying anything. There are two: the return inside the loop and the except Exception: pass. The question that turns those into a business statement is what does the caller see when thirty of five hundred users fail? The answer is None, which is indistinguishable from the result of an empty input. A partial failure has been laundered into a success.
The rejected fix. The reflex is to wrap the call site:
try:
process_all(users)
except Exception:
logger.exception("notification batch failed")
This is treating the symptom. The function still cannot be tested without a network, still abandons the batch mid-loop, and still returns nothing the caller can act on — and now there is a log line that fires only in the cases the function did not swallow, which is the opposite of the ones you need to hear about. A reviewer's tell: if the fix is entirely outside the function, you have decided not to fix it.
The consequence over time. Five hundred users, item twelve raises, four hundred and eighty-eight notifications are never sent, and the batch reports success. Nobody learns anything until a customer asks why they were not told about something three weeks ago — and by then there is no log line, no failure record, and no way to reconstruct which users were skipped, because the only artefact that ever knew was the traceback that pass deleted. The cost of the defect is not the outage; it is that the system destroyed the evidence needed to understand it.
| You see this | What it costs | The move |
|---|---|---|
except Exception: pass | The failure is erased at the moment it was most informative. | Catch the specific error; record it or re-raise. |
| _cache = {} at module scope | Behaviour now depends on import order and call order. | Pass the cache in, or scope it to one object. |
| requests.post inside a rule | No seam: the test needs a network to run at all. | Inject a port; the adapter owns requests. |
| return inside a batch loop | One bad item silently abandons every item after it. | Collect a result per item; report the whole batch. |
| six one-line helpers, each used once | Helper sprawl: shape without structure, nothing named. | Inline them; keep only boundaries that mean something. |
When this is the wrong move
Skip the checklist when the code is genuinely disposable — a scratch analysis, a one-shot migration, a spike you will read once and delete — because structure is an investment in future change and there will be none. Skip it when the generated unit is a leaf: a pure function with no I/O, fully pinned by example tests. There, read the examples against the business rule instead; structure is not the risk. And be honest about the arithmetic on small pieces: if applying the five points costs more than writing the twenty lines yourself, write them yourself. The checklist earns its place on code that will be changed by someone else, later, under time pressure.
- AI writes bad code.
- It writes average code, which is a harder problem. Bad code announces itself and gets reviewed carefully. Average, fluent, well-formatted code with type hints defeats the instinct that something needs a second look — the defect and the camouflage arrive together.
- Better prompting removes the need to review.
- Framing shifts the distribution of defects; it does not truncate the tail, and the tail is where the expensive ones live. What good framing actually buys you is that the remaining defects are in the filling rather than the frame, so a review can be shallow in the right places.
- Tests generated alongside the code prove it works.
- Tests written from the same context tend to assert what the code does, including its bugs — they are characterization tests in a unit test's costume. That makes them genuinely useful as a refactoring safety net and near-worthless as evidence of correctness. Correctness evidence comes from examples derived from the requirement, not from the implementation.
- Careful line-by-line review catches it.
- Line-by-line review is the mode AI output passes most easily, because every individual line is defensible. The defects live in what is absent: the seam that was never created, the invariant nobody stated, the failure path that returns nothing. Review the shape and the contract, then spot-check the lines.
Skill: run the AI review checklist
In the snippet, the mid-loop return is dangerous because:
It silently abandons the batch
It posts the duplicate emails
It forgets the entire cache
It makes the loop slow
Correct. Exactly: one failed notification silently abandons every remaining user — the failure is invisible and partial.
The worst thing about except Exception: pass in that loop is:
It destroys the most informative signal
It catches errors the caller expects
It makes the function return None
It hides a network timeout only
Correct. Right: the traceback at the moment of failure is the highest-information artefact the system will ever produce about that bug, and this line deletes it. Everything downstream becomes guesswork.
Best first step when AI code "works" but scares you:
Refactor it immediately
Test-lock the behavior
Regenerate the code
Rewrite it yourself
Correct. Correct: characterization tests lock the current behavior at a seam, making the restructure safe — the Feathers discipline.
Practice on your own code
Take a function AI generated for you recently. Run the five-point checklist (god function, hidden global state, missing invariants, bare exceptions, missing seams). Before fixing anything, answer the two senior questions: what would I have to stand up to run this, and what does the caller see when part of the work fails? Then fix exactly one defect, with a test that would have failed before.
In the lab: parts of the returns-resolution service were generated rather than designed, and carry these defects verbatim. The phase-1 checkpoint asks you to run the five-point review over one generated module, write a characterization test that pins its current behaviour — including the behaviour you disagree with — and only then change the structure.
Reveal: the snippet's refactor direction
Split into two seams: a pure de-duplication decision the caller owns, and a Notifier port the HTTP call hides behind. Every item gets a recorded outcome, so a partial failure is a fact the caller can act on rather than a silence.
class Notifier(Protocol):
def notify(self, user: dict) -> None: ...
def process_all(users: list[dict], notifier: Notifier) -> BatchResult:
result = BatchResult()
seen: set[str] = set()
for user in users:
if user["email"] in seen:
result.skipped(user)
continue
seen.add(user["email"])
try:
notifier.notify(user)
except NotifyFailed as exc:
result.failed(user, exc)
else:
result.done(user)
return result
Read what changed structurally, not cosmetically. The network moved behind a port, so the test runs offline with a list-backed fake. The except names one error type, so a typo still crashes the way a typo should. No path leaves the loop early, so five hundred users produce five hundred outcomes. And the return type is now a thing the caller can branch on, which is what makes "twelve failed" a fact rather than an absence. Failure semantics get their own treatment in lesson 0014.
- Explain to a junior, in two sentences, why fluent, well-formatted generated code deserves more structural suspicion than messy human code, not less.
- Find one generated function in your own repository this week and name its cost concretely: what a test of it currently requires, and what a partial failure currently tells the caller.
- Predict what breaks in a year if your team keeps merging at the current rate without a machine-checkable structural rule — name the first architectural property that will quietly stop being true.
Your win
You can now review AI output with a five-point checklist instead of vibes — and you've learned the deeper move: treat it as legacy code, lock it with tests, then restructure.
Read and watch deeper
- Working Effectively with Legacy Code, Feathers — ch. 4 "The Seam Model" (where a seam is defined as a place you can change behaviour without editing in that place) and ch. 13, "I Need to Make a Change but I Don't Know What Tests to Write", which is where characterization tests are introduced.
- Google's code review guide — what to look for: a primary source on reviewing design before lines, written for exactly the case where you did not write the code.
- A Philosophy of Software Design, Ousterhout — ch. 16 "Modifying Existing Code": how to improve unfamiliar code without breaking it.
- Clean Code, Martin — ch. 7 "Error Handling": why bare excepts and silent returns are structural defects.
- Watch: ArjanCodes YouTube — search "AI code review" or "AI coding best practices".
- Next: lesson 0010 covers steering AI toward better structure from the start.
- Reference: Patterns — seam / characterization test; glossary — seam, characterization test.
Paste an AI-generated function to your agent-teacher and ask for the five-point review — then argue with its findings.
Source page: lessons/0005-structural-issues-in-ai-code.html
Lesson 0006 · Core Designer · Module 2
Design Around Reasons to Change
Responsibilities are fuzzy; change sources are facts. Structure your modules around the forces that will actually edit them.
Mission tie-in: "design around reasons to change" is the upgrade from lesson 0002's SRP. It turns a principle into a method you can apply before writing a line.
Knowledge: the change source is the unit
Every future edit to your codebase comes from a finite list of forces: tax law changes, a new payment provider, a new report format, a re-branding. Call each one a reason to change. Design says: code that changes for the same reason lives together; code that changes for different reasons lives apart.
Consider an invoice class:
class Invoice:
def __init__(self, lines: list[Line], vat_rate: Decimal) -> None:
self.lines = lines
self.vat_rate = vat_rate
def total(self) -> Money:
subtotal = sum(line.price for line in self.lines)
return Money(subtotal * (1 + self.vat_rate))
def to_html(self) -> str:
rows = "".join(f"<tr><td>{l.name}</td></tr>" for l in self.lines)
return f"<table>{rows}</table>"
def to_json(self) -> dict:
return {"lines": [{"name": l.name, "price": str(l.price)} for l in self.lines]}
Two reasons to change are welded together: how invoices are calculated (tax rules, discounts) and how invoices are rendered (HTML tweaks, new JSON fields for a mobile client). When the mobile client needs a new field, you edit the class that owns the tax calculation — and risk the tax rules.
The stable design separates them:
class Invoice:
def __init__(self, lines: list[Line], vat_rate: Decimal) -> None:
self.lines = lines
self.vat_rate = vat_rate
def total(self) -> Money: # reason: pricing rules
subtotal = sum(line.price for line in self.lines)
return Money(subtotal * (1 + self.vat_rate))
class InvoiceRenderer:
def __init__(self, invoice: Invoice) -> None: ...
def to_html(self) -> str: ... # reason: web presentation
def to_json(self) -> dict: ... # reason: API contract
Now a pricing change edits Invoice and nothing else; a rendering change edits InvoiceRenderer and nothing else. Note what you did not do: you didn't count methods, and you didn't split for symmetry. You named the change sources — the whole method.
"Responsibility" is unfalsifiable. Two competent engineers can argue for an hour about whether rendering is part of an invoice's responsibility, and neither can be shown wrong, because the word has no measurement attached. "Reason to change" is a different kind of claim: it is a statement about the future edits to a file, and its proxy — which files have historically been edited together — is sitting in your version control system right now.
That gives the principle a mechanism. Your module boundaries should match the joint distribution of edits. Two files that change together in most commits have a boundary between them that nobody wanted: every change pays two reviews, two merges, and an ordering constraint. One file whose commits fall into two disjoint clusters — different authors, different motivations, no overlap — has a boundary missing: every change to one cluster puts the other inside its blast radius, and the two groups collide in the same diff.
Both failures cost, and they cost differently. A boundary in the wrong place charges coordination: extra reviewers, release ordering, cross-team scheduling. A missing boundary charges blast radius: unrelated tests, merge conflicts, and ambiguous ownership when something breaks. When you hear a team say "we can't ship X until Y's change lands", you are hearing the first. When you hear "nobody wants to touch that file", you are hearing the second.
Where it stops applying. History only describes the past, and it has three specific failure modes. A greenfield module has no history at all, so any split is a guess — and since it is much easier to split a module that grew too broad than to reunite two that were separated early and evolved incompatible interfaces, the honest default in an unfamiliar domain is fewer, larger modules until evidence arrives. Co-change can also be an artefact of process rather than design: one team on one release train will commit things together whether or not they belong together. And a file that changed for two reasons last year may be about to stop changing for one of them, because that integration is being retired — history tells you what happened, not what is coming.
How a senior designer finds the line
A 250,000-line commerce codebase, four teams. invoice.py is the file everyone complains about.
They start with the co-change data, not the class diagram. git log --name-only --since=1.year over the last thirty commits that touched invoice.py shows two clusters that barely overlap: seven commits also touching tax_tables.py, all authored by the finance-integration team; twenty-two also touching templates/, all authored by the growth team. Two disjoint editor populations on one file. That is the measurement, and it took two minutes.
Then the question: has any single commit needed both? If tax changes and template changes have never landed together, the file is holding two things that have no reason to be in the same room. If they routinely land together, the coupling is real and splitting them would just move the cost into coordination.
Then they reject the split everyone proposes. The reflex refactor is to layer it: InvoiceModel, InvoiceService, InvoiceRepository. This is the most common wrong answer in the industry, and it is wrong for a precise reason: layering slices along mechanism — data, orchestration, storage — while the forces that issue edits run along subject matter. After the layer split, a VAT rate change touches the model (the rate), the service (the calculation order), and the repository (the stored breakdown); a template change touches the service and the model too. The average edit went from one file to three, the review from one team to two, and the file everybody complained about has become three files everybody complains about. The tell: after a split, count the files an average edit touches. If it went up, the axis was wrong.
The split that works puts tax calculation in one module with a finance owner and presentation in another with a growth owner. Now tax pull requests touch one file and need one reviewer who knows tax; template pull requests touch one file and need one reviewer who knows the brand. The two teams stop appearing in each other's diffs, and — the part that actually shows up in delivery metrics — stop waiting on each other's review queues.
The consequence over a year. Before: median invoice pull request touches three files, needs two reviewers from different teams, and waits on whichever is busier. After: one file, one reviewer, same day. Nothing about the business logic changed. The entire improvement came from putting the boundary where the edits already were.
| You see this | What it costs | The move |
|---|---|---|
| git log --format=%s -- billing.py | The commit subjects name this file’s change sources out loud. | Group the subjects; each distinct world is a module. |
| Invoice.to_html() beside Invoice.total() | A branding tweak edits the file that owns the tax rules. | Move rendering out; the calculation stops being touched. |
| one class edited by three teams | Three worlds, one file — every change becomes a negotiation. | Split along the team boundary (lesson 0033). |
| a helpers.py nobody claims | Its change source was never named, so nobody owns its edits. | Ask who edits it and why; that answer is its home. |
When this is the wrong move
Do not design around change sources you have invented. In a domain you have worked in for two weeks, the list of "reasons to change" you can produce is a list of guesses, and each guess that turns out wrong leaves a boundary you now have to maintain and eventually move — which is more expensive than the coupling you were avoiding. Keep it in one module, let it get uncomfortable, and split when the history tells you where. Equally, do not split a file whose two "reasons" are really one reason wearing two hats: a rename that touches both the calculation and the label is one change source, not two, and separating them means every rename becomes two commits. And a module with a single owner, a low change rate, and no cross-team traffic is not worth restructuring however many jobs it appears to do — the arithmetic in lesson 0001 multiplies by a change frequency near zero.
- A reason to change is the same as an actor or a role.
- Actors are a useful proxy — Clean Architecture uses them — but the thing you actually want is co-change. Two stakeholders who always request the same edit at the same time are one reason; one stakeholder who asks for two unrelated kinds of change is two. Ask what arrives together, not who asked.
- Splitting into layers is applying this principle.
- Layering is a different axis, and a legitimate one — it controls dependency direction. But it does not separate change sources, and applied as if it did, it multiplies the number of files each change source touches. Use layers for the arrows; use change sources for the boundaries.
- Every module should have exactly one reason to change.
- The workable target is weaker and more useful: no ordinary edit should have to cross a boundary it did not need to cross. A module with one broad change source that touches many things is fine. A module with two narrow ones that never coincide is not.
- Git history gives you the answer.
- It gives you evidence, which still needs a designer. History cannot see the integration you are about to retire, the regulation arriving next quarter, or the fact that last year's churn was one migration that will never repeat. Read it as a hypothesis generator, then check the hypothesis against what you know is coming.
Skill: name the change sources
The invoice class above should be split because:
It has too many methods
The class is too long
It has two change sources
Its types are too loose
Correct. Exactly: tax changes and presentation changes are different forces that would both edit this file.
A class with many methods and one change source is:
A cohesion failure
Cohesive and correct
A naming violation
A size violation
Correct. Right: method count is irrelevant — the single change source makes the whole class one unit.
Splitting the invoice class into model, service, and repository classes mainly:
Adds files without separating change sources
Separates calculation from presentation cleanly enough
Satisfies SRP by reducing class size
Splits the class along team ownership
Correct. Right: layering slices along mechanism, while edits arrive along subject matter. Tax changes and template changes now both land in all three classes, so the average edit touches more files than before.
Practice on your own code
Pick your most-edited class and run the measurement rather than the intuition: git log --name-only --since=1.year -- <that file>, then group the commits by author and by subject. If they fall into disjoint clusters, you have found a split that would already have paid for itself. If they do not, you have learned something more valuable — the file is fine and your discomfort was about its size.
In the lab: the returns-resolution service ships with a commit history designed to be read this way. The phase-1 checkpoint asks you to derive the change sources of its largest module from that history, propose the boundary, and say explicitly which layered split you are rejecting and why.
Reveal: a worked split
A ReportGenerator that both queries the warehouse and formats PDFs. Edit history: new warehouse schema (twice) and new branding (once). Two worlds. Split into WarehouseReportQuery (owned by the warehouse team's contract) and ReportFormatter (owned by design). Branding changes stop touching query code.
- Explain to a junior, in two sentences, why "one reason to change" is a better test than "one responsibility" — without using either phrase.
- Find one file in your own repository this week whose history splits into two clusters, and name the cost: how many files an average edit touches, and how many reviewers it needs.
- Predict what happens in a year if the two clusters keep sharing a file — who stops touching it, and which change eventually gets made in the wrong place because of that.
Your win
You can now enumerate a module's reasons to change from its edit history — and split along those lines instead of along method counts or symmetry.
Read and watch deeper
- Clean Architecture, Martin — ch. 7 (SRP as "one reason to change") and ch. 8 (Open-Closed: design for extension along the change source).
- A Philosophy of Software Design, Ousterhout — ch. 2 "The Nature of Complexity": complexity defined as "anything that makes change hard".
- Refactoring, Fowler — ch. 3 "Bad Smells in Code": smells are symptoms; change sources are the diagnosis. "Divergent Change" and "Shotgun Surgery" are this lesson's two failure modes, named.
- Your Code as a Crime Scene, Adam Tornhill — the chapters on hotspots and temporal coupling: how to compute the co-change analysis above from a repository, and what the numbers do and do not license you to conclude.
- Watch: ArjanCodes YouTube — search "single responsibility".
- Reference: Design Principles — SRP; and the glossary on reason to change.
Bring a class you edit often to your agent-teacher and co-list its change sources — disagreement is where the insight lives.
Source page: lessons/0006-designing-around-reasons-to-change.html
Lesson 0007 · Core Designer · Module 2
Composition over Inheritance
Inheritance is a fuse: the subclass detonates with the superclass. Composition is a seam. Learn to tell the two apart — and when the fuse is worth keeping.
Mission tie-in: "replace inheritance with composition where it matters" is one of Arjan's seven principles, and the #1 structural fix AI-generated hierarchies usually need.
Knowledge: the fuse and the seam
When FancyLogger extends Logger, every change to Logger — its constructor, its storage, its naming — propagates into FancyLogger. The subclass cannot survive alone, cannot be tested alone, cannot be reused elsewhere. That's the fuse.
The classic failure is subclassing for reuse rather than for polymorphism:
class FileBackedLogger(Logger):
def __init__(self, path: str) -> None:
super().__init__()
self.path = path
self._file = open(path, "a")
def log(self, msg: str) -> None:
super().log(f"[file] {msg}")
self._file.write(f"{msg}\n")
"I need logging, I'll extend the logger" — that's reuse-by-inheritance, and it pays for the fuse: the file-handling lifecycle is now welded to the logger's, and the subclass can't be used with a different base behavior.
Composition says: hold it, delegate to it, decide its life yourself.
class FileBackedLogger:
def __init__(self, sink: Logger, path: str) -> None:
self._sink = sink
self._file = open(path, "a")
def log(self, msg: str) -> None:
self._sink.log(msg)
self._file.write(f"{msg}\n")
Now FileBackedLogger owns its lifecycle, can wrap any logger, and can be tested with a fake sink. The seam is visible: you can see exactly where one behavior ends and the other begins.
Composition is preferred not because it is prettier but because inheritance couples you to the base class's implementation — not merely its interface — through three channels that are invisible from the subclass's source.
Construction order. Subclass state and super().__init__() interleave, so the base can call a method the subclass has overridden before the subclass's own fields exist. The bug appears as an AttributeError in code that looks correct.
The fragile base class problem. A base method is free to change which of its own methods it calls. If it starts implementing one operation in terms of another that a subclass overrode, every subclass's behaviour changes silently. Nothing in the subclass's file changed; nothing in the base's diff mentions the subclass.
A shared namespace. Base and subclass write to one set of attribute names. Adding a field to the base can collide with one a subclass has been using for years, in a package you do not own, discovered at runtime.
Watch the second one in Python, where you can see the whole mechanism in nine lines:
class CountingList(list):
def __init__(self) -> None:
super().__init__()
self.added = 0
def append(self, item) -> None:
self.added += 1
super().append(item)
def extend(self, items) -> None:
self.added += len(items)
super().extend(items)
This counts correctly — and only because CPython's list.extend does not internally call self.append. If it did, every extended item would be counted twice: once by the override and once by the base re-entering it. The subclass's correctness rests on an implementation detail of the base that no documentation promises and no test of yours can defend. That is the whole argument against reuse-by-inheritance, in a language where you can actually watch it happen.
There is also an asymmetry in time. Composition can always be introduced later: wrap the object, delegate the method, nothing outside changes. Inheritance is expensive to remove once callers depend on the subtype relation, because deleting it changes the type of every variable that held one — including in code you do not own. And inheritance makes a behavioural promise (anything true of the base is true of the subclass, which is Liskov substitutability). Reuse-inheritance makes that promise by accident and then breaks it, and the breakage surfaces in callers that never mention the subclass by name.
Where it stops applying. When there is no implementation to borrow — an abstract base class or a Protocol with no state and no method bodies — the three channels do not exist, and inheritance is simply a declaration of intent. When a framework requires it (a Django model, a unittest.TestCase, an Exception), fighting it costs more than it saves and buys nothing. And when the hierarchy is closed and you own all of it — a fixed set of variants modelled as a base and four dataclasses — the fragile-base problem is bounded by a set you can read in one sitting.
How a senior designer reviews a new subclass
A pull request adds RetryingHttpClient(HttpClient).
They look at two things before the logic: what the subclass overrides, and what state it adds. Those two answers classify it. If it overrides methods the base declares and adds no state, it varies — legitimate. If it overrides nothing and calls super() everywhere while adding fields of its own, it borrows — the fuse.
Then the question: if I deleted every method this class overrides, would anything about the base's contract change for its callers? For a varying subclass the answer is yes — the whole point was to change behaviour behind a shared type. For a borrowing subclass the answer is no, and you have just discovered that the inheritance is doing no work except giving you access to the base's methods, which delegation would give you with a visible dependency instead of a hidden one.
Two alternatives rejected. The first is to keep the hierarchy but tighten the base — make its helpers private, document which methods may be overridden. This does not help, because it is precisely the base's public surface that the borrowing subclass wants; you have added rules without removing the coupling. The second is a mixin. Mixins look like composition — small, focused, combinable — and are multiple inheritance: same shared namespace, same fragile base problem, plus method resolution order, so the behaviour now depends on the order the classes are listed in. A mixin is the fuse with a friendlier name.
The consequence over time, at scale. The end state of reuse-by-inheritance is not a bug; it is paralysis. On one platform team, a base class accumulated forty subclasses and a dozen protected helpers. A change to one helper's contract meant reading forty subclasses to find out who depended on the old behaviour, so the team did it once, took two weeks, shipped one regression — and thereafter refused to touch the base at all. The hierarchy stopped being a design and became a liability nobody was allowed to edit: new behaviour got bolted on outside it, and the "shared" code stopped being shared in any useful sense. That is the real cost, and it does not appear in any one commit.
| You see this | What it costs | The move |
|---|---|---|
| super().__init__() then new state | The subclass now depends on the base’s construction order. | Hold the base as a field; own your own lifecycle. |
| a subclass that overrides nothing | It inherits to borrow, not to vary — the fuse with no upside. | Delegate: self._base = Base(). |
| isinstance(x, Base) in the caller | The hierarchy exists for the caller’s branching, not polymorphism. | Give the base one method the caller can call blind. |
| an abstract base with one subclass | Ancestry with nothing to vary, paid for on every read. | Collapse to one class; add the base when case two lands. |
When this is the wrong move
- Framework base classes. If the ORM, the test runner, or the web framework requires a subclass, inherit and move on. The coupling is real, but the alternative is fighting a tool that will win.
- Exception hierarchies.
class PaymentDeclined(PaymentError)is inheritance used exactly right: the "is-a" is the entire feature, because callers catch by type, and there is no implementation being borrowed. - Closed sum types. A base plus four dataclasses representing four known variants gives you exhaustive handling and a shared type. The set is closed and small, so the fragile-base risk is bounded.
- Wide interfaces. If delegating means forwarding twenty methods to add one behaviour, the wrapper is worse than the fuse. That is a real signal — the interface is too wide — but if you cannot narrow it today, inheriting and writing down why is the honest call. Do not reach for
__getattr__forwarding to make the boilerplate go away: it recreates the fuse invisibly and defeats every tool that reads the class.
- Composition over inheritance means never inherit.
- It is a default, not a prohibition. The maxim is about class inheritance for reuse. Abstract base classes, protocols, exception hierarchies, and framework-mandated bases are all fine, and refusing them produces worse code, not purer code.
- Inheritance is safe if the subclass overrides everything.
- Then the base is giving you nothing but a name — no shared implementation, no shared state. That is what a
Protocolor an abstract base is for, and using a concrete class instead means you inherited the implementation's constraints for free and its benefits not at all. - Mixins are a form of composition.
- They are multiple inheritance. They share the namespace, they can be broken by a change in the base, and their behaviour depends on the method resolution order, so adding a mixin can silently change what an existing one does. The visual resemblance to composition is exactly what makes them dangerous.
- Delegation is boilerplate, so it is worse.
- The boilerplate is the dependency made visible. A wrapper with five forwarding methods tells you, precisely, the five things it needs from its collaborator — which is a smaller and more reviewable claim than "everything my base class has, now and in future versions".
Skill: fuse or seam?
Which situation is inheritance for?
Reusing utility behavior
One interface's variants
Sharing convenient fields
Making things testable
Correct. Right: a caller depending on a stable interface while implementations vary is the legitimate polymorphic use.
"I need logging, so I'll extend Logger" is:
Proper polymorphism in use
Reuse done quite right
Reuse with hidden fuse
A pattern against composition
Correct. The real defect: subclassing for reuse fuses two behaviors that change independently.
CountingList counts correctly only because CPython's list.extend:
does not call append itself internally
always calls append for each item
is faster than a Python loop
copies the list before extending it
Correct. Right. If extend were implemented by calling self.append, every item would be counted twice — once by the override and once by the base re-entering it. The subclass's correctness rests on a detail the base never promised.
Practice on your own code
Find a subclass in your codebase (or an AI-generated one) and run the classification test before touching anything: list what it overrides and what state it adds, then ask whether deleting its overrides would change the base's contract for any caller. If it borrows, convert it to composition — hold the base, delegate the method — and notice how much smaller the dependency becomes once it is written out explicitly.
In the lab: the returns-resolution service has a small hierarchy where one subclass exists to borrow rather than to vary. The phase-1 checkpoint asks you to identify which one, convert it to delegation, and show that the resulting class can be exercised in a test without constructing its former base at all.
Reveal: a worked conversion
# a CsvExporter that "is a" DatabaseExporter, reusing its row logic
class CsvExporter(DatabaseExporter): # wrong: borrows, doesn't vary
...
# becomes: CsvExporter holds the database reader and formats its rows
class CsvExporter:
def __init__(self, source: RowSource) -> None: # RowSource = any row provider
self._source = source
- Explain to a junior, in two sentences, why a subclass can break when nothing in its own file changed.
- Find one hierarchy in your own code this week and name its cost: how many classes you would have to read before you could safely change one base method.
- Predict what happens in a year if the base keeps growing — who ends up refusing to edit it, and where the next piece of shared behaviour gets put instead.
Your win
You can now classify every subclass you meet: variation (legitimate) or borrowing (fuse). And you have the mechanical fix — delegation — for the second kind.
Read and watch deeper
- Design Patterns, Gamma et al. — the introduction's "Favor object composition over class inheritance": the original statement.
- Head First Design Patterns, Freeman et al. — ch. 1: the duck example that made this principle famous.
- Fluent Python, Ramalho — "Inheritance: For Better or for Worse" (ch. 14 in the 2nd edition, ch. 12 in the 1st): subclassing built-in types, why
CountingList-style overrides are unreliable, and when a nominal hierarchy is still the right tool. - Barbara Liskov & Jeannette Wing, "A Behavioral Notion of Subtyping" (ACM TOPLAS, 1994) — the primary source for the promise inheritance makes on your behalf, and therefore for what it means to break it.
- Watch: ArjanCodes YouTube — search "composition over inheritance".
- Reference: Design Principles — composition over inheritance.
Unsure whether your hierarchy is legitimate variation? Ask your agent-teacher to interrogate the "is-a" claim with you.
Source page: lessons/0007-composition-over-inheritance.html
Lesson 0008 · Core Designer · Module 2
Strategy, Adapter, and Facade, Intentionally
Lesson 0004 taught you to notice Strategy emerging. Here you learn to reach for it — and for its two structural cousins — on purpose, with the problem first.
Mission tie-in: "use patterns intentionally" means naming the problem you have before naming the pattern that fixes it. That order is the whole difference between designing and decorating.
Knowledge: three problems, three shapes
Strategy — problem: one behavior varies; the context should not. Shape: interface + implementations + delegation. You saw it emerging in lesson 0004; here you apply it before the branch chain forms, because the variation is already proven.
Adapter — problem: your code needs interface A; a service speaks interface B, and B is not yours to change. Shape: a wrapper that implements A and translates into B.
class PaymentGateway(Protocol): # your interface: named for the need
def charge(self, amount: Money, token: str) -> str: ...
class StripeSdkAdapter: # adapts a third-party SDK to it
def __init__(self, client: StripeSDK) -> None:
self._client = client
def charge(self, amount: Money, token: str) -> str:
result = self._client.payment_intents.create(
amount=int(amount.cents), currency=amount.currency, token=token
)
return result.id
Note the naming: the interface is PaymentGateway, not StripeGateway. An adapter that names itself after the thing it adapts has already let the vendor define your vocabulary — the arrow you will straighten in lesson 0009.
Facade — problem: clients must orchestrate several subsystems to do one recurring thing. Shape: one class with a few coarse methods that drive the subsystems.
class CheckoutFacade:
def __init__(self, inventory, payments, shipping, email) -> None: ...
def place_order(self, cart: Cart, payment_token: str) -> Order:
self._inventory.reserve(cart)
charge_id = self._payments.charge(cart.total, payment_token)
order = self._shipping.create_shipment(cart)
self._email.send_confirmation(cart.customer, order)
return order
Three patterns, three problems: vary a behavior, translate an interface, simplify a ritual. If you cannot name which problem you have, applying any of them is decoration.
† The test of intentional use: the pattern disappears from the problem. A Strategy means callers never mention variants; an Adapter means callers never see the SDK; a Facade means callers never see the subsystems.A pattern is not a shape you like; it is a decision about where variation lives and who absorbs a change. Each of these three answers a different question about one boundary, which is why "which pattern?" is answerable at all:
- Strategy — the behaviour varies. Change is absorbed by adding an implementation; nothing existing is edited.
- Adapter — the vocabulary varies. Change is absorbed at one translation point; the rest of the system keeps speaking your language.
- Facade — the sequence varies. Change is absorbed by the owner of the ritual; callers keep making one call.
Naming the shape is not decoration either. Its practical value is prediction: a reviewer who recognises a Strategy knows, without reading the code, where the next variant goes and what will not be touched. That predictability is most of what "readable architecture" means in a team of more than three people.
Every one of the three costs the same thing: one level of indirection, paid on every future read. So the test is not "does this apply?" — all three usually could — but does it remove a repeated decision? A Facade over a single call removes nothing and adds a hop. An Adapter around an SDK you call in one place removes nothing today; what it buys is optionality, and optionality has a price you should state rather than assume.
Where each stops applying. A Facade that accumulates every workflow in the system becomes the god object it was meant to prevent — one facade owns one ritual. An Adapter whose interface is the SDK's interface with different capitalisation has adapted nothing; the test is whether your method names would still make sense if the vendor disappeared tomorrow. And a Strategy degenerates when variants need to combine, or when selection requires more than one key: a two-dimensional variation produces a matrix of classes, and the honest answer there is a rules table or a pipeline, not more classes.
How a senior designer chooses in a real review
A payments integration. Three engineers, a six-week window, one vendor live and a second in a commercial conversation that may or may not conclude.
They start by counting call sites, not by naming patterns. The SDK is called in six places across three modules. That number is the input to everything else: it is the size of the edit if the vendor changes, and the size of the indirection tax if it does not.
Then they price both errors. Wrong to adapt: one extra file, about forty lines, and one hop of indirection on six call sites — recoverable in an afternoon. Wrong not to adapt: a vendor migration touching six call sites in three modules, with no test that can run without a network, under whatever deadline the commercial decision arrives with. The costs are wildly asymmetric, so the adapter goes in — not because adapters are good, but because the mistake it prevents is the expensive one.
Then they reject the version of this decision that has no brakes. The same argument, applied uniformly, produces a port for the logger, a port for datetime.now(), and a port for the templating library. The rule that stops it is worth memorising: adapt what you might have to replace; stub what you must control in a test; leave everything else alone. A clock is the second kind — you do not need a ClockPort hierarchy, you need to pass a function. A logger is usually the third kind: you are not going to replace it, and if you do, the migration is a configuration change.
Then the facade gets the same scrutiny. The proposed CheckoutFacade also handles refunds and subscription renewals. Three rituals, one class — and that class becomes the file every team edits, which is the merge-conflict hot-spot from lesson 0006 arriving by a different road. One ritual per facade. If two rituals share steps, share the steps, not the facade.
The consequence, eighteen months on. The payments port did its job: the vendor change was eleven days of work, contained to one adapter and its tests, with the domain untouched. The logging port that nobody needed cost four hours to write, and then cost every new joiner an afternoon of confusion about why log.info was not log.info. Both decisions came from the same principle. Only one of them was priced.
| The problem you have | What it costs | The shape that fits |
|---|---|---|
| the SDK is called in six files | Vendor churn lands in six places, each a separate edit. | Adapter: one wrapper, one place the vendor can change. |
| four calls that always run together | The ritual is copied per caller and drifts apart. | Facade: one coarse method that owns the sequence. |
| the if-chain grew a fifth arm | A variation point with a growing list of branches. | Strategy: one implementation per arm. |
| class OrderManager wrapping one call | A pattern applied with no problem behind it. | Delete the wrapper; call the thing directly. |
When this is the wrong move
Skip the adapter when there is one call site, no realistic replacement, and a test that already runs without the dependency — the wrapper is then pure ceremony, and ceremony is what gives patterns a bad name. Skip the facade when the ritual happens in exactly one place: a sequence with one caller is a function, and wrapping it in a class hides nothing from nobody. Skip the strategy when there are two stable arms. And treat a facade in a published library differently from one inside your own system: there, freezing a sequence behind one method is a compatibility commitment your users will hold you to for years, so the convenience you are offering is also a constraint you are accepting.
- Adapter and Facade are the same thing, since both wrap something.
- They differ in what they change. An Adapter changes the vocabulary of one thing so it fits an interface you already have. A Facade changes the granularity of several things so one call replaces four. You can tell them apart by asking what would break: rename the vendor's methods and the adapter changes; reorder the steps and the facade changes.
- An adapter should expose the SDK's full capability.
- An adapter that mirrors the SDK method for method has adapted nothing — it is the SDK with your naming convention, and swapping vendors still rewrites every caller. The port should be shaped by what the caller needs, which is usually three methods where the vendor offers forty.
- Patterns add complexity, so a codebase with fewer of them is simpler.
- The complexity was already there, in the form of a decision repeated at every call site. A pattern names it and puts it in one place. What genuinely adds complexity is a pattern with no repeated decision behind it — and the way to tell the difference is to point at the repetition before you point at the pattern.
- You choose one of the three.
- They are orthogonal and frequently stack. A facade routinely selects a strategy and calls two adapters. "Which pattern is this?" is the wrong question when the honest answer is "three, at three different boundaries".
Skill: match the problem to the pattern
Your code calls a legacy SOAP service whose interface you can't change. Which pattern?
A strategy pattern
An adapter pattern
A facade pattern
Ports & Adapters
Correct. Right: an Adapter implements your interface and translates calls into the SOAP service's language.
Every order triggers reserve, charge, ship, email in sequence. Which pattern?
An adapter
A facade
A strategy
A bridge
Correct. Exactly: one coarse method hides a four-step ritual from every caller.
You call one payment SDK from exactly one place and will not change vendors. An adapter is:
Justified only if tests need it
Always right, because vendors always change
Wrong, because adapters add needless indirection
Required, because domains must own interfaces
Correct. Right: with one call site and no realistic replacement, the only remaining reason is control in a test. If you can already test without the SDK, the wrapper buys optionality you have priced at zero — name the reason or skip it.
Practice on your own code
Find one recurring ritual (three or more lines repeated in several callers) and one interface mismatch in your codebase. Before applying anything, price both errors the way the review above does: what does it cost if you wrap it and never needed to, and what does it cost if you do not and later must? Then apply a Facade to the ritual and an Adapter to the mismatch. If you find neither, say so out loud — refusing to apply a pattern is the same discipline as applying one.
In the lab: the returns-resolution service contains all three problems — a growing branch over resolution outcomes, a third-party carrier client called from several places, and a four-step ritual copied into two callers. The phase-1 checkpoint asks you to label each one with the problem it actually is before naming any pattern, and to defend one case where the right answer is to do nothing.
Reveal: judgment on a tricky case
An EmailSender wraps both an SMTP library and a template system. Two subsystems, one recurring job ("send a templated email to a user"). That's a Facade — but with one eye on lesson 0009: the facade is also the natural port the domain will depend on.
- Explain the difference between an Adapter and a Facade to a junior in two sentences, using your own codebase as the example.
- Find one wrapper in your own code this week that has no repeated decision behind it, and name its cost: how many readers have paid a hop for it, and what it has protected you from so far.
- Predict what happens in a year to a facade that keeps acquiring workflows — which team ends up owning it, and what the merge queue looks like.
Your win
You can now state the problem first — varying behavior, foreign interface, repeated ritual — and reach for exactly one of the three shapes, for a reason you can defend in review.
Read and watch deeper
- Design Patterns, Gamma et al. — ch. 4 (Adapter, Facade) and ch. 5 (Strategy): the canonical intent statements.
- Head First Design Patterns, Freeman et al. — ch. 1 (Strategy) and ch. 7 (Adapter & Facade): problem-first teaching.
- Refactoring Guru — the intent/fit framing for all three.
- A Philosophy of Software Design, Ousterhout — ch. 6 "General-Purpose Modules are Deeper" and ch. 4 on interface versus implementation: the argument for shaping a port by what the caller needs rather than by what the supplier offers.
- Watch: ArjanCodes YouTube — search "design patterns python" for the channel's walkthroughs.
- Reference: Patterns Quick Reference — the “find the pattern by the problem” table, plus Decorator, the fourth structural shape this lesson's trio leaves out.
Describe a messy integration to your agent-teacher and ask which of the three problems it is — before either of you names a pattern.
Source page: lessons/0008-strategy-adapter-facade.html
Lesson 0009 · Core Designer · Module 2
Controlling Dependency Direction
The most important arrows in your architecture point at the domain. Ports & Adapters is the mechanism for drawing them correctly — this lesson is the drawing lesson.
Mission tie-in: dependency direction is what makes the domain your asset. Every later phase builds on this: repositories, infrastructure isolation, event-driven systems.
Knowledge: the arrow points inward
In a well-directed system, the domain — the rules that are true regardless of technology — depends on nothing. The world (databases, APIs, files, clocks) depends on the domain. The domain declares ports: the interfaces it needs. The world supplies adapters: implementations of those ports.
# the domain defines the port it needs:
class PaymentGateway(Protocol):
def charge(self, amount: Money, token: str) -> str: ...
class CheckoutService:
def __init__(self, gateway: PaymentGateway, orders: OrderRepository) -> None:
self._gateway = gateway
self._orders = orders
def checkout(self, cart: Cart, token: str) -> Order:
charge_id = self._gateway.charge(cart.total, token)
order = Order.from_cart(cart, charge_id)
self._orders.save(order)
return order
Now the adapter, living outside the domain:
# infrastructure provides the adapter:
class StripeAdapter:
def __init__(self, client: StripeSDK) -> None:
self._client = client
def charge(self, amount: Money, token: str) -> str:
return self._client.payment_intents.create(...).id
Who owns the interface? The domain does. That single fact is the whole pattern. If the payment SDK defines what your checkout logic looks like, the arrow points the wrong way.
Test it: swap Stripe for a fake adapter and run checkout with no network, no SDK, no mocks — the domain never knows. The port made that possible.
† Where do ports live? At the application boundary, not between every pair of classes. Ports answer "what does the domain need from the world?" — not "what interface should this class have?"Dependency direction decides whose calendar you are on. Code that depends on X has to be revisited when X changes. Your business rules change on your company's schedule; a payment SDK, a cloud API, and an ORM change on somebody else's, announced by a release note you did not read. Pointing the arrows inward means the parts you control absorb the parts you do not — at one place per dependency, instead of everywhere the dependency is mentioned.
The second mechanism is subtler and more valuable: inversion changes who shapes the interface. An interface defined by the supplier is as wide as the supplier's feature list — forty methods, because forty customers wanted different things. An interface defined by the consumer is as wide as the consumer's need, which is usually three. That difference is not aesthetic. A three-method port is something a person can hold in their head, fake in four lines, and reason about completely; a forty-method interface is a second system to learn.
The third is that testability is a consequence, not a goal. A test is just another adapter. If the domain can be driven by a fake, it has no hidden dependencies; if it cannot, it does. This makes test difficulty the cheapest measuring instrument you have for dependency direction — you do not need a diagram or a tool, you need to try to write the test and notice what it demands.
Where it stops applying. This costs a level of indirection and a composition root, and in a five-hundred-line service whose "domain" is a database schema with validation, that is ceremony protecting nothing (see lesson 0015). It also breaks down when the port would have to hide something that genuinely cannot be hidden — transactions, streaming, pagination, or a query language. A repository port that pretends the database has no way to express "the fifty most recent, joined to their lines" ends up either re-implementing a query language badly or leaking one through a string. And ports belong at the application boundary, not between every pair of classes: an interface defined next to its single implementation, in the same package, protects nothing and is not a port however much it looks like one.
How a senior designer audits an existing hexagon
A team whose README says "hexagonal architecture" and whose codebase has fourteen ports.
The first thing they check is not the arrows but the implementations. For each port: how many classes implement it, counting the test fake? A port with two or more is doing work. A port with exactly one implementation and no fake is unpaid indirection — it obeys the letter of the pattern and buys nothing, and there are usually four or five of them.
The question they ask per port: name the thing on the other side you might have to replace, or the test that needs the seam. If neither answer exists, the port exists because the pattern was applied, not because a problem was solved. Deleting it is a legitimate outcome of an architecture review, and saying so is what stops a good idea from becoming a ritual.
Then the interesting case. One port is ReportRepository, and its single method is:
class ReportRepository(Protocol):
def run(self, sql: str) -> list[dict]: ...
Every dependency arrow points the right way. The domain imports no database driver. And the boundary is gone anyway, because the database's language crossed it inside a string: the domain is now writing SQL, and swapping the adapter would require rewriting every caller. This is the case worth memorising — you can satisfy the import graph and still lose the boundary, because ports are judged by vocabulary, not by the shape of the imports.
The alternative they reject. The obvious repair is to add a method to the port for each report: top_customers(), refunds_by_week(), and so on. Follow that and in a year the port has forty methods, every one of which is a query with a name — the ORM, reimplemented, with a worse query planner and no composability. The tell: the interface grows one method per use case and never stabilises.
The call they make instead is to stop pretending reporting is domain logic. Reads that answer questions — dashboards, exports, analytics — are given their own module that talks to the database directly and returns plain rows, while the port stays for the transactional domain where invariants actually live. This is not a compromise of the pattern; it is the recognition that the pattern protects rules, and a report has none.
The consequence, measured. While every new report went through the domain port, it needed a change to the interface, a change to the adapter, and a review from the domain owner: median lead time nine days for something an analyst could describe in a sentence. After the read path was split out, the same work was one module and one reviewer, and shipped the same week. The ports that remained got taken seriously again, because they were no longer in the way of work they had nothing to do with.
| You see this | What it costs | The move |
|---|---|---|
| import stripe inside domain/ | The vendor now defines what your checkout means. | Declare the port in the domain; the adapter imports the SDK. |
| a domain method taking Session | Storage vocabulary has crossed the boundary inward. | Take domain arguments; the adapter keeps the session. |
| a domain test that patches requests | The domain performs I/O, so the test must fake a network. | Inject a fake port; delete the HTTP mock entirely. |
| the Protocol defined beside its adapter | The port lives outside the domain — the arrow is reversed. | Move the port into the package that needs it. |
When this is the wrong move
Do not build a hexagon around a service whose domain is its schema. If the business rules amount to "validate these fields and store the row", the ports are ceremony and the composition root is a second thing to maintain; a straightforward layered service is honest and cheaper. Do not build a port for something you cannot actually replace — your language runtime, your cloud provider's identity model, the framework your whole company standardised on. The insurance premium is real and the claim will never be filed. And do not force read-heavy paths through a domain port: hiding a query language costs more than it protects when there are no invariants on the other side. Rules go through the port; reports go around it.
- Any interface is a port.
- A port is an interface owned by the consumer, at an application boundary. An interface declared next to its only implementation, in the same package, is a naming convention. The ownership and the location are the whole content of the idea.
- Ports and Adapters is a folder structure.
- It is a rule about arrows. Folders are a way of making violations visible, and an import-contract check makes them fail the build — but you can have perfect folders and a domain full of vendor vocabulary, which is worse than untidy folders and a clean dependency direction.
- The domain must depend on nothing at all.
- It must depend on nothing volatile.
Decimal,datetime, and your own value objects are fine, and purity for its own sake produces a domain that cannot express dates. The goal is insulation from other people's release schedules, not from the standard library. - Using a repository interface means the domain is decoupled from the database.
- Not if the interface speaks the database's language. Sessions, query objects, lazily-loaded relations and raw SQL strings in the signature all mean the boundary is decorative. Read your port's method signatures and ask whether they would still make sense if the storage were a file.
Skill: which way is the arrow?
In Ports & Adapters, the port interface is owned by:
The domain core
The vendor SDK
The adapter layer
The framework itself
Correct. Right: the domain declares what it needs; adapters conform to the domain, never the reverse.
A ReportRepository port whose only method takes a SQL string is:
an interface that leaks its vocabulary
a correct port with one method
a violation of the dependency rule
a deep module hiding the database
Correct. Right. The import graph is clean, but the database's language crossed the boundary inside a string, so the domain is writing SQL. Ports are judged by vocabulary, not by the direction of the import.
The checkout domain can run against a fake gateway because:
It builds its gateway
It receives its gateway
It avoids all I/O
Built only for testing
Correct. Exactly: dependency injection at the boundary lets any adapter implementing the port be plugged in.
Practice on your own code
Pick one external thing your code touches (a database, an API, a filesystem). Draw the arrow: does your domain's shape follow the external thing, or does the external thing conform to your domain? If the former, introduce a port — even if only for that one dependency. Then run the audit in the other direction: for every interface you already call a port, name the second implementation or the test that needs it. Any port that fails that question is a candidate for deletion, and deleting it is as much a win as adding one.
In the lab: the returns-resolution service imports a carrier client and a payments client directly inside its decision logic. The phase-1 checkpoint asks you to introduce one port shaped by what the decision actually needs — not by what the vendor offers — and to prove it by running the resolution rules in a test with no network and no mocking library.
Reveal: an arrow audit
A metrics module calls boto3 directly inside a record_event function. The arrow points at AWS. Port: MetricsSink.record(event). Adapter: CloudWatchSink. Tests now run with an in-memory sink; the domain's "record an event" meaning is no longer AWS's to define.
- Explain to a junior, in two sentences, why "the domain owns the interface" is a statement about release schedules rather than about tidiness.
- Find one dependency in your own code this week whose arrow points outward, and name the cost: what a test of the surrounding logic currently requires, and how many files a vendor change would touch.
- Predict what happens in a year if a vendor's vocabulary keeps spreading inward — which word from their documentation ends up in your domain model's method names, and who will have to explain it to a new hire.
Your win
You can audit any dependency for arrow direction, and you know the one rule that decides it: the domain owns the interface. The full infrastructure application comes in lesson 0023.
Read and watch deeper
- "Hexagonal Architecture", Alistair Cockburn — the original statement of ports and adapters.
- Architecture Patterns with Python, Percival & Gregory — ch. 2 "Repository Pattern" and ch. 3, "A Brief Interlude: On Coupling and Abstractions": the Python working version of this lesson, including the fake repository that proves the seam.
- Clean Architecture, Martin — ch. 17–18 on drawing boundaries and their anatomy, and ch. 22, where the dependency rule is stated in its strongest form.
- import-linter — the tool that turns this lesson's arrow into a build failure. A rule your CI enforces outlives the review that agreed on it (lesson 0022).
- Watch: ArjanCodes YouTube — search "ports and adapters" or "hexagonal architecture".
- Reference: Glossary — port, adapter, dependency direction.
Map your project's dependency arrows with your agent-teacher — let it push back on where you claim the boundary is.
Source page: lessons/0009-ports-and-adapters.html
Lesson 0010 · Core Designer · Module 2
Guiding AI Toward Better Structure
Lesson 0005 taught you to review AI output. This lesson teaches you to steer it before it writes: specify the seams, then let the AI fill them in.
Mission tie-in: "guide AI toward better structure instead of patching bad outputs" — the exact skill that separates developers who stay in control from developers whose codebase becomes the AI's drafting table.
Knowledge: you are the architect, AI is the drafter
A drafter draws what the architect specifies. Ask an AI to "build a checkout" and you will get a god function with SDK calls inside — because you specified a feature, not a structure. Ask it to implement a structure you already designed and the output inherits your design.
The recipe — specify the seams first:
- Name the boundary. Which parts are domain, which are infrastructure? State it in the prompt: "domain code must not import the SDK."
- Declare the ports. Give the interfaces:
PaymentGateway,OrderRepository. "Implement the adapter, not the port." - State the invariants. "An order can never be confirmed twice." AI will encode what you tell it to protect.
- Ask for the tests. "Write the tests first, with an in-memory fake adapter." The tests document the structure as much as they verify it.
# prompt shape (paraphrased):
# Implement checkout for the CheckoutService below. Do NOT modify this
# class or the PaymentGateway port. Domain code must not import Stripe.
# Write StripeAdapter in infrastructure/, plus tests using a FakeGateway.
class PaymentGateway(Protocol):
def charge(self, amount: Money, token: str) -> str: ...
class CheckoutService:
def __init__(self, gateway: PaymentGateway, orders: OrderRepository): ...
Now the AI's freedom is bounded by your structure. Bad output is still possible — AI loves a helper explosion — but the defects are now in the filling, not the frame.
† The review loop stays: even with a boundary spec, run the five-point checklist from lesson 0005 on the result. Steering reduces defects; it doesn't eliminate them.Everything you leave unspecified is filled in with the statistical default of the model's corpus, which is the tutorial shape from lesson 0005. That single fact tells you where your leverage is — and where it is not.
It is not in adjectives. "Write clean, maintainable, well-structured code" cannot steer anything, because it does not select between candidate outputs: the model already believes its output is clean, and so does the next one you ask. Leverage lives in constraints that are checkable — a type signature it must not alter, an import that must not appear, a test that must pass. Checkable constraints do two jobs at once. They narrow generation, and they let you verify the result without reading every line, which matters because review, not generation, is the resource you are short of.
The deeper asymmetry is about time. A structural defect is paid at change time, months after the code was written, by whoever is on call. The generator is optimising for plausibility now and has no stake in the next twelve months; you are the only party in the loop that does. That is the entire reason the human owns the structure and delegates the filling — not because AI writes worse lines, but because nobody else in the transaction is holding the long end of the cost.
It is also worth being precise about what a frame improves. Specifying structure mostly reduces variance, not the average line. You get fewer catastrophic shapes — the god function with the SDK inside, the module-level cache — rather than uniformly better code. That is exactly the improvement you want from a component you cannot supervise line by line.
Where it stops applying. Over-specification destroys the thing AI is genuinely good at: exploring an unfamiliar API, generating a large volume of mechanical code, showing you three shapes you had not considered. For exploratory work a tight frame produces a confident, well-structured answer to the wrong question, which is harder to detect than a mess. And a frame you have not thought through is worse than no frame at all: the model will implement your bad boundary faithfully, at speed, in enough places that moving it later becomes a project.
How a senior designer writes the brief
The task: sync inventory from a supplier API, to be generated rather than hand-written.
They start from what they are unwilling to review. Not "what should this code do" but which mistakes would I fail to notice in a diff? Three answers: dependency direction, because an import inside a new file looks like every other import; error semantics under partial failure, because the happy path is what a reviewer reads; and whether the tests actually exercise the seam or quietly patch around it. Those three become the specification. Everything else — naming, HTTP details, retry timings — is left free, because a wrong choice there is visible in review and cheap to change.
The alternative they reject is the forty-line prompt. It dictates naming conventions, file layout to the character, docstring style, and the logging format, and it reads like rigour. In practice it does two harmful things: it buries the three constraints that matter among thirty that do not, and it spends the model's attention on compliance with cosmetics. The better artefact is smaller and harder — the port pasted in as literal code, one forbidden import, one test that must pass. Three sentences you could check mechanically beat forty you could not.
Then the move that outlives the session. A prompt is gone when the window closes; the next developer, and the next generation, know nothing about it. So the durable form of "domain must not import the SDK" is not a sentence in a chat — it is an import contract in CI:
# architecture test: the rule survives the conversation that agreed it
FORBIDDEN = {"stripe", "requests", "boto3"}
def test_domain_imports_no_vendors() -> None:
for module in walk_modules("app/domain"):
assert not FORBIDDEN & imports_of(module), module
Now the constraint is enforced by something that does not get tired on a Friday afternoon, and it applies equally to code written by a person, by a model, or by a person in a hurry using a model. This is the fitness-function idea from lesson 0022, aimed at the specific problem that generated code arrives faster than review can inspect it.
The consequence at team scale. Take a team of five where roughly a third of new code is generated. Without a checkable frame, structural defects arrive faster than review removes them; nothing dramatic happens in any given week, and within a quarter the architecture the team believes it has and the architecture in the repository have quietly separated. With three machine-checked constraints, drift is caught by a build, not by a reviewer's diligence — and the reviewer's attention goes where a machine cannot help: whether the business rule is right at all.
| Put this in the prompt | What it pins down | What you get without it |
|---|---|---|
| “domain/ must not import stripe” | Dependency direction (lesson 0009). | SDK calls inside the business rule. |
| “implement the adapter, not the port” | The interface stays yours. | A port quietly reshaped to match the SDK. |
| “an order cannot be confirmed twice” | The invariant lives in the object. | A status flag every caller must remember. |
| “tests first, with an in-memory fake” | The seam is proven by a test. | Tests that patch requests and freeze the defect. |
| “no parameters beyond this case” | Speculative generality is blocked. | A configurable framework for one use. |
When this is the wrong move
Do not frame exploratory work. When the question is "what would this look like using library X", a specification pre-commits you to a design you are still trying to evaluate, and you get a fluent answer that forecloses the alternatives you were asking about. Do not specify a boundary you have not earned: if you do not yet know where the seam belongs, saying it confidently produces a guess embedded in twenty files rather than in one. Do not bother for genuinely mechanical output — a data migration, a batch of fixtures, a translation of a schema — where structure is irrelevant and correctness is checked by running it. And apply the arithmetic honestly: for twenty lines, writing the specification costs more than writing the code, so write the code.
- A good enough prompt removes the need to review.
- Framing shifts where the defects land — into the filling rather than the frame — which lets a review be shallow in the right places. It does not empty the tail, and the tail is where the expensive defects live. Steering and reviewing are complements, not substitutes.
- Telling it to follow SOLID or write clean code works.
- Those are adjectives with a bibliography. They do not discriminate between candidate outputs, because every output already claims them. Ask for the consequence instead: which module may import what, which invariant the object must refuse to break, which test must pass.
- More context always helps.
- More context improves convention-matching and API usage, and it also lets the model reproduce your existing defects with confidence and at scale. If your codebase already swallows exceptions, a well-contextualised model will swallow them in your house style. Context is a mirror, not a filter.
- The specification is the prompt.
- A prompt lasts one session. The durable artefact is a test, a lint rule, or an import contract — something the next person and the next generation cannot bypass without noticing. If a structural rule matters enough to say twice, it matters enough to encode once.
Skill: prompt with the design first
Which kind of instruction most reliably changes the structure of generated code?
A rule a test can check
An adjective describing the desired quality
A longer prompt with more context
A request to follow SOLID principles
Correct. Right: "domain must not import stripe" is falsifiable — by the model, by you, and by CI. Falsifiable constraints are also the only kind that outlive the conversation that produced them.
"Domain code must not import the SDK" is an instruction about:
Fail-fast behavior
Dependency direction
Configuration style
Naming conventions
Correct. Exactly: it pins the dependency arrow so the AI keeps technology outside the domain.
Asking AI to write tests with a fake adapter serves to:
Document the structure
Speed up production
Replace the domain
Cut review burden
Correct. Right: tests lock the seam and demonstrate the structure, so the AI reproduces it instead of bypassing it.
Practice on your own code
Pick a small feature you'd normally hand to AI. Before writing the brief, answer the senior question: which mistakes here would I fail to notice in a diff? Specify only those, as constraints something could check. Then generate, and run the five-point checklist from lesson 0005: how many defects did the spec prevent, and which one of your three constraints could you turn into a test that outlives the session?
In the lab: the returns-resolution service is the place to try this end to end. The phase-1 checkpoint asks you to write a boundary brief for one new capability, generate against it, review the result with the five-point checklist, and then convert your most important constraint into an executable check that fails the build if a later change breaks it.
Reveal: a before/after comparison
Before: "Write a function to sync inventory from the supplier API." Result: one function doing HTTP, parsing, merging, and saving, with requests imported throughout.
After: "Implement InventorySyncService using the SupplierFeed port below; adapters in infrastructure/; tests with a canned feed." Result: domain service with two injectable ports, thin adapter, and tests that run offline. Same feature, opposite structure.
- Explain to a junior, in two sentences, why "write clean code" is a useless instruction and "domain must not import stripe" is a useful one.
- Take one structural rule your team currently enforces by review this week and name its cost: how many times it has been said in pull requests, and how many times it has still slipped through.
- Predict what your architecture looks like in a year if generated code keeps arriving faster than it can be reviewed, and no rule is machine-checked. Name the first property that stops being true.
Your win
You can now treat AI as a drafter with a brief: structure specified up front, defects reviewed after. The combination is the Core Designer's AI workflow.
Read and watch deeper
- A Philosophy of Software Design, Ousterhout — ch. 3 "Working Code Isn't Enough" (tactical versus strategic programming: a generator is a tactical programmer with unlimited stamina) and ch. 16 "Modifying Existing Code".
- Architecture Patterns with Python, Percival & Gregory — ch. 2–4: the seams (repository, service layer) this lesson's prompts encode.
- Study the official sdm example code — structure worth imitating in your prompts.
- Watch: ArjanCodes YouTube — search "AI code generation" for Arjan's guidance videos.
- Next: lesson 0011 starts Module 3, protecting the domain core.
- Reference: Patterns — Ports & Adapters and composition root: the two shapes worth naming in every prompt.
Draft a boundary spec with your agent-teacher for a feature you're about to generate — then compare the spec you'd have written alone.
Source page: lessons/0010-guiding-ai-toward-better-structure.html
Lesson 0011 · Core Designer · Module 3
Modeling Domain Invariants Directly
An invariant is a rule the domain cannot break. If your callers must remember to respect it, you don't have an invariant — you have a hope.
Mission tie-in: "make invalid states impossible" is the Core Designer's third pillar. It's also the highest-value thing to state to an AI before generation (lesson 0010).
Knowledge: the rule lives where the data lives
Take the rule "you cannot withdraw more than your balance." Two ways to build it:
# way one: everyone remembers
def transfer(from_acct: Account, to_acct: Account, amount: Money) -> None:
if amount > from_acct.balance: # caller-enforced
raise InsufficientFunds()
from_acct.balance -= amount
to_acct.balance += amount
# way two: the domain refuses
class Account:
def __init__(self, balance: Money) -> None:
self._balance = balance # private: no caller can assign it
@property
def balance(self) -> Money:
return self._balance # readable, never writable
def withdraw(self, amount: Money) -> None:
if amount > self._balance:
raise InsufficientFunds() # the object itself refuses
self._balance -= amount
def deposit(self, amount: Money) -> None:
self._balance += amount
def transfer(from_acct: Account, to_acct: Account, amount: Money) -> None:
from_acct.withdraw(amount) # no caller judgment needed
to_acct.deposit(amount)
In the first version, the rule lives in one caller — the next caller who forgets the check creates a negative balance, silently. In the second, the rule lives in the object that owns the state; it cannot be violated by any caller, present or future, human or AI.
The privacy is not decoration. Leave balance as a public attribute and acct.balance -= amount still walks straight past withdraw — an invariant is only as strong as the widest door into the state it guards. One leading underscore and a read-only property close that door.
An invariant is a claim about every reachable state, so its enforcement cost is set by how many paths can reach that state. Caller-side checking makes the rule a conjunction of n independent promises, and n grows with the codebase while the rule stays fixed: every new endpoint, job, migration script and AI-generated helper adds another promise that can be broken alone. Moving the check inside the object that owns the data collapses n to one, because the only way to reach the state is through the door you control. That is the whole mechanism — it is a counting argument, not a style preference.
Which tells you exactly when it stops working. The move only pays if one object can see everything the rule needs. "No two customers share an email" is not enforceable inside a Customer: the rule ranges over a set the object cannot see, so it belongs to whatever owns the set — a repository query in a transaction, or a unique index. "Refunds over €500 need approval" looks like an invariant but is policy: it changes on a business calendar, and freezing it in a constructor turns a pricing decision into a domain redeploy. And an invariant must hold for every state the object can be in, including partially built ones — if your object has a legitimate half-finished phase, the rule belongs to the transition, not the type.
That's the design move: find every rule your callers "know" and move it into the object that owns the data it protects. Then invalid states become unrepresentable, and the set of bugs the system can have shrinks.
Notice what this version still does not guarantee. If deposit raises, the money has already left the first account and never arrives at the second: "no money is created or destroyed" is an invariant that spans two objects, and no single object can enforce it. Rules that cross objects need a bigger guard — an aggregate root or a transaction. That is exactly the problem lesson 0012 picks up.
How a senior designer works the case
A refund service on a six-person payments team. A customer is refunded twice for the same order; finance finds it four days later during reconciliation. The ticket says "add a check in the refund endpoint."
What they look at first. Not the endpoint. They grep for every write to the number the rule constrains — grep -rn "refunded" --include='*.py' — and count the sites:
# three paths write the same field; one of them checks the rule
def refund_endpoint(order, amount, payments):
if amount <= order.refundable_amount(): # checked here
payments.refund(order.order_id, amount)
order.refunded += amount
def retry_failed_refunds(rows, orders, payments):
for row in rows: # not checked here
order = orders.get(row.order_id)
payments.refund(order.order_id, row.amount)
order.refunded += row.amount
def csv_import(row, order, payments):
payments.refund(order.order_id, row.amount) # or here
order.refunded += row.amount
The question they ask. Not "where did it break?" but "can I enumerate every path that changes this number, and will I still be able to in a year?" Three paths today; the partner-returns webhook on next quarter's roadmap makes four. If the answer needs a grep, the rule is being observed, not enforced.
The alternative they reject. The obvious refactor is a shared validate_refund(order, amount) helper called from all three sites. It removes the duplication and it is still a hope: the fourth caller — written by someone who never read this file, or generated by an assistant that saw two of the three call sites — has no reason to call it. A helper reduces typing; only a boundary creates enforcement. The test to apply is blunt: can a competent newcomer break this rule without noticing?
class Order:
def authorize_refund(self, amount: Money) -> Refund:
if amount.currency != self._total.currency:
raise CurrencyMismatch()
if amount > self._total - self._refunded:
raise RefundExceedsOrder(self.order_id, amount)
self._refunded += amount # the only writer of this field
return Refund(self.order_id, amount)
The consequence over time. The caller-side fix closes one path, and the next incident arrives through a different one — same defect, new stack trace, another on-call night plus a finance reconciliation. The boundary fix closes the class: after it, over-refunding requires a new mutation path, and a new mutation path is visible in review as an assignment to _refunded outside Order. That is the difference worth paying for — not fewer bugs this week, but a bug family that cannot regrow silently as the team and the call sites multiply.
| You see this | What it costs | The move |
|---|---|---|
| assert amount > 0 atop three functions | The rule holds only where someone remembered to write it. | Move it into the constructor of the value it guards. |
| # must be called after validate() | An ordering rule kept in a comment nobody has to read. | Return a validated type; make the wrong order unspellable. |
| if order.status == "draft" at six call sites | Caller number seven will skip the check. | order.edit() refuses; delete the six guards. |
| a public mutable attribute | Any caller can bypass every method you wrote. | Make it private with a read-only property. |
- "Every attribute should be private with a read-only property."
- Privacy is a cost, paid to protect a rule. A field with no rule attached gains nothing from a property except a line of ceremony and a slower reader. Make private the state that some invariant depends on; leave the rest plain.
- "An invariant is just validation moved into the constructor."
- Validation answers "is this input acceptable?" once, at the edge. An invariant answers "is this object ever allowed to be in this state?" for the object's whole life — which means every mutating method must re-establish it, not only
__init__. - "The database constraint already enforces it, so the domain need not."
- A constraint fires at commit, after the work is done, and reports in the storage layer's vocabulary — an
IntegrityErroryour caller must decode. Keep it (it is your last line of defence against paths you don't own) but let the domain refuse first, in domain language, before side effects have been produced. - "If I raise an exception, the invariant is enforced."
- Only if the raise sits on the sole path to the state. An exception inside a method that a public mutable attribute lets callers bypass enforces nothing at all.
When this is the wrong move
Push a rule inward and you also push a decision inward. Do not do it when:
- The type is a message, not a model. DTOs, event payloads and API request objects should carry whatever the sender actually sent, including the invalid parts — you cannot report a good error about a value your parser refused to construct. Validate at the seam between the message and the domain, in one place, and keep the message type dumb.
- The rule is policy. Thresholds, limits and eligibility windows that a product manager changes are configuration with an owner and an audit trail, not constants welded into a constructor. The invariant is "a limit exists and is respected"; the number is data.
- The object cannot see the data. Uniqueness, "no more than fifty in the queue today", "the sum across all lines" — set-level rules belong to the thing that owns the set. Faking them inside a single object usually means the object secretly reaches for a repository, which is worse coupling than the check it replaced.
- You are reading, not changing. Read models, analytics rows and imported history are frequently invalid by the current rules, because they were created under older ones. Force them through today's constructors and yesterday's data becomes unloadable.
Skill: who enforces the rule?
A rule checked by each caller before acting is:
A domain invariant
A hopeful convention
Input validation rules
A documented precondition
Correct. Exactly: a hope — one forgotten check away from a broken state.
The invariant "cannot confirm twice" belongs in:
The user interface
The database layer
The order object
A validation module
Correct. Right: the object that owns the confirmed state refuses the second confirmation, for every caller.
A negative balance becomes possible. The cheapest place to have caught it was:
A comprehensive test
A careful review
The withdraw method
A monitoring alert
Correct. Correct: in the withdraw method itself, where no path can bypass it.
Practice on your own code
Find a rule your codebase "knows" but doesn't enforce: a check repeated at call sites, a comment saying "must be called after X", an assert at the top of functions. Move it into the object that owns the state it protects.
In the lab: the phase-1 checkpoint gives you the returns-resolution service with its refund rule checked in one HTTP handler and ignored by the retry job and the bulk importer. Your task is to find every writer of the state, move the rule into the object that owns it, and delete the guards that are now unreachable.
Reveal: a worked move
# every caller remembered: "status must be DRAFT to edit"
if order.status == "draft": # repeated in 6 places
order.edit(lines)
# the order object now refuses:
class Order:
def edit(self, lines: list[Line]) -> None:
if self.status != Status.DRAFT:
raise OrderLockedError()
self.lines = lines
- In two sentences, tell a junior why moving a check into an object is different from extracting it into a shared helper both callers use.
- Find one rule in code you touched this week that lives in callers. Count its write paths, and say what the next forgotten one would cost — in incident time, not in style points.
- Your team doubles and half the current call sites are rewritten. Predict which of your hopeful conventions breaks first, and what the failure will look like to the person on call.
Your win
You can find the "everyone remembers" rules in your code and move them into the objects that own the state — making whole classes of bugs unrepresentable.
Read and watch deeper
- Domain-Driven Design, Evans — ch. 6 "The Life Cycle of a Domain Object": invariants as the reason aggregates exist.
- Architecture Patterns with Python, Percival & Gregory — ch. 1 "Domain Modeling": invariants modeled as the domain's own behavior.
- A Philosophy of Software Design, Ousterhout — ch. 10 "Define Errors Out of Existence": designing so errors can't occur, not handling them well.
- Object-Oriented Software Construction (2nd ed.), Meyer — ch. 11 "Design by Contract": the primary source for class invariants, preconditions and postconditions, and the argument that a contract is enforcement rather than documentation.
- Watch: ArjanCodes YouTube — search "domain model" for modeling examples.
- Next: lesson 0012 — value objects and aggregates, the carriers of invariants.
- Reference: Design Principles — make invalid states unrepresentable; glossary — domain invariant.
Show your agent-teacher a "must be called after X" comment and design the refusal together.
Source page: lessons/0011-domain-invariants.html
Lesson 0012 · Core Designer · Module 3
Value Objects and Aggregates
Two containers protect the domain: value objects make data trustworthy, aggregates make change trustworthy. This lesson builds both on one example.
Mission tie-in: "use value objects and aggregates to protect integrity" — the mechanism behind lesson 0011's "make invalid states impossible."
Knowledge: the two protectors
Value object: an immutable thing whose identity is its value. Two instances with equal fields are interchangeable. Money is the classic:
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError("amount cannot be negative")
if not self.currency.isalpha() or len(self.currency) != 3:
raise ValueError("currency must be an ISO code")
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise CurrencyMismatch()
return Money(self.amount + other.amount, self.currency)
Before: amount and currency drifted through the codebase as two unconnected numbers, each function re-validating or forgetting. After: you cannot construct a broken Money, and adding dollars to euros refuses itself. The value object killed a whole family of bugs — and it's shareable and testable because it's immutable.
Aggregate: a cluster of objects that must change together, with one root guarding the cluster's invariants. The order and its lines:
class Order:
def __init__(self, order_id: str, customer: CustomerId, currency: str) -> None:
self.order_id = order_id
self.customer = customer
self._currency = currency
self._lines: list[OrderLine] = []
self._status = Status.PENDING
def add_line(self, product: ProductId, qty: int, price: Money) -> None:
if self._status != Status.PENDING: # invariant: only drafts change
raise OrderLockedError()
if price.currency != self._currency: # invariant: one currency per order
raise CurrencyMismatch()
self._lines.append(OrderLine(product, qty, price))
def total(self) -> Money:
total = Money(Decimal("0"), self._currency)
for line in self._lines:
total = total + line.total() # Money refuses a mismatch anyway
return total
Order is the root: every change to the cluster goes through it, and it enforces the invariants from lesson 0011. Lines are edited only via the root — no one reaches into _lines to bend a rule.
Watch the two layers cooperate. Money refuses to add euros to dollars — a rule about one value. Order refuses a line in the wrong currency at the moment it is added — a rule about the cluster, caught at the door rather than three steps later inside total(). The value object makes each datum trustworthy; the aggregate makes each transition trustworthy. Neither can do the other's job.
The two containers work by two different mechanisms, and confusing them is why people apply them in the wrong places.
A value object converts a validity rule from something checked at every use into something proved by the type. Once a Money exists, every function downstream receives a guarantee it never has to re-verify, and the number of places that can produce an invalid one drops to a single constructor. Immutability is what makes the proof durable rather than momentary: a mutable "already validated" object can be invalidated a line after the check, which is the same time-of-check/time-of-use gap that makes caller-side validation unreliable in the first place.
An aggregate is not a tree of objects; it is a consistency boundary. Drawing it is a decision about which facts must be true at the same instant and which may become true a second later. That is why its real constraints are operational: the aggregate is the unit of locking, the unit of write conflict, and usually the unit of loading. Make it bigger and you buy stronger guarantees with contention and load cost; make it smaller and you buy throughput with rules that must now be enforced by a process rather than by a method.
So the boundary conditions follow directly. Value objects stop paying where construction cost dominates the work (millions of rows, vectorized numeric code) or where the thing actually has a lifecycle you track. Aggregates stop paying when the cluster is only ever read together — that is a query, not an aggregate — or when the boundary grows until one popular entity serialises the whole system.
How a senior designer works the case
The returns-resolution initiative: customers open returns against existing orders, and one rule matters to finance — a customer must never be refunded more than they paid. A modeller's instinct is to put the orders and the returns inside one Customer aggregate, where that rule is a single method away from being an enforced invariant.
What they look at first. Not the class diagram — the write pattern. How many writes per second hit one customer, how many orders does a heavy customer have, and which of these facts have to agree at commit time versus within a few seconds? For a retailer's returns flow, a given customer writes rarely, but the eligibility check reads the order, and the customer with 400 orders is exactly the one who returns things.
The question they ask. "What breaks, in business terms, if these two facts are stale relative to each other for two seconds?" If the honest answer is "a return is accepted against an order that was cancelled a second ago, and we reject it on review", that is a recoverable business event, not a corruption — so the two facts do not need one transaction.
The alternative they reject. The Customer-as-root design. It makes the refund-total rule enforceable in one place, and it costs: every return on any order locks the customer, a heavy customer loads hundreds of orders to add one line, and two teams now write to the same aggregate. Worse, on inspection the rule was never an invariant — the fraud guardrail it was protecting is a policy with thresholds a risk team tunes monthly. Enforcing policy as an invariant means a business tuning decision becomes a domain deployment.
@dataclass(frozen=True)
class OrderId:
value: str
class Return: # its own aggregate root
def __init__(self, return_id: str, order_id: OrderId, currency: str) -> None:
self.return_id = return_id
self.order_id = order_id # reference by identity, not object
self._currency = currency
self._lines: list[ReturnLine] = []
self._state = ReturnState.REQUESTED
def add_line(self, sku: str, qty: int, refund: Money) -> None:
if self._state is not ReturnState.REQUESTED:
raise ReturnLockedError(self.return_id)
if refund.currency != self._currency:
raise CurrencyMismatch()
self._lines.append(ReturnLine(sku, qty, refund))
def reject(self, reason: str) -> None: # the compensating transition
self._state = ReturnState.REJECTED
The consequence over time. Two aggregates mean two teams can hold two locks, ship on two schedules, and reason about two state machines. The price is explicit and paid once: eligibility is checked against a snapshot at creation, the order system stays authoritative, and the race — a return raised against an order that changed underneath it — surfaces as a reject() transition you can test, count and show a customer. Compare that to the contention failure of the big aggregate, which shows up as timeouts at peak, six months later, in code nobody wants to reshape by then.
| You see this | What it costs | The move |
|---|---|---|
def book(start: datetime, end: datetime) | Two values that must agree, re-validated at every call site. | One DateRange value object, validated once. |
| comparing two frozen dataclasses by id | Identity matters here — it is an entity in a value object’s clothes. | Give it an identifier and compare on that. |
| order.lines.append(...) from outside | The root’s invariants bypassed through a public list. | order.add_line(); keep _lines private. |
| float for money | Rounding drift nobody in the domain ever agreed to. | Decimal inside a Money value object. |
- "A value object is a frozen dataclass."
- Frozen is a means. The properties that matter are validity at construction, equality by value, and being safe to share. A frozen dataclass holding a
listis not immutable — callers mutate the list through the "frozen" object, and the guarantee you thought you had is gone. - "An aggregate is the parent in a parent/child relationship."
- It is a transaction boundary. Two clusters that are drawn as one tree but never change in the same transaction are two aggregates; one cluster spread over three tables that must commit together is one aggregate. The ERD is not the answer.
- "Primitive obsession is bad, so wrap every primitive."
- Wrapping buys a rule, a unit, or a name that prevents a mix-up (metres and feet, cents and euros). Where there is no rule and no confusable neighbour, a
stris astr, and the wrapper adds an import, a constructor and a layer of unwrapping at every boundary. - "Aggregates should mirror the database schema."
- Then the schema owns your model, and every normalization decision made for storage reasons becomes a domain decision. Design the boundary from the consistency requirement first; let the mapping be the adapter's problem (lesson 0013).
When this is the wrong move
- Bulk and analytics paths. Reconciling ten million rows through
Moneyconstructors buys validation you already have from the source system and costs an object per row. Validate at the ingest boundary; work in primitives inside the loop. - Screens that read across boundaries. A dashboard needing customer, orders, returns and shipping is not evidence that they belong in one aggregate. It is evidence you need a read model. Growing the aggregate to serve a query is the single most common way aggregates become contention points.
- CRUD with no rules. A settings form with twelve independent fields and no invariants gains nothing from a root that guards nothing. The ceremony is real; the protection is imaginary.
- Facts you do not own. If another system is authoritative for a value, your "aggregate" is holding a replica. Model it as a cached snapshot with an age, not as state you can enforce rules on (lesson 0020).
Skill: which container, and when?
An interval with start and end times is best modeled as:
A value object
An entity object
An aggregate root
Two plain primitives
Correct. Right: two equal intervals are interchangeable; the value object can also guard start < end at construction.
Adding dollars to euros should fail because:
Convert the money silently
The Money object refuses
The caller checks first
Round to two decimals
Correct. Exactly: currency mismatch is a domain error, refused by the value object itself.
Order lines change only through the root because:
Keeps all logic centralized
Encapsulation is good practice
The root guards invariants
It avoids direct imports
Correct. The invariant 'only drafts change' must hold for every edit — the root is its guard.
Practice on your own code
Find a pair of values that travel together as primitives (a price and currency, a start and end, a unit and rate) — promote them to a value object. Then find a cluster that must change together and give it a root that refuses invalid edits.
In the lab: the phase-1 checkpoint ships a returns service that passes an amount and a currency code as two separate arguments through four layers, and a return whose line list is appended to from outside. You promote the pair into one value object and decide, with the contention argument above, where the aggregate boundary actually belongs.
Reveal: a worked promotion
# before: temperature and unit passed separately, revalidated everywhere
def display(temp: float, unit: str) -> str: ...
# after: Temperature can't be malformed, and unit conversions live inside it
@dataclass(frozen=True)
class Temperature:
value: float
unit: Unit
def in_celsius(self) -> float: ...
- In two sentences, explain to a junior why aggregate size is a concurrency decision rather than a modelling preference.
- Find one pair of primitives that travel together in your codebase this week. Name the specific bug their separation makes possible, and how often that code changes.
- Assume your largest aggregate keeps absorbing one more "related" thing per quarter. Describe the failure you will be paged for in a year, and which metric shows it first.
Your win
You can spot primitive pairs (value objects waiting to happen) and unguarded clusters (aggregates waiting to happen) — and you know which identity test decides the container.
Read and watch deeper
- Domain-Driven Design, Evans — ch. 5 "A Model Expressed in Software" (entities vs value objects) and ch. 6 (aggregates and invariants).
- Implementing Domain-Driven Design, Vernon — ch. 6 "Value Objects" and ch. 10 "Aggregates": the practical rules of thumb.
- Architecture Patterns with Python, Percival & Gregory — ch. 1–2: value objects and aggregates in working Python.
- Vernon, "Effective Aggregate Design" (three essays, dddcommunity.org) — the primary source for the rules used above: model true invariants inside a boundary, keep aggregates small, reference other aggregates by identity, and update one aggregate per transaction with eventual consistency between them.
- Watch: ArjanCodes YouTube — search "value objects" or "aggregate".
- Reference: Glossary — value object, entity, aggregate.
Bring a borderline case — entity or value object? — to your agent-teacher and argue both sides.
Source page: lessons/0012-value-objects-and-aggregates.html
Lesson 0013 · Core Designer · Module 3
Repository Boundaries That Don't Leak
A repository is a collection, from the domain's point of view. When it starts showing its storage internals, the domain stops being protected.
Mission tie-in: the repository is the port from lessons 0009 and 0010, specialized for storage. It's also the boundary your AI prompts should name explicitly.
Knowledge: a collection, not a query API
The repository's job: let the domain load and save aggregates as if they lived in a collection. The domain never learns about SQL, sessions, or ORMs. The port speaks domain language:
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
def add(self, order: Order) -> None: ...
def delete(self, order_id: str) -> None: ...
class SqlAlchemyOrderRepository:
def __init__(self, session: Session) -> None:
self._session = session
def get(self, order_id: str) -> Order:
row = self._session.execute(
select(OrderRow).where(OrderRow.id == order_id)
).scalar_one()
return Order(...) # row -> domain aggregate
def add(self, order: Order) -> None:
self._session.add(OrderRow.from_domain(order))
The boundary's proof is the fake — a drop-in replacement with no storage at all:
class InMemoryOrderRepository: # implements the same port
def __init__(self) -> None:
self._orders: dict[str, Order] = {}
def get(self, order_id: str) -> Order:
try:
return self._orders[order_id]
except KeyError:
raise OrderNotFound(order_id) # the port's vocabulary, not the ORM's
def add(self, order: Order) -> None:
self._orders[order.order_id] = order
Twelve lines, no database, and every domain test runs against it in milliseconds. That is also the diagnostic: if your fake cannot be written in a dozen lines, the port is carrying storage semantics it should not — lazy loading, session lifetimes, flush ordering. Note the error too: the fake raises OrderNotFound, not KeyError and not the ORM's NoResultFound. The port owns its failure vocabulary as much as its methods.
What makes this boundary honest is what it refuses:
- No
session,query, or ORM types appear in the port or in domain callers. - Queries are expressed as domain questions:
get_unshipped(), notfilter(status=...)with raw column names. - The domain gets domain objects back — never rows, dicts, or ORM models.
- Persistence details — transactions, lazy loading, caching — live behind the boundary.
Test it the lesson-0009 way: a fake in-memory repository must be a drop-in, and the domain must never know which one it holds. If your fake has to mimic ORM behavior, the boundary leaked.
The repository pays off because storage and domain change at different rates, for different reasons, under different people. Schema migrations, connection pooling, ORM upgrades and query tuning arrive on the infrastructure clock; refund rules and eligibility arrive on the product clock. A boundary between two clocks means a change on one side stops propagating to the other — which is the same argument as lesson 0006, applied to the noisiest dependency most systems have. The measurable proof is test cost: domain tests that no longer need a database run in microseconds instead of seconds, so they get run on every save instead of in CI, and the feedback loop that catches design mistakes tightens by two orders of magnitude.
It stops being true in three situations, and all three are common. First, when the query is the product: reporting, search, aggregation and analytics need the storage engine's expressive power, and a collection interface forces you to re-implement a query language, badly, in Python. Second, when storage semantics are domain semantics — set-based operations, window functions, "update the 40,000 matching rows" — where pretending the database is a collection means loading a million rows to do work the engine would do in one statement. Third, and least noticed: an interface may hide implementation, but it must not hide cost. A port that conceals how many round trips a call makes has swapped one leak for another, and the second one only shows up in production.
How a senior designer works the case
An order service: eight engineers, twenty-five endpoints, a repository boundary everyone is proud of. After one sprint, the order-list page's p95 goes from 180 ms to 1.4 s. The ticket says "the repository is slow."
What they look at first. Not the SQL, and not the adapter. The call site, next to the port's signature:
# the port looks immaculate
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
# the caller wrote the loop the port invited
def order_list(order_ids: list[str], repo: OrderRepository) -> list[OrderSummary]:
return [summarize(repo.get(oid)) for oid in order_ids] # 50 round trips
The question they ask. "Which side of this boundary decides how many round trips happen?" Here, neither did on purpose: the caller wrote an innocent comprehension, the port had no way to express "fifty at once", and the fake — which returns from a dict — made the loop look free in every test. That is the diagnosis. The port hid cost, and the fake made the hiding invisible.
The alternatives they reject. The quick fix is get_many(ids), then get_with_lines(ids) for the next screen, then get_with_lines_and_shipping(ids). Each is reasonable alone; together they grow one method per screen, so a write-model interface slowly becomes a read API with nineteen methods, six of them used once, all of which the fake and every implementation must support forever. The other tempting fix — switching the ORM to eager loading globally — repairs this page and pessimizes every write path, and makes the port's behaviour depend on a configuration line in a different file.
What they choose, and what it costs. Split the two jobs the boundary was doing. The repository stays narrow and aggregate-shaped for the write side: load one thing by identity, change it, save it. A separate read side — a query service — is allowed to know SQL, because reading is a query:
@dataclass(frozen=True)
class OrderSummary: # a read model, not a domain aggregate
order_id: str
customer_name: str
line_count: int
total_cents: int
class OrderQueries(Protocol): # a separate port, read-only
def recent_summaries(self, limit: int) -> list[OrderSummary]: ...
The consequence over time. Two boundaries with different rules is more code than one, and the honest cost is a second mapping path that can drift from the first. What it buys is that neither side has to lie: the domain keeps its twelve-line fake and its microsecond tests, the list page gets one statement instead of fifty, and the next screen adds a read model instead of another method to an interface eleven classes implement. The failure mode you avoid is the one that kills repositories in practice — a port that grows until nobody can implement it and everybody routes around it.
† Leak test: grep the domain folder for the ORM's import. Any hit is a hole — the dependency arrow from lesson 0009 is pointing the wrong way.| You see this | What it costs | The move |
|---|---|---|
| session in a port signature | Every caller has to know an ORM exists. | Take and return domain objects only. |
| query(Order).filter(...) in a service | SQL vocabulary in domain code; the fake must mimic an ORM. | repo.get_unshipped() — a domain question. |
| a repository returning OrderRow | Rows leak the schema; every caller learns the columns. | Map row to aggregate inside the adapter. |
| repo.save() called twice “to be safe” | Transaction semantics leaked, and were misunderstood. | One unit of work owns the commit. |
- "One repository per table."
- One per aggregate (lesson 0012). Tables are a storage decision; a single aggregate frequently spans three of them, and a repository per table forces callers to reassemble the aggregate — which puts the storage layout back in the domain, the exact leak the boundary existed to close.
- "Every repository needs a save() method."
- Often the transaction, not the repository, owns the commit. When each object saves itself, a caller changing three objects performs three writes with no boundary around them, and partial failure leaves the system in a state no invariant covers. Let a unit of work own the commit; the repository owns retrieval and registration.
- "A generic Repository[T] with CRUD methods is good reuse."
- It is the storage vocabulary again with generics on top.
create/read/update/deleteare not domain questions, and the methods you actually want —get_unshipped(),with_pending_returns()— differ per aggregate precisely because the domains differ. Shared base classes here mostly share the leak. - "If the fake passes, the boundary is verified."
- The fake tests your code against your beliefs about storage. It has no unique constraints, no lazy loading, no isolation levels and no ordering surprises, so it will happily confirm a design the real database rejects. Run the same suite against the real adapter as a contract test — occasionally is enough — or the fake becomes a comfortable lie.
When this is the wrong move
- Reporting, search and analytics. Go to the engine directly, with a read model. A repository between a dashboard and a warehouse query buys nothing and costs expressiveness.
- Set-based work. "Expire the 40,000 sessions older than a day" is one statement in SQL and a disaster as a loop over a collection. Put it behind a named domain operation, and let the adapter do it in one round trip.
- Small applications where the ORM model is the model. One team, one storage, CRUD rules, no plans to swap: the port is ceremony, and the honest design is to admit the framework is the architecture. Say so in an ADR so the next person knows it was a decision.
- Spikes. A boundary you introduce before you know the domain's questions will be a boundary shaped by your first guess. Write the ugly version, learn the queries, then draw the line.
Skill: what crosses the boundary?
A repository port may expose:
The database session
Domain objects only
Raw column names
The query API
Correct. Right: domain objects in, domain objects out — like a collection would.
"Find orders waiting for shipment" should appear in the repository as:
A filter by status
A method named get_unshipped
An exposed ORM query
Raw SQL in callers
Correct. Exactly: a domain-named method that hides whatever the storage has to do to answer the question.
A fake in-memory repository proves the boundary works when:
The domain runs unchanged
It mimics the ORM
It matches database speed
It tests the database
Correct. Right: the domain runs on the fake with zero changes — the collection illusion held.
Practice on your own code
Look at how your code fetches and saves domain objects. Grep the domain folder for your ORM's import. If it appears — or if the boundary doesn't exist yet — introduce a repository port with domain-named methods and move the ORM behind it.
In the lab: the phase-1 checkpoint has a returns store that hands ORM rows and a live session back to the service layer, and a list endpoint that loops one lookup per return. You give the port a domain vocabulary, write the in-memory fake, and decide which of the remaining calls belongs on a read side instead.
Reveal: the leak audit
A CustomerService calls session.query(CustomerModel).filter_by(...) directly. Grep shows the ORM import inside the domain service. Fix: a CustomerRepository port with get_active(), implemented by an adapter that owns the session. The service now states its data needs as questions, not SQL.
- In two sentences, explain to a junior why a repository interface that hides the number of database round trips is still a leak.
- Find one data-access call in your code this week whose cost is invisible at the call site. Name what it would cost at ten times today's volume.
- Assume every new screen adds one method to your repository interface. Describe what that interface looks like in a year, and who stops using it first.
Your win
You can design a repository boundary that speaks domain language, run the leak test (grep the ORM import), and prove it with an in-memory fake.
Read and watch deeper
- Architecture Patterns with Python, Percival & Gregory — ch. 2 "The Repository Pattern": the clearest working treatment, with the fake-repository test.
- Implementing Domain-Driven Design, Vernon — ch. 12 "Repositories": design rules for repository interfaces.
- Domain-Driven Design, Evans — ch. 6: the original repository concept, and why it exists.
- Patterns of Enterprise Application Architecture, Fowler — the Repository entry in the object-relational metadata mapping patterns: the original catalog definition, and the Query Object it is usually paired with.
- Fowler, "CQRS" — the argument for letting the read side and the write side have different models, with an explicit warning about when the split is not worth it.
- Watch: ArjanCodes YouTube — search "repository pattern".
- Reference: Patterns — Repository and Unit of work, the transaction boundary the repository sits inside.
Show your agent-teacher your data-access code and audit the boundary together — grep included.
Source page: lessons/0013-repository-boundaries.html
Lesson 0014 · Core Designer · Module 3
Fail-Fast at the Right Boundaries
The question is never "should we fail?" — it's where. Fail early, at the boundary, loudly, and the error arrives with its address attached.
Mission tie-in: fail-fast completes the Core Designer phase — it's how the domain core protects itself from the messy world around it.
Knowledge: fail where the cause lives
A bad value that travels is a bomb with a delayed fuse: it's written here, read there, and explodes in a third place, hours later, in unrelated code. Fail-fast means the bomb detonates at the moment and place of the cause, where the fix is obvious.
Three boundaries deserve loud failures:
- The input boundary — data enters the system. Validate and refuse before work starts:
def schedule_meeting(request: MeetingRequest) -> Meeting:
if request.end <= request.start:
raise InvalidMeetingRequest("end must be after start")
...
- The invariant boundary — domain rules (lesson 0011) refuse invalid transitions, whoever is asking:
class Meeting:
def cancel(self, reason: str) -> None:
if self._status is Status.CANCELLED:
raise AlreadyCancelled(self.meeting_id) # not a silent no-op
self._status = Status.CANCELLED
The tempting alternative — returning quietly when the meeting is already cancelled — hides a real bug: something in the system believes it is cancelling a live meeting. Silence turns that into a mystery three days later; the exception turns it into a stack trace now.
- The integration boundary — the outside world fails; errors surface as errors, not as
None:
def process_batch(orders: list[Order], gateway: PaymentGateway) -> BatchResult:
results = []
for order in orders:
try:
charge_id = gateway.charge(order.total, order.token)
except PaymentError as e:
results.append(FailedOrder(order.order_id, str(e))) # loud, per item
continue
results.append(PaidOrder(order.order_id, charge_id))
return BatchResult(results)
Notice the shape: each failure is caught at the boundary where it can be handled (per-order), recorded loudly, and reported — versus lesson 0005's AI version that swallowed everything and returned early. The difference between "fail-fast" and "fragile": the first fails loudly at the right boundary; the second fails silently at the wrong one.
The cost of a defect is set by the distance between cause and symptom — measured in call frames, in state transitions, and in wall-clock time. Each transition between the moment a value goes wrong and the moment anyone notices multiplies the space you have to search, because every one of them is a candidate explanation. Once the bad value is persisted, the distance stops being bounded at all: the cause is in a log that has since rotated, in a request nobody can replay. Failing at the cause collapses that search to zero — the stack trace is the diagnosis. Fail-fast is an economic argument about debugging cost, not a moral position about strictness.
Which is exactly why it is not universal. Failing fast trades availability for correctness, and that trade is only good when a wrong answer costs more than no answer. Money, safety, permissions, anything that gets written down: refuse. A recommendations panel, an enrichment lookup, a nice-to-have field on a dashboard: a broken pixel is cheaper than a broken page. And there is one place where naive fail-fast actively inverts: when the failing input will be handed back to you immediately. A poison message on a retrying queue, or a config error at process start under an orchestrator, turns "fail loudly" into a crash loop that takes out the healthy traffic behind it. There the discipline is not to swallow the error but to fail the smallest unit and make the failure countable: dead-letter the message, degrade the feature, emit the metric — so that silence is never mistaken for success.
How a senior designer works the case
A payments API, forty pods, a rolling deploy at 02:00. Startup validates the whole config file strictly and refuses anything it does not recognise — a rule the team adopted after a missing signing key was silently defaulted to empty. Tonight someone ships a typo in an optional feature flag. Every replacement pod fails to boot, the rollout drains the healthy ones, and a service that could have answered every request is down for nineteen minutes.
What they look at first. Not the typo. The question of what the failing check was protecting, and what it took down. Strictness here was not wrong in kind; it was applied at one granularity to two kinds of configuration with opposite risk profiles.
The question they ask. "Does failing here prevent harm, or cause it?" A missing signing key means every subsequent request is processed unverified — failing prevents harm, and must happen before the pod takes traffic. An unparseable flag for a UI experiment means one feature stays off — failing causes harm, and nothing else.
The alternative they reject. The reflex fix, wrapping the config load in try/except and defaulting everything, restores availability by deleting the distinction. It also silently reinstates the original incident: the missing signing key becomes "signature verification disabled", discovered by an auditor rather than by a crash. Trading a nineteen-minute outage for an unbounded security failure is not a trade a senior takes.
REQUIRED = ("database_url", "signing_key", "gateway_url")
def load_config(raw: dict) -> Config:
missing = [key for key in REQUIRED if not raw.get(key)]
if missing: # cannot serve correctly: refuse before traffic
raise ConfigError("cannot start without: " + ", ".join(missing))
flags, rejected = parse_flags(raw.get("feature_flags", {}))
for key, problem in rejected: # optional: degrade, but countably
log.error("ignoring feature flag %s: %s", key, problem)
metrics.config_degraded.labels(key=key).inc()
return Config(raw, flags=flags, degraded=tuple(key for key, _ in rejected))
The consequence over time. The line between "cannot serve" and "nice to have" is now a list in the source, so adding a required key is a visible diff someone argues about, rather than an accident of which parser ran first. Tonight's typo becomes one ERROR line and a tick on a gauge. The subtler win is that the degraded state is observable: a pod running without an optional feature is now distinguishable from a pod running correctly, which is the property blanket try/except destroys and the reason it keeps producing incidents that take days to attribute.
None or empty results at the boundary where the error still means something. Only convert where a default genuinely makes sense — and name that decision.
| You see this | What it costs | The move |
|---|---|---|
except Exception: return None | The caller cannot tell “absent” from “broken”. | Raise; let the caller name the case it handles. |
| if not user: return in a handler | A violated precondition treated as an ordinary path. | Refuse at the door, with the reason attached. |
| a KeyError surfacing three layers up | The value entered the system unvalidated. | Validate at the input boundary, where the cause is. |
| logger.error(...) then continue | Loud in the log, silent in the returned result. | Record the failure in the result you return. |
- "Fail fast means never use a default."
- A default is fine when it is a decision — written down, and visible at runtime. The defect is the implicit default that hides an absence, so that "configured to 30 seconds" and "nobody configured this" produce identical behaviour and identical logs.
- "
assertis how you fail fast." - Assertions vanish under
python -O, and they express programmer beliefs, not runtime contracts. Anything a user, a queue or another service can trigger must be an explicitraisewith a typed error. Keepassertfor "this cannot happen" — and expect it to be absent in production. - "Fail-fast and resilience are opposites."
- They work at different levels. Resilience is built out of components that fail predictably: a unit that fails fast can be retried, isolated or shed by the layer above it, while a unit that limps on corrupted state cannot be contained by anything. Fail fast inside; contain at the boundary.
- "Logging the error counts as failing loudly."
- Only if someone counts the log. If the function still returns a plausible value, the caller proceeds and the log becomes archaeology, read after the customer complains. Loud means the result carries the failure — an exception, or a result type with the failure inside it.
When this is the wrong move
- Best-effort features. Recommendations, enrichment, avatars, telemetry. A failure here should shrink the response, not replace it. Return the page without the panel and count the omission.
- Streaming ingest. One malformed record must not stop the stream. Dead-letter it with enough context to replay, and alert on the dead-letter rate rather than on the record.
- Startup dependencies that are merely late. Crashing because the database is not up yet is reasonable under an orchestrator that restarts you; crashing because it was slow for four seconds at 03:00 is a self-inflicted outage. Bounded retry with a deadline, then fail.
- Anywhere the failure costs more than the wrong answer. Name that judgment explicitly, in the code, next to the fallback — the next reader needs to know it was chosen rather than forgotten.
Skill: which boundary fails how?
A payment fails for one order in a batch of fifty. The batch should:
Record and continue others
Abort the whole batch
Skip it all silently
Retry until it succeeds
Correct. Right: per-order failure is handled at the boundary where it's meaningful — recorded, reported, rest continue.
Returning None from get_user() when the user is missing is:
Explicit domain choice
The fail-fast way
An ambiguity generator
Good error handling
Correct. The classic ambiguity: None means 'missing', 'error', or 'no permission' — and each caller guesses.
The input check end <= start belongs at the boundary because:
Every caller re-checks it
The database catches it
The error detonates there
Log it and proceed
Correct. Exactly: the error detonates where it's caused and where the fix is obvious — at the door.
Practice on your own code
Find a silent failure in your code: a swallowed exception, a return None hiding an error, an early return abandoning work. Decide the correct boundary, make the failure loud, and (if useful) add the per-item handling pattern.
In the lab: the phase-1 checkpoint contains a returns pipeline whose gateway call is wrapped in a bare except Exception that returns an empty result, so a failed refund is indistinguishable from a return with nothing to refund. You decide which boundary owns the failure and make the difference visible in the value that comes back.
Reveal: a worked fix
# before: silent
def load_config(path):
try:
return yaml.safe_load(open(path))
except Exception:
return {}
# after: loud at the input boundary
def load_config(path: Path) -> dict:
return yaml.safe_load(path.read_text()) # errors propagate with their cause
An empty config silently disables features; a raised error tells the operator the config is broken, at startup, where it can be fixed.
- In two sentences, explain to a junior why "fail fast" and "be resilient" are instructions to different layers rather than contradictory advice.
- Find one swallowed exception in code you touched this week. Say what a caller currently cannot distinguish because of it, and who eventually pays for that confusion.
- Assume that swallow stays for a year while the call sites triple. Describe the incident it produces, and why the log will not explain it.
Your win
You can now place failures deliberately: refuse at input, guard invariants, catch-and-report at integration boundaries — and you've completed the Core Designer phase. Take the Phase II checkpoint.
Read and watch deeper
- A Philosophy of Software Design, Ousterhout — ch. 10 "Define Errors Out of Existence": fail-fast's philosophical home.
- Clean Code, Martin — ch. 7 "Error Handling": why swallowed errors are structural defects.
- Release It!, Nygard — the stability patterns: where loud, early failure protects the whole system.
- Shore, "Fail Fast" (IEEE Software, 2004) — the short primary source that named the practice, including its warning that fail-fast is about making bugs visible, not about crashing more.
- Watch: ArjanCodes YouTube — search "error handling" or "exceptions".
- Next phase: lesson 0015, System Designer.
- Reference: Design Principles — fail fast and its counterpart, define errors out of existence.
Bring a swallowed-error site to your agent-teacher and decide the correct boundary together.
Source page: lessons/0014-fail-fast-error-handling.html
Lesson 0015 · System Designer · Module 1
Classes vs Functions
Not everything needs a class. The decision is not style — it's about what has to change: stateless behavior belongs in functions; state and polymorphism belong in classes.
Mission tie-in: "decide when to use classes and when simple functions are enough" is the first System Designer skill — the one AI gets wrong in both directions.
Knowledge: the two habitats
Functions are the natural home for behavior without state: a transformation from input to output, no history, no configuration of its own. Pure functions — same input, same output, no side effects — are the most composable, testable units software has.
def sanitize(word: str) -> str:
return word.strip(".,!?").lower()
def words(text: str) -> list[str]:
return [sanitize(w) for w in text.split()]
def word_frequencies(words: list[str]) -> Counter[str]:
return Counter(words)
# three stateless steps compose into a pipeline with no ceremony:
freqs = word_frequencies(words(raw_text))
Classes earn their keep when there is state to own (something that must stay consistent across calls) or polymorphism (several behaviors behind one interface — lesson 0004's strategy). A class with no state and one method is usually a function wearing a costume:
class TextStats: # stateless costume
def compute(self, text: str) -> Counter[str]:
return Counter(text.lower().split())
# the function version is the same thing with less ceremony:
def compute_text_stats(text: str) -> Counter[str]:
return Counter(text.lower().split())
The test is a change question, not a religion: what will vary here? A new behavior variant → a protocol + implementations (classes earn it). New arguments to a transformation → a function with parameters (functions earn it). Lifetime and consistency across calls → state, and a class owns it.
The two units make different promises. A function's contract is a single mapping: these arguments produce that result. A class's contract is a set of methods sharing an invariant over a lifetime — which means introducing one hands every future reader two extra things to model: a state space (what combinations of attributes are possible?) and an object lifetime (when is it created, when is it valid, who holds it?). That is the price. You should pay it when something must stay true between calls, when several implementations must be substitutable behind one name, or when a set of collaborators is bound once and used many times.
Read that list again and notice what is not on it: the number of methods. This is where the folk rule — "a class with one method and no state is a function in disguise" — stops being true. A class is also the standard way to give a seam a name and a lifetime. An object constructed at the composition root with three collaborators and used at fourteen call sites is not ceremony; it is the alternative to passing those three collaborators through fourteen call sites. The honest test is Ousterhout's, and it cuts across the whole question: prefer whichever unit hides more behind a smaller interface.
How a senior designer works the case
Two classes surface in the same review, and by the folk rule they are identical: one method, no mutable state.
class Slugifier: # candidate A
def make(self, title: str) -> str:
return "-".join(title.lower().split())
class ReturnLabelPrinter: # candidate B
def __init__(self, carrier: CarrierClient, retries: RetryPolicy) -> None:
self._carrier = carrier
self._retries = retries
def print_label(self, ret: Return) -> Label:
return self._retries.run(lambda: self._carrier.create_label(ret))
What they look at first. Not the method count — the constructor. Candidate A's is empty: nothing is bound, nothing has a lifetime, and the only thing the class contributes is a noun the caller must import before it can call a function. Candidate B binds two collaborators that were chosen elsewhere, and one of them holds a connection pool.
The question they ask. Not "does it have state?" but "does it have a lifetime, and is it a substitution point?" Candidate B is swapped for a fake in tests, for a sandbox client in staging, and for a second carrier when the contract changes — and its lifetime matters, because creating the client per call would open a connection per label.
The alternative they reject. Converting B to a free function print_label(ret, carrier, retries). It reads cleaner in isolation and moves the binding problem to the callers: five call sites today, and the fourteen a year from now each have to obtain a carrier client and a retry policy they do not care about. That is change amplification bought with tidiness. Reaching for functools.partial to fix it recovers the class with a worse name — an object with no type name in a stack trace, no place to hang a second method, and a repr that tells an on-call engineer nothing.
The consequence over time. A converts to a function and one import disappears from six modules. B stays a class and, eighteen months later, absorbs a second method (void_label) and a metric counter without a single call site changing — because the seam already had a name. The rule survives, but its real content turns out to be "pay for a class when you need a lifetime, a seam, or a family", and "no state" is only the most common symptom of not needing any of the three.
| You see this | What it costs | The move |
|---|---|---|
| __init__ that only stores callables | A function factory wearing a class costume. | A closure, or functools.partial. |
| a method that never reads self | It is a function that happens to live in a class. | Move it out; @staticmethod is the smell, not the fix. |
| a growing mode="…" parameter | Polymorphism by string, without the interface. | A protocol plus one implementation per mode. |
| 20 free functions sharing one module dict | State with no owner (lesson 0019’s phone line). | One class owning the dict, methods around it. |
- "Classes are legacy ceremony; functions are always simpler."
- Both are namespaces with different defaults. A module of thirty free functions sharing one module-level dict is a class — with worse encapsulation, no lifecycle and one instance forced on the whole process (lesson 0019). You do not avoid objects by refusing the keyword.
- "A class with a single method is always a smell."
- Only when it binds nothing. When the constructor holds collaborators that were selected at the composition root, the single method is the interface and the constructor is the point.
- "
@staticmethodfixes a method that never touchesself." - It relocates the problem. Callers still import the class, still name it, and still read it as though the grouping meant something. If nothing is shared, move the function out; if something is shared, use it.
- "Dataclasses aren't really classes."
- They are — with generated boilerplate. They carry invariants in
__post_init__, they can hold methods, and a frozen one is a value object (lesson 0012). "Just a struct" is a decision you make, not a property the syntax gives you.
When this is the wrong move
- When the framework wants a class. ORM models, serializers, view classes and plugin base classes are the ecosystem's interface. Fighting them to satisfy a habitat rule costs you every convention and every piece of tooling built on top.
- When a function has grown six parameters that always travel together. The answer is a class with no methods at all — a parameter object or a value object. Splitting it back into free arguments is the anti-pattern this lesson is sometimes misread as recommending.
- When you need one seam and the alternative is threading collaborators. As above: the class is cheaper than the plumbing.
- In numeric or per-item hot paths. Attribute lookups and object construction per element are measurable in tight loops. Rare, but real — and worth a benchmark rather than an opinion.
Skill: name the habitat
A transformation with no state and one method is best as:
A pure function
A one-method class
An abstract class
A static method
Correct. Right: no state, no polymorphism — a pure function is the simpler, equally capable home.
Three payment behaviors behind one interface justify:
A switch function
A strategy hierarchy
Global function branches
An inheritance tree
Correct. Exactly: polymorphism — several variants behind one stable interface — is the class habitat.
A counter that must stay consistent across calls needs:
A pure function
A state-owning class
A module global
A plain closure
Correct. Right: state that must survive and stay consistent across calls is exactly what classes own.
Practice on your own code
Find a class with no state (no self. assignments in __init__ that vary) and convert it to a function. Then find one place where a function keeps gaining conditional branches and sketch the protocol that would house them.
In the lab: the phase-2 checkpoint has a returns codebase containing both shapes — a ReturnValidator whose methods never read self, and a handful of free functions that pass the same three collaborators to each other. You move each one to the habitat that fits and justify the two decisions differently.
Reveal: a worked conversion
# before
class Slugifier:
def make(self, title: str) -> str:
return "-".join(title.lower().split())
# after — same behavior, no costume
def make_slug(title: str) -> str:
return "-".join(title.lower().split())
- In two sentences, tell a junior what a class buys that a function cannot, without using the words "encapsulation" or "object-oriented".
- Find one class in your code this week whose constructor binds nothing. Count the modules that import it, and name what deleting it would save them.
- Assume a stateless helper class keeps accreting methods for a year. Predict what its readers will get wrong about it, and what its tests will start needing.
Your win
You can classify any unit by its habitat: stateless transformations → functions; owned state and polymorphism → classes. The costume cases get undressed.
Read and watch deeper
- Python docs — Functional Programming HOWTO: pure functions and composition, the function habitat in depth.
- A Philosophy of Software Design, Ousterhout — ch. 4 "Modules Should Be Deep": a well-named function is a module; a class is one module shape among several.
- Fluent Python, Ramalho — ch. 7 (functions as objects): what Python functions can do that classes can't, and vice versa.
- Python docs — dataclasses reference: the standard way to build the parameter objects and value objects that this decision keeps producing.
- Diederich, "Stop Writing Classes" (PyCon US 2012) — the canonical talk against the one-method class. Watch it against this lesson's counter-case, where the constructor, not the method, is the reason to keep it.
- Watch: ArjanCodes YouTube — search "the mighty function".
- Reference: Glossary — deep module, higher-order function.
Bring a borderline class to your agent-teacher and run the habitat test together.
Source page: lessons/0015-classes-vs-functions.html
Lesson 0016 · System Designer · Module 1
Injecting Behavior: Strategies and Higher-Order Functions
Lesson 0004 extracted the Strategy as classes. Python's cheaper version: pass the behavior itself. Functions are strategies too — and often the better ones.
Mission tie-in: "inject behavior using strategies and higher-order functions" — the System Designer's move for making code evolve without rewrites.
Knowledge: behavior as an argument
A strategy is just a unit of behavior chosen at runtime. In Python, a callable is the cheapest possible strategy interface — no protocol, no class, no ceremony:
from collections.abc import Callable
Discount = Callable[[Money], Money] # strategy: total -> amount off
def percentage(rate: Decimal) -> Discount: # factory: makes a strategy
def apply(total: Money) -> Money:
return total * rate
return apply
def over_threshold(minimum: Money, then: Discount) -> Discount:
def apply(total: Money) -> Money:
return then(total) if total >= minimum else Money(Decimal("0"), total.currency)
return apply
def final_price(total: Money, discount: Discount) -> Money:
return total - discount(total)
# 10% off, but only on orders of 100 EUR or more:
policy = over_threshold(Money(Decimal("100"), "EUR"), percentage(Decimal("0.10")))
final_price never changes when pricing policy changes — new policies are new functions, passed in. That's the strategy pattern with the interface made implicit.
Higher-order functions — functions that take or return functions — give you composition for free:
def capped(discount: Discount, maximum: Money) -> Discount: # wraps a strategy
def apply(total: Money) -> Money:
return min(discount(total), maximum)
return apply
# 10% off, only over 100 EUR, never more than 20 EUR off:
policy = capped(
over_threshold(Money(Decimal("100"), "EUR"), percentage(Decimal("0.10"))),
maximum=Money(Decimal("20"), "EUR"),
)
Read what just happened: over_threshold and capped take a strategy and return a strategy. Each new rule is a small function, and business policy becomes an expression you can read top to bottom. The class version of this needs a decorator class per rule and an interface they all implement — the same design, three times the ceremony.
When should the strategy be a class instead? When it carries state (lesson 0015) or must participate in polymorphism with a nameable interface (lesson 0017). A stateless one-method strategy is a function's job.
Injection works by converting a modification into a composition. The decision about which behaviour runs moves from inside the module to its edge, and the core stops being on the change path. The measurable form of that is diff size: adding a variant to a branch chain touches the module, its imports and its tests; adding a variant to an injected seam adds a new function and one wiring line. Open–closed is not a moral principle, it is a statement about which files appear in the next pull request.
The bill arrives as lost traceability. A reader of the core can no longer see what runs; a stack trace shows a closure called apply; a log line says a discount was applied without saying which one. You have traded "what happens here is obvious, changing it is expensive" for "changing it is cheap, knowing what happened requires a tool". That trade is good when variants are many or arrive from outside your team, and bad when there is exactly one variant that has been stable for two years — then the branch is both cheaper and more honest. And once an injection point becomes something other teams plug into, its signature is a compatibility surface you support forever, which is a much larger commitment than the flag you deleted.
How a senior designer works the case
A retailer's pricing service, injected discount callables, no branch chain anywhere — textbook. Then a customer receives a promotion twice. Support cannot reproduce it; the order log contains one line: discount_applied=12.40. Three engineers spend a day reconstructing which policies were in the list, in what order, for that customer, on that day.
What they look at first. Not the stacking logic. The log line — because the design question here is not "is this extensible?" (it plainly is) but "when this misbehaves at 02:00, what will the on-call engineer be able to see?" The refactor that removed the branch also removed the only place a name existed.
The alternative they reject. Someone proposes moving pricing rules into a YAML rules engine so marketing can change them without a deploy. It is the same instinct — push variation outward — taken one step too far: it produces a second programming language with no type checker, no debugger, no tests and no code review, and it converts the 02:00 problem from "read three Python functions" into "debug an interpreter we wrote ourselves". (Lesson 0029 is the longer argument.) Rejected — but the impulse behind it is right: the rules did need identity.
What they choose. Keep the injected callables; give each one a name, and make the applied names part of the result rather than a log statement:
@dataclass(frozen=True)
class Policy:
name: str
apply: Discount # Callable[[Money], Money]
def percentage(rate: Decimal) -> Policy:
def apply(total: Money) -> Money:
return total * rate
return Policy(f"percentage:{rate}", apply)
def final_price(total: Money, policies: Sequence[Policy]) -> PricedTotal:
off = Money(Decimal("0"), total.currency)
applied: list[str] = []
for policy in policies:
amount = policy.apply(total)
if amount.amount:
off = off + amount
applied.append(policy.name) # the receipt travels with the price
return PricedTotal(total - off, tuple(applied))
The consequence over time. The next stacking incident is a grep, not an investigation: the order stores ("percentage:0.10", "percentage:0.10") and the duplicate is visible in the data. Finance's quarterly "why did this order get 22% off" is answerable without a code reading. The cost is real and small — a name per policy, and a team rule that lambdas are not allowed at this seam — and it buys back exactly the property injection took away. That is the general shape of the move: when you push a decision outward, push its identity outward with it, or you will pay for the flexibility in incident time.
functools.partial and closures do the same binding work without inventing classes: a partially applied function is a configured strategy.
| You see this | What it costs | The move |
|---|---|---|
def send(data, urgent: bool) | Control coupling: the caller steers with a flag it must decode. | Inject the behaviour; delete the branch. |
| if callback is None: callback = default | The default is a policy hidden inside the callee. | Choose the default at the composition root. |
| a class with __init__ and one method | A closure with extra syntax around it. | A function factory returning the callable. |
| a callable threaded through five layers | That parameter is configuration, not an argument. | Bind it once with functools.partial at the edge. |
- "Dependency injection means a DI container."
- Passing an argument is dependency injection. In Python the container is almost always optional and frequently a net loss: it moves wiring from readable code into a registry with its own lifecycle rules, and stack traces stop naming the thing that constructed your object.
- "Callables beat strategy classes."
- They win on ceremony and lose on identity. A class gives you a type name in tracebacks, a
repr, a home for construction-time validation, and room for a second related method. When either of those matters more than brevity, take the class. - "If it's injected, it's decoupled."
- The coupling moved; it did not vanish. Every implementation is now bound to a signature and to unwritten semantics — does a discount return the amount off, or the reduced total? Get that backwards and every strategy inverts silently. Contracts at a seam need a docstring and a shared test, or the seam is a trap.
- "More injection points means more flexible."
- Each one is a decision deferred to whoever wires the system. A module with six injected collaborators has not become configurable; it has relocated its design into its caller, who now needs to understand all six to use it correctly.
When this is the wrong move
- One variant, stable for years. Write the branch. A seam with a single implementation is an abstraction with no information in it, and readers pay for the indirection on every visit.
- When the choice arrives as data. If a database row or a queue message names the behaviour, you need a registry of names anyway — so start there rather than passing anonymous callables that no message can refer to.
- When the behaviour must be stored, scheduled or sent. You cannot put a closure in a queue, a database or a retry table. Persist the name and its parameters; resolve to a callable at the edge.
- When the seam becomes a public extension point. Then it is an API with a support obligation and a deprecation policy, and the cheap version — "just pass a function" — is the expensive version in disguise.
Skill: callable or class?
A stateless discount strategy is best injected as:
A callable function
A strategy class
A caller switch
An abstract class
Correct. Right: no state, no polymorphism — a callable is the strategy, with the interface made implicit.
A new pricing policy arrives. With injected callables you:
Add one new function
Edit the pricing core
Register the strategy class
Change the function signature
Correct. Exactly: new policy, new function, passed where the discount is chosen — the core never edits.
The class version of a strategy earns its keep when:
It binds its configuration
It must own state
It has one method
It needs a name
Correct. Right: when the strategy owns state that must persist across calls, a class is the honest home.
Practice on your own code
Find a function with a flag or a type field that selects behavior (a control-coupling smell from the coupling ladder). Replace the flag with an injected callable, and move the selection to the caller.
In the lab: the phase-2 checkpoint gives you a returns-resolution routine steered by two boolean parameters and a mode string, and asks you to replace them with injected, named behaviour — then to show, from the recorded result alone, which path a given return actually took.
Reveal: flag to injection
# before: control coupling — the flag steers behavior
def send_report(data, urgent: bool) -> None:
if urgent:
channel = pager()
else:
channel = email()
channel.deliver(data)
# after: the behavior is injected; no flag, no branch
def send_report(data, channel: Channel) -> None:
channel.deliver(data)
send_report(data, pager()) # or email() — the caller decides
- In two sentences, explain to a junior what injection takes away, not only what it gives.
- Find one injected callback or strategy in your code this week. Ask what a production log would tell you about which one ran, and price the gap in minutes of investigation.
- Assume this seam gains one new variant per quarter for a year, all anonymous. Describe the first incident where nobody can say what executed.
Your win
You can now turn flag-driven branches into injected behavior, and you know the class-vs-callable rule: state earns the class, everything else takes the function.
Read and watch deeper
- Fluent Python, Ramalho — ch. 7: higher-order functions, closures, and
functools.partialas behavior injection. - Python docs — functools reference: the standard toolkit for binding behavior without classes.
- Head First Design Patterns, Freeman et al. — ch. 1: the strategy intent, re-read now that you know the lighter-weight variant.
- Norvig, "Design Patterns in Dynamic Languages" — the primary source for why several classic patterns, Strategy among them, shrink to a function or become invisible in a language with first-class functions.
- Watch: ArjanCodes YouTube — search "higher order functions".
- Next: lesson 0017 — naming the extension point when you need one.
Convert a flag-parameter function with your agent-teacher and compare the injected-callable design.
Source page: lessons/0016-injecting-behavior.html
Lesson 0017 · System Designer · Module 1
Protocols and ABCs: Clean Extension Points
An extension point is a promise: "anything with these methods works here." Protocols make the promise structural; ABCs make it nominal. Choose by who's making the promise.
Mission tie-in: "use protocols and abstract base classes to design clean extension points" — how the System Designer opens a module for extension without weakening its core.
Knowledge: two ways to promise
Protocol — structural typing: an object qualifies by having the methods, whether or not it ever heard of your interface. The extension point is open to anything that walks like the duck:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Notifier(Protocol):
def send(self, message: str, recipient: str) -> None: ...
def notify_all(notifier: Notifier, users: list[User]) -> None:
for user in users:
notifier.send(f"Update for {user.name}", user.email)
ABC — nominal typing: an object qualifies by declaring itself a subclass, and the base can provide default behavior and enforce method presence:
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, message: str, recipient: str) -> None: ...
def send_bulk(self, messages: list[str], recipient: str) -> None: # shared behavior
for m in messages:
self.send(m, recipient)
How to choose:
- Protocol, when the module must accept things it doesn't own — third-party objects, framework callbacks, your own legacy code. The promise is "if you have these methods, you're in."
- ABC, when you own the family and want shared default behavior, enforced structure, or a place where the extension contract is discovered by subclassing.
Either way, the extension point should be deep (lesson 0001's reading): a small, stable promise that hides how much work it enables. If the interface is bigger than the implementation, it's not an extension point — it's a leak.
An interface is a promise about substitutability, and the mechanism you pick decides two things: who bears the cost of conforming, and which way the dependency arrow points in the source graph. Structural typing lets the consumer state exactly what it needs, so the interface can live beside the code that calls it and no implementation imports anything at all — dependency inversion with zero edges added to the import graph. Nominal typing makes the producer declare intent, which buys shared default behaviour, discoverability by subclassing, runtime checks and a place to evolve a family — paid for with an inheritance edge from every implementation back to your package, and therefore a release coupling between you and them.
Now the uncomfortable part, because it decides when either is worth anything. Neither mechanism enforces behaviour. A Protocol is checked only where a type checker runs, so in a codebase with no mypy or pyright in CI it is a comment with syntax highlighting. @runtime_checkable narrows that gap barely: isinstance then verifies that the names exist, not their signatures, so a class whose send() takes no arguments passes cleanly and fails at the call. An ABC gives you one real guarantee — instantiation fails if an abstract method is missing — which is still presence, not correctness; a subclass whose method raises NotImplementedError satisfies it. Substitutability in the Liskov sense is verified by exactly one thing in Python, and it is not the type system: a shared contract test that every implementation must pass.
How a senior designer works the case
A nine-method Notifier ABC lives in a shared/interfaces package imported by eight services owned by four teams. A new requirement needs a tenth method. The proposed change adds it to the base class.
What they look at first. Not the method. The package the interface lives in, and the list of things that import it — because that list is the blast radius of every future change to this promise. Then the usage: each service calls two or three of the nine methods, and every implementation stubs the rest.
The question they ask. "Who owns this interface — the callers or the implementations?" An interface owned by the implementations is a contract you must negotiate; one owned by a caller is a description of what that caller happens to need, and it can be changed by the person changing the caller.
The alternative they reject. Versioning the shared package, so teams can adopt shared-interfaces 2.x on their own schedule. It works, and it makes the coupling official: every interface change now needs a migration plan across four teams' release trains, and the nine-method promise — which no implementation ever needed in full — is preserved forever because removing anything is a breaking change.
# declared in the module that USES it, naming only what it calls
class SendsAlerts(Protocol):
def send(self, message: str, recipient: str) -> None: ...
class EscalationPolicy:
def __init__(self, alerts: SendsAlerts) -> None:
self._alerts = alerts # SlackClient imports nothing of ours
def check_sends_alerts(candidate: SendsAlerts, recorder: Recorder) -> None:
candidate.send("ping", "ops@example.com") # one contract test, parameterised
assert recorder.last_call == ("ping", "ops@example.com") # over every adapter
The consequence over time. The nine-method interface dissolves into three small ones, each sitting next to its consumer. Adding a method to the Slack client breaks nobody. The shared package stops being a place where four teams negotiate promises and becomes what it was good at — shared implementations. The honest cost: the same client now satisfies three protocols and no single file says so, so a change to it can break a consumer it never heard of. That is what the contract test is for, and it is the trade a senior takes knowingly — decentralised promises plus tests that catch drift, over one central promise that everyone has to schedule around.
† AI-generated code tends to reach forABC by default (it's the famous one). Review it: if nothing shares default behavior and nothing declares ancestry, a Protocol (or a bare callable, lesson 0016) was lighter and just as open.
| Your situation | Choose | Because |
|---|---|---|
| you must accept objects you don’t own | Protocol | Third-party classes will never subclass your base. |
| the variants share a real default step | ABC | The base is the only place that step can live once. |
| one method, no state, no family | a plain callable | The interface would add a name and nothing else. |
| you need a runtime isinstance check | ABC (or @runtime_checkable) | Structural checks verify method names, not signatures. |
| eight methods, implementations use three | neither, yet | The promise is bigger than the need — split it first. |
- "Protocols are typing sugar — duck typing already worked."
- It worked invisibly, which is the problem: the required shape existed only in the bodies of the functions that used it. Writing it down names the consumer's requirement, so it can be reviewed, narrowed and refactored instead of being rediscovered by whoever breaks it.
- "
@runtime_checkablemakesisinstancesafe." - It checks that the attribute names exist. Signatures, argument counts and return types are not verified, so a mismatched implementation passes the check and fails at the call site — one layer further from the cause (lesson 0014).
- "An ABC guarantees implementations are correct."
- It guarantees the methods exist at instantiation. A subclass that raises
NotImplementedError, returns the wrong type, or violates the base's documented ordering satisfies the ABC entirely. Correctness needs a contract test. - "Design the interface to cover the methods you'll probably need."
- Interface size is a tax charged to every implementation and every fake, forever. Unused methods get stubbed with
pass, then someone calls one. Add the method when the second implementation actually needs it.
When this is the wrong move
- You are publishing a plugin API to people you do not know. A nominal base class gives plugin authors a discoverable contract, a place for defaults, and somewhere to put deprecation shims. Structural typing gives them nothing to read and no signal when the shape changes.
- Your CI does not run a type checker. Then a Protocol enforces literally nothing. Either add the checker — the cheaper fix — or use an ABC, which at least fails at instantiation.
- The variants genuinely share a step. A template method in a base class is the only place that step can live exactly once. Protocols cannot carry behaviour.
- You need runtime dispatch on type. Registries keyed by class,
singledispatchandABCMeta.registerexist for this, and structural checks are the wrong tool.
Skill: which promise fits?
Your module must accept any object with a render() method, even ones you don't own. Choose:
A Protocol
An ABC
A callable
Class inheritance
Correct. Right: structural typing accepts anything with the method — no declaration, no ancestry required.
You own a family of parsers sharing one default step. Choose:
A Protocol
An ABC
Free functions
An Adapter
Correct. Exactly: owned family + shared default behavior is the ABC habitat.
An interface bigger than its implementations is a sign of:
Leaky extension point
A deep module
Good future-proofing work
A cohesion failure
Correct. Leaky is the right instinct — a fat promise forces every implementation to fake unused methods.
Practice on your own code
Find an ABC in your codebase (or an AI-generated one). Ask: do the subclasses share default behavior, or do they each implement everything? If the ABC is just a contract with no shared behavior and no external subclasses, convert it to a Protocol.
In the lab: the phase-2 checkpoint ships a returns codebase with one wide abstract base class that every adapter inherits and only partly implements. You narrow it to the methods each consumer actually calls, decide per seam whether a protocol or a base class fits, and add the contract test that neither mechanism gives you.
Reveal: an ABC that was really a protocol
# an AI wrote this: a contract with zero shared behavior
class Exporter(ABC):
@abstractmethod
def export(self, data) -> str: ...
# every subclass implemented export() fully; nothing shared; nothing external
# convert to a protocol — same contract, open to anything with export()
class Exporter(Protocol):
def export(self, data) -> str: ...
- In two sentences, explain to a junior why an interface is usually better owned by the code that calls it than by the code that implements it.
- Find one interface in your codebase this week and count, per implementation, how many of its methods are real. Name the cost of the stubs.
- Assume a shared base class keeps gaining a method every quarter across four teams. Predict what the release process looks like in a year.
Your win
You can now pick the extension-point mechanism by ownership and shared behavior — protocol for open, ABC for family — and you know the leak test for the interface's size.
Read and watch deeper
- Fluent Python, Ramalho — ch. 13 "Protocols and ABCs": the definitive Python treatment of the choice.
- Python docs — typing.Protocol: the structural-typing reference.
- A Philosophy of Software Design, Ousterhout — ch. 4: deep modules as the shape of good interfaces.
- PEP 544 — Protocols: Structural subtyping and PEP 3119 — Introducing Abstract Base Classes: the two primary sources, each with the rationale and the explicit limits of what it checks.
- Watch: ArjanCodes YouTube — search "protocol or abstract base class".
- Reference: Glossary — protocol; and Design Principles — open–closed, the principle an extension point serves.
Show your agent-teacher an AI-generated ABC and argue the protocol conversion together.
Source page: lessons/0017-protocols-and-abc.html
Lesson 0018 · System Designer · Module 1
Memory-Efficient Pipelines with Generators
Processing a terabyte of logs doesn't require a terabyte of RAM. Generators turn "load everything, then process" into "process as it flows" — with the same readable shape.
Mission tie-in: "build memory-efficient data pipelines with generators and streaming" — the composability skill that keeps large-data code honest and small.
Knowledge: lazy stages, chained
A generator produces values one at a time, on demand. Chain generators and each stage processes a single item as it flows past — memory stays flat no matter how much data streams through:
def read_lines(path: str):
with open(path) as f:
for line in f: # the file itself streams
yield line
def parse(line: str) -> LogEntry:
...
def error_entries(lines):
for line in lines:
entry = parse(line)
if entry.level == "ERROR":
yield entry
def summarize(entries) -> Counter[str]:
counts = Counter()
for entry in entries:
counts[entry.service] += 1
return counts
total = summarize(error_entries(read_lines("app.log")))
At any moment, exactly one line is in memory: the file streams, each stage transforms one item, the counter aggregates. The pipeline reads like a sentence — summarize(error_entries(read_lines(...))) — and each stage is independently testable.
The contrast is the list-based version: read all lines into a list, build a filtered list, build a parsed list… each intermediate list is the whole dataset again. That's the memory failure mode of naive code — and of AI code, which defaults to lists.
Laziness does not remove work; it changes when the work happens and who is holding things while it happens. Peak memory falls from O(n) per stage to O(1) per stage because no stage ever owns a whole collection. What you hand over in exchange is everything that depends on the collection existing: random access, length, a second pass — and, less obviously, the execution context. The body of a generator runs inside the consumer's call frame, at the consumer's moment, which means errors, resource lifetimes and transaction boundaries migrate to the consumer too. That single fact explains almost every generator bug worth knowing about.
So the rule stops being true in four places. When you need more than one pass, laziness costs you the data. When the generator owns a resource — an open file, a database cursor, a transaction — the resource now closes whenever the consumer finishes, which may be never. When an argument is invalid, the exception is raised not where the generator was called but where someone iterated it, sometimes in another module, which is the exact inversion of lesson 0014. And when the sink wants batches, per-item streaming turns one round trip into a million.
How a senior designer works the case
An export endpoint in a long-running web worker. The pipeline is the textbook one above: read_lines opens the file and yields inside the with. A preview feature wraps it in islice(stream, 100). Two weeks later, the box serving the most previews dies with OSError: too many open files.
What they look at first. Not the memory profile — the with statement's position relative to the yield:
def read_lines(path: str):
with open(path) as f: # the context manager's exit is inside the generator
for line in f:
yield line # abandoned here, the file stays open
The question they ask. "Who owns this resource, and at what moment does it close?" The answer is uncomfortable: it closes when the generator is exhausted, or when the generator object is collected and CPython throws GeneratorExit into the suspended frame. Refcounting usually makes that prompt — and "usually" is not a design. Hold a reference in a cache, a partially consumed result, or an exception traceback, and the descriptor lives as long as the reference does.
The alternative they reject. Going back to return f.readlines(). It restores deterministic cleanup and reinstates the original problem: this endpoint exports files of several gigabytes, which is why the pipeline was written lazily in the first place. Trading a descriptor leak for an out-of-memory kill is not progress.
def parse_lines(lines: Iterable[str]) -> Iterator[LogEntry]:
for line in lines: # owns no resource; owns no lifetime
yield parse(line)
def export(path: str) -> Response:
with open(path) as f: # the request owns the file, and closes it
return stream_response(parse_lines(f))
The second half of the same case. The fixed pipeline writes its results to Postgres, one row at a time, because streaming is the habit now. Two million rows at half a millisecond per round trip is roughly seventeen minutes of network latency doing nothing else. The fix is the other half of the rule — stream between stages, batch at the sink:
def batched(iterable, size): # itertools.batched, on Python 3.12+
iterator = iter(iterable)
while chunk := list(islice(iterator, size)):
yield chunk
for chunk in batched(entries, 1000): # flat memory, 2,000 round trips
db.insert_many(chunk)
The consequence over time. Both fixes are small, and both come from the same idea rather than from generator trivia: laziness moves ownership to the consumer, so decide deliberately what the consumer owns. Teams that skip this end up with the two classic outcomes — a service that leaks descriptors under partial consumption, and a nightly job that is memory-perfect and four hours long.
† Composability rule: a generator stage should take an iterable and yield items — never materialize a collection in between.itertools (chain, islice, groupby, takewhile) is the standard library's pipeline toolbox.
| You see this | What it costs | The move |
|---|---|---|
rows = f.readlines() | The whole file lands in memory before any work starts. | for line in f: — the file is already a stream. |
| [x for x in …] between two loops | One extra full copy of the dataset per bracket pair. | Drop the brackets: a generator expression. |
| len(stream) or stream[0] | Something materialized the stream just to index it. | itertools.islice, or count as you go. |
| a generator consumed twice | The second pass sees nothing — a silent empty result. | itertools.tee, or materialize once, deliberately. |
- "Generators are faster."
- They lower peak memory, which is often why a job gets faster — it stops swapping or stops being killed. Per item they add overhead, so over a few thousand elements a list comprehension usually wins. Streaming is a memory and latency decision, not a speed trick.
- "A generator holds no memory."
- It holds its suspended frame: every local, everything it closed over, and every resource it opened. A generator iterating a list keeps the whole list alive. What it avoids is materialising new collections between stages.
- "Nothing runs until you iterate, so mistakes show up early."
- The opposite. Calling a generator function runs none of its body, so a bad argument raises nothing at the call — the error appears at the first
next(), often in a different module, with a traceback pointing at the consumer. Validate arguments in a plain wrapper function that returns the generator. - "I can iterate it twice."
- The second pass yields nothing, silently — no exception, just an empty result and a wrong total. If two passes are needed, materialise once on purpose, or build the source twice.
When this is the wrong move
- Small, fixed collections. A list you can print, index, and see in a debugger is worth more than flat memory you did not need.
- Multi-pass work. Sorting, joining, or any statistic that needs a second look at the data. Materialise deliberately and say why in a comment.
- Vectorized numeric code. NumPy, pandas and polars are fast because they operate on whole arrays; per-element Python iteration is the slow path you are trying to avoid.
- When diagnosis matters more than memory. Deferred execution scatters stack traces across modules. On a critical path that fails rarely and expensively, an eager version that fails at the call site can be the better engineering.
Skill: list or stream?
A pipeline over a huge file should be built with:
Chains of materialized lists
Chained stages of generators
Read everything then process
Using parallel worker threads
Correct. Right: generator stages stream one item at a time, keeping memory flat.
A generator stage should accept and return:
Iterable in, items out
List in, list out
One item, one call
Everything loaded, then filtered
Correct. Exactly: take an iterable, yield items. That lazy contract is what lets any two stages be chained without a copy in between.
The right time to reach for generators is:
Whenever lists are used
Large or streaming data
Any pipeline of transforms
Always, with no exception
Correct. Right: large or streaming data, or infinite sequences — that's the generator habitat.
Practice on your own code
Find a function that builds a list, transforms it into another list, then another (three or more materializations). Convert it to chained generator stages with an iterable contract, and measure the peak memory if you can.
In the lab: the phase-2 checkpoint has a returns export that builds three full lists in a row and then writes rows one at a time. You convert the middle to streaming stages, batch the write, and decide where the file handle should be opened once you have.
Reveal: list pipeline → stream
# before: three full copies of the dataset
rows = fetch_all_rows()
filtered = [r for r in rows if r.active]
parsed = [parse(r) for r in filtered]
total = sum(r.amount for r in parsed)
# after: one pass, flat memory
def active(rows):
for r in rows:
if r.active:
yield r
total = sum(parse(r).amount for r in active(fetch_rows_stream()))
- In two sentences, explain to a junior why "stream between stages, batch at the sink" is one rule rather than two.
- Find one generator in your code this week that opens a resource. Say what happens to that resource if a caller stops iterating halfway.
- Assume your team converts every pipeline to generators over the next year. Name the failure you expect first, and the metric that would reveal it.
Your win
You can spot the list-chained pipelines in your code (and AI output), convert them to flat-memory streams, and keep each stage composable and testable.
Read and watch deeper
- Fluent Python, Ramalho — ch. 17 "Iterators, Generators, and Classic Coroutines": generator fundamentals and pipeline composition.
- Python docs — itertools reference: the streaming toolbox; and the Functional Programming HOWTO.
- Effective Python, Slatkin — the items on generators: when lazy evaluation wins and when it doesn't.
- PEP 255 — Simple Generators: the primary source, including why a generator's frame is suspended rather than copied, which is the mechanism behind every ownership question above.
- Watch: ArjanCodes YouTube — search "generators python".
- Next module: lesson 0019, managing complexity in growing applications.
- Reference: Patterns — generator pipeline, with the iterable-in/items-out contract.
Bring a list-chained function to your agent-teacher and convert it to a stream together.
Source page: lessons/0018-generators-and-streaming.html
Lesson 0019 · System Designer · Module 2
Identifying and Reducing Global Coupling
A module-level mutable value is a phone line to every file in the project. Global coupling is the quietest way a codebase learns to depend on call order.
Mission tie-in: "identify and reduce global coupling" — the System Designer's first complexity leak, and the one AI code most reliably introduces with caches and singletons.
Knowledge: the hidden phone line
Global state is any state reachable from anywhere: a module-level mutable container, a singleton, a cache, a registry. The coupling it creates is invisible — no import declares it, but every consumer shares it:
# config.py
_settings: dict = {} # module-level mutable state
def set_setting(key: str, value) -> None:
_settings[key] = value
def get_setting(key: str):
return _settings.get(key)
# service_a.py
import config
config.set_setting("rate_limit", 10) # runs at import time
# service_b.py
import config
limit = config.get_setting("rate_limit") # 10 — only if service_a imported first
The bug is in the wiring, not the values: service_b's behavior depends on service_a having run first. Import order, test order, and deployment order all become part of your program's semantics. Change a test file and the behavior changes — with no diff pointing at the cause.
The fix is to make the dependency explicit — pass it, own it, inject it:
class RateLimiter:
def __init__(self, limit: int) -> None:
self._limit = limit
self._used = 0
def allow(self) -> bool:
if self._used >= self._limit:
return False
self._used += 1
return True
# wiring happens once, visibly, at the composition root:
limiter = RateLimiter(limit=10)
service_b = ServiceB(limiter=limiter)
Now the dependency appears in the constructor — reviewable, mockable, and ordered by whoever builds the system, not by import order.
Global mutable state converts a static dependency graph into a temporal one. Coupling through imports is bounded by the graph and can be read by a tool; coupling through shared mutable state is bounded only by the process, and no static analysis will find it, because the connection is made at runtime by whoever touched the value first. Two things follow mechanically. A function's signature stops describing its contract — its real inputs now include every global it reads. And tests stop being independent, which costs more than it sounds: once results depend on order, a green build is no longer evidence, and the team learns that red means "run it again".
That gives the honest boundary. Some state genuinely is process-scoped: logging configuration, a metrics registry, a connection pool, the module cache itself. The rule that survives contact with real systems is not "no globals" but a global must be immutable after the composition root, or owned with an explicit lifecycle. And in a 200-line CLI that runs once and exits, threading a dependency through four layers can cost more than the ambient value ever will. The question is never the keyword — it is who else can observe this state, and how long the process lives.
How a senior designer works the case
A twenty-person team, a fourteen-minute pipeline, and three tests that fail perhaps one run in six. Nobody can reproduce them locally. The team's coping strategy is a rerun button.
What they look at first. Not the three tests. They run the whole suite in a randomised order with a recorded seed (pytest-randomly and friends do this), because the question is not "which test is flaky" but "is my suite order-independent at all?" Twenty failures appear. The three flaky ones were the only cases where the usual alphabetical order happened to be unlucky.
The cost they name out loud. Not the twenty failures — the rerun habit. One extra pipeline run per merge on a team merging dozens of times a week is hours of wall-clock delay, and, far worse, it trains everyone to treat a red build as noise. The next real regression will also be rerun. That is the argument that gets the work funded; "globals are bad practice" is not.
The alternative they reject. An autouse fixture that resets the module's dictionaries between tests. It goes green in an afternoon, and it is a tax that grows with every new global, paid by every future test author. It also fixes the wrong system: production still depends on import order, so a worker that imports lazily still behaves differently from the web process. They keep it — as a dated entry in the debt ledger, not as the fix.
What they choose. Strangle it rather than rewrite it. The state gets an owner; the old global becomes a shim over one instance, with a countable number of remaining callers:
class PluginRegistry: # step 1: something owns the state
def __init__(self) -> None:
self._plugins: dict[str, Plugin] = {}
def register(self, name: str, plugin: Plugin) -> None:
self._plugins[name] = plugin
_default: PluginRegistry | None = None # step 2: an honest, temporary shim
def default_registry() -> PluginRegistry:
global _default
if _default is None: # 23 call sites remain; see the debt ledger
_default = PluginRegistry()
return _default
The consequence over time. New code takes a registry as a parameter; old code goes through the shim; the number of shim callers is a figure in the debt ledger that only ever goes down, and a fitness test (lesson 0022) fails the build if a new module-level mutable container appears in the package. Six weeks later the count reaches zero and the shim is deleted. The team never had to schedule a big-bang refactor, and — the part that actually matters — the suite became evidence again.
† The greedy-review test: grep for module-level mutable containers (= {}, = [], = 0 at module scope) and singletons. Every hit is a candidate phone line. Exceptions: constants (immutable) and caches whose invalidation you own consciously.
| You see this | What it costs | The move |
|---|---|---|
| _registry = {} at module scope | Behaviour depends on which module imported first. | Build the registry at the composition root. |
| @lru_cache on a config reader | The first call freezes the value for the whole process. | Cache on an object you can create and discard. |
| a test that passes alone, fails in the suite | State carried between tests by something shared. | Inject it; give every test its own instance. |
| os.environ[…] read at import time | Deployment config baked in before main() runs. | Read it at the composition root; pass the value down. |
- "Singleton is a design pattern, so it's fine."
- Singleton is a rule about lifetime, and it is usually implemented as a global with a lock attached. Naming it after a pattern changes nothing about who can observe the state or when it was first created.
- "Module-level constants are global state too."
- Immutable values shared by everyone are fine — that is what a module is for. The defect is mutation plus ordering. Watch the fakes, though: a module-level list or dict named in capitals is still mutable, and someone will append to it.
- "Fixing this requires a dependency-injection framework."
- It requires a
main()that constructs the objects and passes them down. That function is the composition root. Containers are optional, and in Python they usually trade readable wiring for a registry with its own lifecycle bugs. - "Thread-locals and
contextvarsaren't global." - They narrow the audience, not the ownership. The value is still ambient, still absent from every signature, and still set by whoever ran first in that context. They are the right tool for request-scoped context — with a documented owner and a place where it is set, exactly once.
When this is the wrong move
- Process-wide infrastructure. Logging configuration, tracing, metrics registries and connection pools are legitimately one per process. Construct them at the composition root, freeze them after startup, and do not let application code reconfigure them at runtime.
- Scripts and short-lived CLIs. One entry point, one run, no test suite to protect: the injection plumbing can cost more than the coupling.
- When the alternative is threading a parameter through layers that don't care. A request context passed through fifteen frames that ignore it is its own kind of noise. Use an explicit context object at the edge, or
contextvarswith one owner — and write down which you chose and why. - Caches of genuinely pure functions.
functools.lru_cacheover a function of its arguments alone is fine. It stops being fine the moment that function reads a config value, a clock or an environment variable.
Skill: find the phone lines
A module-level mutable dict read by two services creates:
No coupling at all
Only a thread issue
A hidden call-order coupling
A contained, owned dependency
Correct. Exactly: behavior now depends on which module ran first — import order semantics.
The right home for a rate limit shared by two services is:
A module global
An injected object
A singleton object
The app context
Correct. Right: one explicitly constructed object, passed to both services at the composition root.
A module-level cache is acceptable when:
You own invalidation
Callers can mutate
Staleness is fine
It's rarely written
Correct. Right: an owned cache whose invalidation you consciously manage is a deliberate trade — name it, own it.
Practice on your own code
Grep your project (and AI output) for module-level mutable containers and singletons. Pick one that two or more modules read or write. Convert it to an owned, injected object and move construction to a composition root.
In the lab: the phase-2 checkpoint asks you to find the module-level registry the returns service mutates at import time, give the state one owner, and leave a shim behind so the migration can be done in slices — plus the order-randomised test run that proves it worked.
Reveal: a worked de-globalization
# before: settings._registry mutated at import time by several plugins
import settings
settings.register_plugin(MyPlugin()) # import side effects everywhere
# after: a PluginRegistry object built once, explicitly
registry = PluginRegistry()
registry.register(MyPlugin())
app = build_app(registry)
Plugin registration is now visible in the build code, testable in isolation, and immune to import order.
- In two sentences, explain to a junior why an order-dependent test suite costs more than the flaky tests themselves.
- Find one module-level mutable value in your codebase this week. Name what would change if two modules imported in the other order.
- Assume nothing is done and the service grows a second entry point — a worker, a CLI, a migration. Predict the first behaviour difference between them.
Your win
You can grep for global coupling, name the hidden dependency it creates (call order), and replace it with an injected, owned object — with the composition root as the one place wiring happens.
Read and watch deeper
- The Pragmatic Programmer, Hunt & Thomas — the sections on global data and "decoupling": the classic treatment.
- Code Complete, McConnell — ch. 5: global data as the worst coupling level (common coupling).
- A Philosophy of Software Design, Ousterhout — ch. 5 "Information Hiding": what the module exposes is a choice; globals expose everything.
- Fowler, "Inversion of Control Containers and the Dependency Injection Pattern" — the primary source for the distinction this lesson leans on: injection is passing the dependency; the container is an optional extra.
- Watch: ArjanCodes YouTube — search "global state" or "dependency injection".
- Next: lesson 0020 — shared state and ownership.
- Reference: Design Principles — separate creation from use; glossary — global coupling, composition root.
Run the greedy-review grep with your agent-teacher and decide each hit together.
Source page: lessons/0019-global-coupling.html
Lesson 0021 · System Designer · Module 2
Practical, Maintainable Error Handling
Lesson 0014 said fail fast at the right boundary. This lesson builds the full strategy: where errors are raised, how they travel, and where they are translated, retried, and logged.
Mission tie-in: "design practical and maintainable error handling strategies" — the difference between exceptions as a mechanism and exceptions as a language.
Knowledge: a language, not a mechanism
An error strategy has four layers, each with a job:
- Raise — in the domain, at the moment of refusal (lessons 0011, 0014).
- Translate — at boundaries, wrap foreign errors in your vocabulary.
- Retry — at integration boundaries, where failures are transient.
- Log and report — at the edges, once, with the error's story intact.
An exception is an edge between the place that detects a problem and the place with the context to decide what to do about it. Those two are almost never the same, and the distance between them is the design. The domain knows what is invalid; only the adapter knows whether a failure was transient; only the edge knows who is waiting and what they can do with the answer. Handle too early and you destroy information the later decision needs; handle too late and the decision is taken by code that no longer knows what was attempted.
The four layers are therefore four kinds of knowledge, not four conventions — which is where every rule below comes from: don't catch what you can't decide about, don't erase a cause you didn't understand, don't log where you don't yet know the outcome.
Where it stops applying. In a 200-line script the raise site and the edge are twenty lines apart, so a hierarchy is ceremony. A library has no edge at all: it raises and translates, but logs nothing and retries nothing, because both take decisions that belong to its caller. And where failure is the normal path at volume — a parser over millions of rows — an exception per item costs speed and clarity both; an accumulated result is the honest shape. The example below sits on that boundary.
The language is your exception hierarchy — flat, named, and domain-shaped:
class CheckoutError(Exception):
"""Base for everything checkout can fail with."""
class InsufficientStock(CheckoutError):
pass
class PaymentDeclined(CheckoutError):
def __init__(self, provider_message: str) -> None:
self.provider_message = provider_message
super().__init__(f"payment declined: {provider_message}")
class PaymentGatewayUnavailable(CheckoutError): # retryable, not a decline
pass
Each layer then handles exactly what it is placed to handle. Retry belongs to the adapter, because only the adapter knows the failure was a transport problem:
# infrastructure/ — retry wraps the port, never the rule
class RetryingPaymentGateway:
def __init__(self, inner: PaymentGateway, attempts: int = 3) -> None:
self._inner = inner
self._attempts = attempts
def charge(self, amount: Money, token: str) -> str:
for attempt in range(self._attempts):
try:
return self._inner.charge(amount, token)
except PaymentGatewayUnavailable:
if attempt == self._attempts - 1:
raise # out of attempts: let it travel
time.sleep(2 ** attempt) # backoff lives here, not in the domain
Translation and logging belong at the edge, where the error leaves the system and someone outside has to be told something useful:
# api/ — translate once, log once
def checkout_endpoint(cart: Cart, service: CheckoutService) -> Response:
try:
service.checkout(cart)
except InsufficientStock as e:
return Response(409, str(e)) # a client mistake
except PaymentDeclined as e:
return Response(402, e.provider_message) # the bank said no
except CheckoutError:
logger.exception("checkout failed") # logged once, with the traceback
return Response(500, "checkout unavailable")
return Response(201, "created")
The domain in the middle contains no try at all. It raises its own vocabulary and gets on with the rules — which is why its tests are three lines long.
Four rules keep it maintainable: never catch Exception bare; never convert an error to None at the raise site; catch only what you can handle, at the boundary where handling means something; log once, where the error leaves the system.
Worked example: the catch that cost a quarter's reconciliation
A four-person payments team runs a nightly reconciliation over roughly 12,000 settlement rows. Inside the row loop sits except Exception: continue. The job is green every morning.
A senior designer reads the line after the catch first. continue is a statement of policy: this failure has no consequence. So the question is not "should we catch here?" but "who is entitled to decide a settlement row doesn't matter?" Nobody in that loop has the standing, because the loop cannot see the reject rate — and the rate is the only thing separating one bad row from a broken feed. Logging inside the loop is rejected for the same reason: it turns a silent failure into an unread one and leaves the decision where it was.
Price the drift. A 0.3% failure rate is 36 rows a night, about 1,080 unreconciled settlements over a month. At a median €48 a row that is roughly €52,000 of cash the ledger cannot attribute — found by finance at quarter close, not by engineering, and reconstructed through a two-week manual audit. The defect was one line; the bill was a fortnight of someone else's quarter.
The fix moves the decision up one layer and makes the rate visible — landing on the boundary the principle box named, where an accumulated result beats an exception per row.
# application/ — the batch owns the run, so the batch decides
@dataclass(frozen=True)
class RowRejected:
row_id: str
reason: str
def reconcile(rows: list[dict], ledger: Ledger) -> BatchResult:
posted, rejected = 0, []
for row in rows:
try:
ledger.post(parse_settlement(row)) # raises SettlementInvalid
posted += 1
except SettlementInvalid as invalid: # one named failure, handled
rejected.append(RowRejected(row["id"], str(invalid)))
if len(rejected) > len(rows) * 0.01: # a broken feed, not a bad row
raise ReconciliationAborted(posted, rejected)
return BatchResult(posted=posted, rejected=tuple(rejected))
Three things changed and only one is code. The catch is specific, so an unexpected failure still travels. The rejects are data, so the run reports "11,964 posted, 36 rejected" to a dashboard rather than to a log nobody opens. And the threshold makes the policy reviewable. Eighteen months later, when a partner silently changed its date format, the job failed on the first night instead of the ninetieth.
† A hierarchy that's more than two levels deep is usually decoration — "checkout" and "payment" under one base is enough. Retry logic never belongs in the domain (it's coordination); it belongs at the integration adapter.| You see this | What it costs | The move |
|---|---|---|
raise MyError(str(e)) | The original traceback is thrown away at the boundary. | raise MyError(...) from e — keep the cause. |
| a retry loop inside a domain method | Coordination in the business rules; the tests now sleep. | Wrap the adapter (as above); the rule stays pure. |
| the same failure logged at three levels | One incident, three alerts, no coherent story. | Log once, where the error leaves the system. |
raise Exception("bad request") | Callers can catch everything or nothing — no middle. | A named class inside your hierarchy. |
- "I caught the exception, so I handled it."
- Handling means changing the outcome.
except: passandexcept: logdeclare that the failure has no consequence — usually from the layer least qualified to say so. - "Wrapping an error throws away the stack trace."
- Only if you drop the cause.
raise InvoiceRejected(...) from originalkeeps the whole chain and adds your vocabulary on top. Translation preserves;raise MyError(str(e))erases. - "Retries make a system resilient."
- Only where repetition is safe. Retrying a non-idempotent write turns one timeout into two charges. A retry needs an idempotency key, or proof the failure happened before any effect — otherwise it is an outage that bills the customer twice.
- "Log at every level so nothing is missed."
- One incident then produces three entries at three severities and a timeline reassembled by hand. Log once at the edge with the cause chain attached: that entry already says everything the three would have.
When this is the wrong move
Skip the machinery when the layers collapse into each other. A one-file script has no boundary worth translating across. A library raises its own types and stops. Loops where failure is the expected case want an accumulated result, not an exception per item. And don't build a hierarchy before two callers actually distinguish two failures — a base class with one subclass is a naming ceremony, and the second class costs nothing to add on the day the second decision appears.
Skill: where does each error go?
A gateway times out on a charge that may already have succeeded. It should be:
Retried behind an idempotency key
Retried blindly by the adapter
Reported to the caller immediately
Retried inside the domain rule
Correct. Right: the adapter owns the retry, and the idempotency key is what makes repeating the call safe instead of expensive.
An ORM raises its own database error type. At the repository boundary you:
Let it pass through
Translate it to yours
Swallow the error whole
Catch Exception too broadly
Correct. Exactly: wrap it in your vocabulary with 'from', so handlers speak one language and the cause survives.
An error should be logged exactly once, where:
Each catch site logs
It leaves the system
The raise site logs
At the outermost caller
Correct. Right: at the edge, where the error leaves the system — one story, complete, with cause attached.
Practice on your own code
Audit one module's exception handling: list every try/except, what it catches, and what it does. Mark each as raise, translate, retry, or log-and-report. Delete the bare excepts; move retries to boundaries; name the hierarchy if there isn't one.
In the lab. The phase-2 checkpoint hands you the returns-resolution service with a bare except Exception around the carrier call that quietly returns None. You classify every catch as raise, translate, retry or report, and move the retry into the adapter that can prove the call is safe to repeat.
Reveal: a layered fix
A SyncService catches Exception around an API call, prints, and continues — silent partial failure. Fixed: a SyncError base; the HTTP adapter raises SyncRetryable for 5xx (retried once by the adapter), SyncRejected for 4xx (passed through); the service logs once per failed batch with per-item causes.
- Explain to a junior, in two sentences, why the retry belongs in the adapter and not in the rule.
- Find one
exceptin your own code this week whose next line ispass,continue, or a log call. Name its cost: who finds out, how late, and what they have to do about it. - Predict what breaks in a year if that catch stays: what is the first symptom, and who reports it?
Your win
You can classify every error in a module into raise / translate / retry / report, build a flat domain-shaped hierarchy, and keep retries and logging out of the domain.
Read and watch deeper
- Clean Code, Martin — ch. 7 "Error Handling": the exception-hierarchy and boundary-translation rules.
- A Philosophy of Software Design, Ousterhout — ch. 10 "Define Errors Out of Existence": design so many errors never arise.
- "The Error Model", Joe Duffy — the long primary account of bugs vs recoverable errors, and why that distinction decides where you catch.
- Robust Python, Viafore — Python-specific exception design: when to raise, subclass, and wrap.
- Watch: ArjanCodes YouTube — search "error handling python".
- Next: lesson 0022 — organizing modules and folders.
- Reference: Patterns — Decorator, the shape the retrying gateway above is; glossary — fail-fast.
Bring your module's try/except inventory to your agent-teacher and classify each catch together.
Source page: lessons/0021-error-handling-strategies.html
Lesson 0022 · System Designer · Module 2
Organizing Modules and Folders Around Boundaries
Folders are the interface your team reads first. Organize by responsibility and boundary — not by file type — and the architecture announces itself in the tree.
Mission tie-in: "organize modules and folders around responsibilities and boundaries" — the layout that keeps a growing application navigable and its dependency arrows visible.
Knowledge: the tree should tell the story
A project organized by file type — models/, services/, controllers/, utils/ — tells you nothing about what the system does. A project organized by boundary — domain, application services, infrastructure, delivery — tells you how change flows before you open a file:
checkout/
├── domain/ # the core: rules, value objects, aggregates (nothing imports out)
│ ├── order.py
│ ├── money.py
│ └── pricing.py
├── application/ # use cases: orchestrate domain via ports
│ ├── checkout_service.py
│ └── ports.py # PaymentGateway, OrderRepository, Inventory…
├── infrastructure/ # adapters: the world conforming to the ports
│ ├── stripe_adapter.py
│ ├── sqlalchemy_repository.py
│ └── email_adapter.py
└── api/ # delivery: HTTP, CLI, whatever the outside speaks
└── endpoints.py
A folder tree is a cache of the answers to two questions every developer — and every AI agent — asks dozens of times a day: where does this go? and what may this touch? A file-type tree answers neither, so both are re-derived, badly, by whoever is typing. That is the whole mechanism, and it explains why the payoff scales with the number of people rather than the number of files.
Underneath sits something stronger. Change is local only when you can enumerate a module's dependents cheaply. One-way arrows make that enumeration a fact about the tree instead of a search across the repo; a cycle makes it impossible, because every member of the cycle is a potential dependent of every other. Foldering by boundary is not tidiness — it is the thing that keeps the blast radius of a change knowable.
Where it stops applying. A tree only pays when there is more than one boundary to keep apart: a 400-line tool with one boundary should be one file, and splitting it buys navigation cost with no isolation. Foldering also assumes you know the boundaries — in a domain you are still discovering, an early tree hardens the wrong seams and every later correction is a mass file move. One package until the third feature reveals the seam is the honest default there. And the four names below are one instantiation, not the pattern: a feature-first tree (returns/, payments/, each with its own domain and adapters) satisfies the same principle and usually ages better past about twenty thousand lines.
The rules that keep it stable:
- Dependency direction is visible in the tree:
domainimports nothing outside itself;applicationimportsdomain;infrastructureimports both;apiimports application. A cycle shows up as an import you can't explain. - Naming by role, not by technology: "stripe_adapter" says what it is; "payments" as a sibling folder to "domain" says nothing.
- No
utils/graveyard: a utility that doesn't belong to any boundary is a cohesion failure waiting to be named. - Boundaries match teams when they can: a boundary that matches a team's ownership (lesson 0033) makes coordination follow the tree.
A convention nobody enforces decays within a quarter. Turn the rules into a test and the tree becomes a fitness function — the arrows fail the build instead of failing review:
# tests/test_architecture.py
FORBIDDEN = {
"checkout.domain": ("checkout.application", "checkout.infrastructure",
"checkout.api"),
"checkout.application": ("checkout.infrastructure", "checkout.api"),
"checkout.infrastructure": ("checkout.api",),
}
def test_dependency_direction() -> None:
for package, forbidden in FORBIDDEN.items():
for module in walk_modules(package):
for imported in imports_of(module):
assert not imported.startswith(forbidden), (
f"{module} imports {imported}: the arrow points outward"
)
Fifteen lines — walk_modules and imports_of are a few more, built on pkgutil and ast — and the boundary is now a fact about the build rather than a promise in a README. (Tools like import-linter do the same job from a config file; what matters is that something checks.)
Worked example: the re-folder nobody could merge
Nine engineers, one repo, about 90,000 lines, laid out as models/ services/ controllers/. Everyone agrees it should be re-foldered. Two previous attempts died in review.
A senior designer does not start with the folders. They start with the imports, because the folders are an opinion and the imports are evidence: a twenty-line script over ast counts cross-folder import pairs. The finding is specific — services imports controllers in fourteen places, because the request-parsing helpers happen to live there. That is the arrow that should not exist, and it names the actual defect: fourteen files, not nine hundred.
The question they ask next is deliberately not "what should the tree look like?" but "for each folder, what is the reason it changes?" — because a folder whose reason to change you cannot state in one clause is not a boundary (lesson 0006), and moving files into it just relocates the confusion.
The rejected option is the tempting one: re-folder everything in a single pull request. It fails on arithmetic rather than on principle. Nine engineers carry roughly twenty open branches at any time; a 900-file move rewrites the import line of nearly every one of them, so each branch owner pays a manual rebase, and git blame stops answering "why is this line here?" for the next quarter — exactly when the team most needs to read its own history.
What ships instead is a ratchet. Add the fitness test with today's violations frozen into an allowlist; forbid new entries; move one folder per sprint and delete rows from the list as they are fixed. The allowlist is a visible architecture-debt ledger — fourteen lines that only ever get shorter.
# tests/test_architecture.py — today's violations are frozen; new ones fail
KNOWN_VIOLATIONS = {
("checkout.application.pricing", "checkout.api.serializers"),
# …13 more. This set may shrink. It may never grow.
}
def test_dependency_direction() -> None:
found = set()
for package, forbidden in FORBIDDEN.items():
for module in walk_modules(package):
for imported in imports_of(module):
if imported.startswith(forbidden):
found.add((module, imported))
assert found <= KNOWN_VIOLATIONS, f"new violation: {found - KNOWN_VIOLATIONS}"
assert not (KNOWN_VIOLATIONS - found), "fixed — delete these from the allowlist"
Five sprints later the set is empty and the second assertion deletes itself. Nothing was merged that a reviewer could not read in one sitting, and no branch owner paid a rebase they had not chosen. The point worth carrying: the tree was never the deliverable — the arrow count was.
† The foldering test: open the tree and point at each folder, saying what it is and what it may import. If you hesitate on a folder's job or its allowed imports, that folder is a boundary that hasn't been drawn.| Folder | May import | Must never import |
|---|---|---|
domain/ | nothing outside itself | application, infrastructure, api, any SDK |
application/ | domain | infrastructure, api, any SDK |
infrastructure/ | domain, application, third-party SDKs | api |
api/ | application (and the domain types it passes) | infrastructure, directly |
- "Boundary foldering means these four folders."
- Domain / application / infrastructure / api is one instantiation. What the principle requires is that each folder has a stated reason to change and a stated import rule. A feature-first tree meets that too, and scales better when features outnumber layers.
- "The tree enforces the architecture."
- Folders enforce nothing — Python will import across any of them. Only a test enforces. A tree without a fitness function is a convention, and conventions decay at the speed of onboarding.
- "
utils/is fine as long as it stays small." - Size is not the defect; ownership is. It grows precisely because nothing repels additions — there is no owner to say "this doesn't belong here", so the marginal cost of one more file is always zero.
- "Moving files is a zero-risk refactor."
- Import paths are a public interface. Moves break entry points, pickles, serialized class paths, dashboards keyed on module name, and every open branch. Ratchet instead of re-folder.
When this is the wrong move
Don't draw boundaries you haven't discovered. A one-file script, a library with a single public module, and a two-week spike are all better flat. In a genuinely new domain, resist foldering until the third feature shows you where the seam actually is — a wrong boundary is more expensive than no boundary, because code starts conforming to it. And never fold to match an org chart you don't have yet: shaping the tree around a team split that may not happen is speculative generality (lesson 0030) applied to directories.
Skill: draw the tree
The best primary key for folder structure is:
File type
Shared utilities
Responsibility boundary
Layer names
Correct. Right: folders by boundary — domain, application, infrastructure — with allowed imports visible in the tree.
A dependency cycle appears between two folders. The cause is usually:
A misplaced piece of code
Too many modules per folder
Imports written in wrong order
A limitation of Python packages
Correct. Right: something lives in a folder it doesn't belong to and reaches upward to do its job. Move it, and the cycle goes with it.
The utils/ folder is a smell because:
It has no owner
It imports too widely
Its helpers are wrong
It duplicates other folders
Correct. Exactly: it's where cohesion goes to die — helpers that belong to no named boundary.
Practice on your own code
Draw your project's current folder tree and label each folder's job and allowed imports. Then redraw it by boundary. Count the files whose home changes — those files are your mis-organization, and the redraw is your target layout.
In the lab. The phase-2 checkpoint gives you the returns-resolution service as one flat package with the carrier SDK imported from the rules. You write the import table first, add the failing fitness test with today's violations frozen, and only then move a file.
Reveal: a tree redraw
A project has models/, repos/, views/. The redraw puts models/ → domain/ (rules only), repos/ → infrastructure/ (adapters), and the views/ split between application/ (use cases) and api/ (delivery). The dependency cycle that used to exist between models and repos disappeared with the boundary.
- Explain to a junior, in two sentences, why a folder tree is about dependency direction rather than tidiness.
- Find one import in your own code this week that points the wrong way. Name its cost: what can no longer be changed alone because of it?
- Predict what your tree looks like in a year if nothing enforces the arrows: which folder swells, and who stops being able to reason about it?
Your win
You can draw a boundary-based tree, spot the folders that don't belong to a boundary, and make dependency direction visible in the layout itself.
Read and watch deeper
- Clean Architecture, Martin — ch. 12–14: component cohesion and coupling — the principles behind folder boundaries.
- Architecture Patterns with Python, Percival & Gregory — ch. 4: the package layout this lesson's tree comes from.
- A Philosophy of Software Design, Ousterhout — ch. 4 and 7: module depth and "different layer, different abstraction" applied to folders.
- import-linter — the official docs for the contract types (layers, forbidden, independence) that express this lesson's import table as configuration.
- Watch: ArjanCodes YouTube — search "project structure python".
- Next: lesson 0023 — Ports & Adapters for infrastructure.
- Reference: Glossary — fitness function, boundary.
Show your agent-teacher your folder tree and label every folder's allowed imports together.
Source page: lessons/0022-module-organization.html
Lesson 0023 · System Designer · Module 3
Ports & Adapters for Infrastructure
Lesson 0009 drew one arrow. Now draw them all: database, APIs, filesystem, clock, email — every technology behind a port, wired at a single composition root.
Mission tie-in: "apply Ports & Adapters to isolate infrastructure concerns" — the System Designer's full application of the pattern, and the shape your AI prompts should prescribe.
Knowledge: everything outside is an adapter
The domain speaks only to ports. The infrastructure — storage, gateways, files, the clock itself — implements them. The full inventory of infrastructure in a typical system:
- Persistence →
OrderRepository(lesson 0013) - External APIs →
PaymentGateway,SupplierFeed - Filesystem →
InvoiceStore - Email/notifications →
Notifier - The clock →
Clock.now()— even time is infrastructure worth a port
# domain/ports.py — everything the core needs from the world:
class Clock(Protocol):
def now(self) -> datetime: ...
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
def add(self, order: Order) -> None: ...
# infrastructure/ — the world, conforming:
class SystemClock:
def now(self) -> datetime:
return datetime.now()
class SqlAlchemyOrderRepository:
...
# composition root — the only place the two meet:
def build_app() -> App:
clock: Clock = SystemClock()
orders: OrderRepository = SqlAlchemyOrderRepository(make_session())
notifier: Notifier = EmailAdapter(smtp_config())
checkout = CheckoutService(orders, notifier, clock)
return App(checkout=checkout)
A port converts an unknown into a choice. Every direct call to a technology embeds three assumptions in its caller: that the thing is available, that it is fast, and that its vocabulary is yours. A port makes all three properties of one file instead of properties of everywhere.
The payoff people advertise — swapping vendors — is the rarest one. The daily payoff is determinism: the ability to run a rule with time, randomness, and the network held still. That is why the clock earns a port although nobody will ever replace time, and it is the honest justification to reach for when someone asks what the indirection buys.
Where it stops applying. A port pays when the thing behind it is non-deterministic, slow, chatty in a foreign vocabulary, or plausibly replaceable. A dependency that is none of those — json, re, decimal, your own logging helper — gets no port, because the wrapper is a pass-through with a name: two things to read where there was one, and no seam gained. And a port whose only implementation is the real one, with no fake and no second adapter, is speculative generality (lesson 0030). The fake in your tests is what makes it the second implementation, which is why testability is the argument that actually holds.
The composition root — one visible function where everything is constructed and injected — is the pattern's capstone. Nothing anywhere else says "SQLAlchemy" or "stripe" or "datetime.now". Swap a technology by editing this one function.
Worked example: the test that only failed at night
A six-engineer team has a promotional pricing rule that reads datetime.now() to decide whether a promo window is open. Its test asserts "no discount at 23:59". The test passes for eleven months, then starts failing intermittently.
The first thing a senior designer looks for is not the bug but what in this test is not controlled by the test. The answer is the clock: the assertion is about an instant, and the instant is supplied by the machine. CI runs about forty times a day and the flake costs roughly 2% of runs — under one red build a day, and each one buys a re-run plus ten minutes of somebody's attention. That is only a few hours a month, and the hours are not the real cost. The real cost is that the team learns to re-run red builds without reading them, so the next genuine failure is also re-run.
The tempting repair is to monkeypatch datetime in the test. It is rejected for three reasons and it is worth holding all three: it mutates a module global for the whole process (lesson 0019) and leaks into whatever runs next; it silently fails if the rule imported now directly rather than the module; and it leaves the production signature lying — the function still claims to depend on nothing while depending on the wall clock.
The port makes the dependency part of the contract. The fake is three lines, and the test now names the instant it is asserting about:
# domain/ — the dependency is now visible in the signature
class Clock(Protocol):
def now(self) -> datetime: ...
def promo_price(base: Money, promo: Promo, clock: Clock) -> Money:
if promo.starts_at <= clock.now() < promo.ends_at:
return base * promo.factor
return base
# tests/ — the second implementation, and the reason the port exists
class FrozenClock:
def __init__(self, at: datetime) -> None:
self._at = at
def now(self) -> datetime:
return self._at
def test_promo_is_closed_one_second_before_it_opens() -> None:
clock = FrozenClock(datetime(2026, 7, 1, 9, 59, 59))
assert promo_price(Money(100), july_promo, clock) == Money(100)
Read the two signatures side by side: the old one said promo_price(base, promo) and lied; the new one declares that pricing depends on time. Over the following year the same clock let the team test a daylight-saving boundary, a promo that spans midnight, and an expiry-at-year-end case — none of which anyone would have written while the only way to reach them was to wait.
| In the domain today | Its port | Its fake in tests |
|---|---|---|
datetime.now() | Clock.now() | FrozenClock(datetime(2026, 1, 1)) |
requests.post(...) | PaymentGateway.charge() | a fake recording the calls it received |
| open(path) / S3 upload | InvoiceStore.put() | a dict-backed store |
os.environ["API_KEY"] | a settings object, passed in | a plain dataclass built in the test |
| uuid4() / random.random() | IdGenerator.next() | a counter, so assertions can name the id |
- "Ports are for swapping databases."
- Swapping is the rarest return. Determinism and testability are the daily ones. Justify a port by naming the fake you are going to write; if you can't name one, you are buying indirection on speculation.
- "Every third-party import needs a port."
- A pure, fast, deterministic library behind a port is a pass-through. You added a file, a name, and a hop, and gained no seam — the wrapper's only effect is that readers now traverse two layers to find one call.
- "A port means an abstract base class and a folder."
- Often a
Protocoland a function parameter is the entire port. The pattern is about the direction of the dependency arrow, not about the machinery used to express it (lesson 0017). - "The composition root should be a DI container."
- A container is one implementation of the idea and it trades greppability for wiring convenience. A plain
build_app()function you can read top to bottom is usually enough, and it fails at import time rather than at first request.
When this is the wrong move
Skip the port in a spike whose whole purpose is to learn the vendor's API — the abstraction would hide exactly what you are trying to see. Skip it in scripts. Skip it when the proposed port would have one implementation, no fake, and a vocabulary copied verbatim from the vendor: that is not a boundary, it is a rename with an extra file. And be honest about the failure mode on the other side — a system where every stdlib call has been ported is one where finding out what actually happens requires opening four files, and that cost is paid on every read by everyone.
Skill: what gets a port?
Which dependency least deserves a port?
The json standard library
The outbound payment gateway
The system clock itself
The order storage layer
Correct. Right: json is pure, fast and deterministic, so a wrapper adds a name and a hop while gaining no seam at all.
The composition root is where:
Adapters appear everywhere
Everything gets wired
Tests are built
Ports are declared
Correct. Exactly: construction and injection happen once, visibly, in one function.
A technology that will never change and never needs faking:
Always gets a port
May skip the port
Never gets a port
Needs no testing there
Correct. Right: if neither swap nor fake is plausible, the indirection may not pay — KISS applies.
Practice on your own code
List every technology your domain touches today (imports of SDKs, datetime.now(), filesystem, env vars). For each, mark: changeable? needs faking in tests? Give a port to every "yes"; wire them all in a composition root function.
In the lab. The phase-2 checkpoint has you build the infrastructure inventory for the returns-resolution service — carrier SDK, database session, datetime.now() inside the eligibility rule, an environment read three files deep — then introduce a clock and a carrier port with fakes, and wire them in one composition root.
Reveal: the inventory
A reporting service touches: SQLAlchemy (changeable, faked → port), an FTP uploader (changeable → port), and datetime.now() for "as of" timestamps (faked in tests → port). After: three ports, three adapters, one build_reporting_app() root, and the service's tests run offline with fakes for all three.
- Explain to a junior, in two sentences, why the clock gets a port even though time will never be replaced.
- Find one function in your own code this week whose signature hides a dependency on the network, the clock, or randomness. Name its cost: which test could you not write because of it?
- Predict what happens in a year if that dependency stays hidden: which behaviour goes untested, and how do you find out it was wrong?
Your win
You can inventory a system's infrastructure, give each changeable or fakeable technology a port, and wire the whole system at one composition root — with the cost of indirection weighed honestly.
Read and watch deeper
- Architecture Patterns with Python, Percival & Gregory — ch. 2–4: the working pattern — repository, service layer, and the composition root.
- "Hexagonal Architecture", Cockburn — the origin, and the "application is a core surrounded by adapters" diagram.
- Growing Object-Oriented Software, Guided by Tests, Freeman & Pryce — ch. 2 and 8: "only mock types you own", which is this lesson's rule arrived at from the testing side.
- Clean Architecture, Martin — ch. 17–18: boundaries, the dependency rule, and crossing the boundary cleanly.
- Watch: ArjanCodes YouTube — search "ports and adapters".
- Next: lesson 0024 — keeping async out of domain logic.
- Reference: Patterns — Ports & Adapters, composition root; Design Principles — depend on abstractions.
Inventory your domain's technology imports with your agent-teacher and decide each port together.
Source page: lessons/0023-ports-and-adapters-for-infrastructure.html
Lesson 0024 · System Designer · Module 3
Keeping Concurrency and Async Out of Domain Logic
Business rules don't care about event loops. When async bleeds into the domain, every rule becomes untestable and every I/O becomes a hidden dependency.
Mission tie-in: "keep concurrency and async execution out of domain logic" — the discipline that keeps the core pure and the edges fast.
Knowledge: pure core, async edges
An async function is a statement about how code runs. Domain rules are statements about what is true. When the two mix, the domain's tests need an event loop, and every call site inherits the async decision — whether it wanted it or not.
# the spread: domain logic fused to I/O style
async def apply_refund(order: Order, gateway: AsyncPaymentGateway) -> Refund:
if order.status == Status.REFUNDED: # domain rule
raise AlreadyRefunded()
result = await gateway.refund(order.total) # I/O, inside the rule
order.mark_refunded(result.refund_id)
return Refund(result.refund_id)
async is viral in a way ordinary dependencies are not. An await can only appear inside an async def, so one await in the core forces every ancestor in the call graph to become async — including the test, the cron job, and the caller that does not exist yet. This is a coupling that propagates upward automatically and cannot be contained locally. Contrast a database dependency: however deep it sits, one seam injects it away. There is no seam that un-colours a function.
The second half of the mechanism is what makes a rule reusable. A rule is a decision about data; a decision about data is a pure function; and a pure function can be called from a web request, a nightly backfill, a replay script, and a test without any of them agreeing about how I/O happens. Purity is not an aesthetic here — it is what lets one rule serve four callers.
Where it stops applying. When timing is the business rule. A scheduler, a rate limiter, a lease manager, a protocol state machine, a market gateway — in these the concurrency is the domain, and a "pure core" would be a core that lies about what it does. It also weakens when the next fetch depends on what the last fetch returned: pre-fetching everything is then wasteful, and the honest shape is a pure decide-what-to-fetch-next function driving an async loop at the edge, rather than a pure function pretending it had all the data up front.
Test that function and you must build an event loop and mock gateway.refund — and if the rule ever needs a synchronous caller (a cron job, a script), the async spreads further.
The clean split:
# domain: sync, pure, no I/O
def apply_refund(order: Order, refund_id: str) -> Refund:
if order.status == Status.REFUNDED:
raise AlreadyRefunded()
order.mark_refunded(refund_id)
return Refund(refund_id)
# application/service layer: async at the edge
async def refund_order(order: Order, gateway: AsyncPaymentGateway) -> Refund:
result = await gateway.refund(order.total)
return apply_refund(order, result.refund_id) # sync rule, called once
The rule is now testable with plain function calls; the async lives only where I/O lives. Same for threads: domain code never spawns, never locks, never sleeps — it receives decisions and returns results; concurrency is an edge concern.
Worked example: three awaits inside one eligibility rule
A returns-resolution service decides whether a return is eligible. The rule needs three things: the order, the carrier's scan events, and the customer's prior return rate. The first draft is an async def that awaits all three.
What a senior designer checks first is not the keyword but the dependency between the fetches: does any of the three depend on the result of another? Here, no — they are three independent lookups keyed on the same order id. That single observation decides the shape, because independent fetches can be gathered at the edge and handed to the rule as data, whereas dependent ones cannot.
The alternative that gets proposed in review is to keep the rule async so it can fetch lazily — "we only pay for what the rule actually reads". It is rejected on two grounds. First, it makes cost invisible: a rule that reads three attributes now issues three sequential network calls, 3 × 120 ms instead of one gathered 130 ms, and nothing in the rule's text says so. Second, it hides an N+1 the moment the rule runs over a batch.
Put the batch number on it. The nightly job evaluates about 8,000 returns. Sequential in-rule fetching at ~360 ms each is roughly 48 minutes; gathered at the edge — and batched where the repository allows it — the same work finishes in under four. The rule's own test suite drops from seconds of event-loop setup to milliseconds, which is what makes anyone willing to add the fourteenth eligibility case.
# application/ — the edge gathers, because the edge owns I/O
@dataclass(frozen=True)
class EligibilityInput:
order: Order
scans: tuple[CarrierScan, ...]
prior_return_rate: float
async def evaluate(order_id: str, orders, carrier, history, policy) -> Decision:
order, scans, rate = await asyncio.gather(
orders.get(order_id), carrier.scans(order_id), history.rate(order_id)
)
return is_eligible(EligibilityInput(order, tuple(scans), rate), policy)
# domain/ — sync, pure, and readable as a policy document
def is_eligible(data: EligibilityInput, policy: ReturnPolicy) -> Decision:
if data.order.age_days > policy.window_days:
return Decision.refuse("outside the return window")
if data.prior_return_rate > policy.fraud_threshold:
return Decision.review("prior return rate above threshold")
if not any(scan.is_delivery for scan in data.scans):
return Decision.refuse("no delivery scan on record")
return Decision.approve()
Note what the split bought beyond speed. is_eligible is now readable as a policy document by someone who does not write Python — which is how the fraud threshold got questioned by the operations lead, and corrected, before launch. That conversation is not available about a function that also opens sockets.
async, await, thread, sleep, lock. Hits mean concurrency is inside the core. Retries and timeouts (lesson 0021) are coordination — they belong in the adapters too.
| You see this | What it costs | The move |
|---|---|---|
| async def on a method with no await | Async spread by convention rather than by need. | Make it sync; the caller keeps its own loop. |
| time.sleep() inside a rule | The rule now owns a backoff policy it can’t explain. | Move the wait into the adapter. |
| threading.Lock in a domain object | The object was designed for one concurrency model. | Own the state (lesson 0020); lock at the edge. |
| a domain test calling asyncio.run | That test is exercising orchestration, not a rule. | Split it: fetch outside, decide inside. |
- "Async makes code faster."
- Async makes waiting cheaper. A CPU-bound rule gains nothing, and a chain of sequential awaits gains nothing either. Concurrency comes from gathering, not from the keyword — which is why the fix above was
gather, notasync. - "A sync domain means a sync system."
- The edges can be fully async, the workers can be threads, the batch can be a process pool. Purity is a property of the rule, not of the process it runs in — that is exactly why the rule survives all three.
- "I'll inject an async port, that keeps it decoupled."
- An async port still forces the rule to be
async. Injection fixes who does the I/O, not when it happens. Pass the data, not an awaitable. - "A lock inside a domain object is just defensive."
- A lock encodes an assumption about how many callers exist and in which process. It makes the object unusable in a single-threaded batch and meaningless in an async worker, and it hides the real question, which is who owns the state (lesson 0020).
When this is the wrong move
Don't extract a pure core when the concurrency is the domain: schedulers, rate limiters, lease and lock managers, protocol state machines, streaming operators. There, timing and ordering are the rules being modelled, and pushing them to the edge produces a core that describes something the system does not do. Don't do it either when the fetch plan depends on intermediate results — keep a pure "what next?" function and let an async driver walk it. And in a three-function service where the whole application is one async layer, the split buys a file and returns nothing.
Skill: what's where?
An await gateway.refund() inside a domain rule is a problem because:
The rule now performs I/O
Async is slower than sync
The event loop may block
The gateway may raise errors
Correct. Right: the rule performs I/O, so its tests need an event loop and a fake gateway — and every caller inherits the async decision.
The correct home for await is:
The domain core
The service layer
Every layer uniformly
The database layer
Correct. Exactly: the service layer orchestrates I/O and calls the sync domain for decisions.
A domain rule that needs the result of an API call should:
Do the call itself
Await through the port
Receive the data itself
Retry inside the rule
Correct. Right: the caller fetches; the rule decides on the data it's handed.
Practice on your own code
Grep your domain folder for async, await, thread, sleep, lock. For each hit, move the concurrency out: the service layer awaits, the domain decides. Re-run your domain tests without an event loop.
In the lab. The phase-2 checkpoint has the returns eligibility check awaiting the carrier client from inside the rule, once per return, in a loop. You gather the inputs at the edge, turn the rule into a pure function over a frozen input record, and watch the domain test file lose its event loop.
Reveal: a worked extraction
A RebalanceService had async on every method because one of them called an async broker API. Extraction: the async fetch lives in the service's async def wrapper; the rebalancing math is a pure sync function taking the fetched positions. The math's tests now run without asyncio.
- Explain to a junior, in two sentences, why one
awaitin a rule spreads further than one database call in a rule. - Find one
async defin your own code this week that contains noawait, or one rule that awaits. Name its cost: which caller can't use it, and which test nobody wrote? - Predict what the call graph looks like in a year if the async keeps spreading upward: what will it take to run that rule from a backfill script?
Your win
You can run the async leak test on your domain, extract concurrency to the service layer, and keep the core's tests plain function calls.
Read and watch deeper
- Architecture Patterns with Python, Percival & Gregory — ch. 7–8: the sync domain + async edges shape, and the message bus that replaces scattered awaits.
- "What Do You Mean by Event-Driven?", Fowler — the vocabulary for why async belongs at the edges.
- "What Color Is Your Function?", Bob Nystrom — the primary statement of why async is viral through a call graph.
- Robust Python, Viafore — concurrency chapters: keeping the core free of threading primitives.
- Watch: ArjanCodes YouTube — search "asyncio" for the channel's async guidance.
- Next: lesson 0025 — event-driven architecture.
- Reference: Patterns — async boundary.
Show your agent-teacher your async domain and extract one function together.
Source page: lessons/0024-async-out-of-domain-logic.html
Lesson 0025 · System Designer · Module 3
Event-Driven Architecture
When one event sets off many reactions, direct calls chain every component to every other. Events break the chain: the producer doesn't know its consumers exist.
Mission tie-in: "use event-driven architecture to decouple workflows and components" — the System Designer's last module, and the stepping stone to the trade-offs of Phase III.
Knowledge: facts, not instructions
An event is a fact that happened, named in the past tense: OrderConfirmed, PaymentReceived, StockDepleted. It is an immutable value object (lesson 0012) carrying what happened, not what to do. Consumers decide their own reaction.
@dataclass(frozen=True)
class OrderConfirmed:
order_id: str
occurred_at: datetime
# the bus: routes events to handlers
class MessageBus:
def __init__(self) -> None:
self._handlers: dict[type, list[Callable]] = {}
def register(self, event_type: type, handler: Callable) -> None:
self._handlers.setdefault(event_type, []).append(handler)
def publish(self, event: object) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)
# the domain records the fact; it does not publish it:
class Order:
def __init__(self, order_id: str) -> None:
self.order_id = order_id
self._status = Status.PENDING
self.events: list[object] = [] # facts, collected
def confirm(self) -> None:
if self._status != Status.PENDING:
raise OrderLockedError()
self._status = Status.CONFIRMED
self.events.append(OrderConfirmed(self.order_id, now()))
# the service publishes only after the write succeeded:
def confirm_order(order_id: str, orders: OrderRepository, bus: MessageBus) -> None:
order = orders.get(order_id)
order.confirm()
orders.save(order) # commit first…
for event in order.events: # …then announce
bus.publish(event)
# the wiring — handlers care, the Order doesn't:
bus.register(OrderConfirmed, send_confirmation_email)
bus.register(OrderConfirmed, issue_invoice)
bus.register(OrderConfirmed, reserve_stock)
An event inverts who knows about whom. In a direct call the producer names its consumers, so adding a consumer edits the producer. With an event the consumer names the producer, so adding a consumer edits nothing that already works. That inversion is the only thing events buy, and it is bought in one specific currency: the call stack. You are trading static traceability — I can read what happens next — for modification locality — I can add what happens next without touching what exists.
Stated that way the trade tells you its own conditions. It pays when consumers are many, optional, and genuinely independent, and when the producer must not care whether they succeed. It stops paying the moment the producer needs the result (that is a function call), exactly one consumer must handle it (that is a command), ordering between consumers matters (no bus promises it), or there is one consumer and there always will be (a direct call with ceremony and a lost traceback).
There is a second boundary that gets crossed by accident. An in-process synchronous bus keeps your transaction, your ordering, and your stack; an out-of-process broker gives isolation and takes all three, and hands you at-least-once delivery in exchange, which means every handler must become idempotent. Most teams adopt the vocabulary of the second while running the first, and discover the difference during the migration.
The two-step — collect in the domain, publish after the commit — is not ceremony. Publish inside confirm() and a failed save leaves three handlers acting on an order that does not exist: an invoice for nothing, an email about nothing, stock reserved for nothing. The domain records what happened; the layer that owns the transaction decides when it is true enough to announce.
Order knows nothing about email, invoicing, or stock — it announced a fact, and the bus did the rest. Adding a fourth reaction (analytics) touches the wiring only. That's the decoupling: producer ignorance.
The honest price: the flow is no longer visible in one call stack. Reading the code tells you what happened, not what will happen next — that knowledge lives in the wiring. Event-driven is a trade, not a free lunch (the full accounting comes in Phase III's sync vs async).
Worked example: the handler that stopped the two after it
An order service publishes OrderConfirmed to four in-process handlers: email, invoice, stock reservation, analytics. At about 6,000 orders a day it runs quietly for a year. Then the invoicing handler starts raising on a malformed partner VAT number — and because publish is a plain loop, handlers three and four never run for those orders.
The first thing a senior designer inspects is not the invoice bug. It is the failure semantics of the publish loop: what happens to handler N+1 when handler N raises? That is a property of the bus, and nobody had ever written it down. The bug in the VAT parser is a week's worth of incident; the unwritten semantics are the thing that will produce the next three incidents.
The second question is the one that reframes the design: "is each of these reactions genuinely allowed to fail, and who finds out when it does?" Asked plainly, three of the four answer "yes, and nobody needs to know today". Stock reservation answers "no — if this doesn't happen we oversell". That answer means stock reservation was never an event reaction at all. It is a step in the workflow that happens to have been wired like a notification.
The rejected repair is to wrap each handler in try/except and log. It fixes the ordering bug and leaves the real one untouched: the stock is still unreserved, and now silently so. Count the damage before choosing. Malformed VAT numbers appeared on 1.2% of orders for three days — roughly 216 orders shipped without a reservation. Roughly one in eight became an actual oversell: about 27 items, each costing an apology, an expedited restock and a support contact at something like €40 all-in, so a little over €1,000 in direct cost and a much larger dent in the "we have it in stock" promise. The try/except would have prevented none of it.
So two changes ship, and only one of them is the bus. Stock reservation moves out of the event chain into the confirmation workflow, where its failure fails the operation. The remaining three handlers get isolated, with failures preserved for replay rather than logged and lost:
class MessageBus:
def __init__(self, dead_letters: DeadLetterQueue) -> None:
self._handlers: dict[type, list[Callable]] = {}
self._dead_letters = dead_letters
def publish(self, event: object) -> None:
for handler in self._handlers.get(type(event), []):
try:
handler(event)
except Exception as failure: # one handler, one blast radius
self._dead_letters.put(event, handler.__name__, failure)
That except Exception looks like a violation of lesson 0021 and is not. Lesson 0021's rule is "catch only what you can handle" — and this code can handle it, because its job is isolation: it changes the outcome by preserving the event for replay instead of discarding it, and it is the one layer that knows the blast radius is meant to stop here. A catch that changes the outcome is handling; a catch that only continues is not.
| You are modelling | It is a… | Because |
|---|---|---|
OrderConfirmed | event | Past tense, many consumers, safely ignorable. |
SendReceipt | command | One handler, must happen, has an addressee. |
OrderUpdated | neither, yet | “Updated” names no fact — what changed? |
| a handler that raises to stop the producer | command in event clothing | If the producer cares about the outcome, it was never an event. |
- "Events decouple, so more events means less coupling."
- Events move coupling from the call graph to the schema. Every consumer depends on the event's fields, so renaming one breaks four consumers at once — silently, at runtime, instead of loudly, at import.
- "An in-process bus is a stepping stone to a broker."
- The vocabulary transfers; the semantics do not. In-process is ordered, exactly-once and inside your transaction. A broker is unordered, at-least-once and outside it. Handlers written against the first are wrong against the second, and the compiler will not say so.
- "Publishing inside the aggregate is safe — the ORM will roll back."
- The ORM rolls back your database. It does not un-send an email, un-charge a card, or un-reserve stock in a partner's system. That is the entire reason for collect-then-publish-after-commit.
- "Event-driven means asynchronous."
- A synchronous in-process bus is event-driven and keeps the stack. Whether the reaction crosses a process boundary is a separate decision with its own trade-offs (lesson 0031) — deciding both at once is how teams buy queue infrastructure to solve a naming problem.
When this is the wrong move
Don't reach for a bus when there is one consumer, when the producer needs the result, when the order of reactions matters, or when a reaction must happen — that last one is a command, and dressing it as an event is exactly the misclassification the worked example cost money on. Be honest about the small-team case too: in a service where "who reacts to this?" is answered by one grep, indirection has a real read-tax and no offsetting benefit. Events earn their keep when the set of consumers changes more often than the producer does.
Skill: what's the decoupling?
An event is best described as:
An instruction to act
A fact that happened
The producer's own state
A list of reactions
Correct. Right: a past-tense fact, immutable, naming what happened — reactions are the consumers' business.
The producer of OrderConfirmed knows:
All of its consumers
None of its consumers
Their order of execution
That handlers all succeeded
Correct. Exactly: producer ignorance — the email, invoice, and stock handlers are the wiring's secret.
The honest cost of the bus is:
The flow stays visible
It has simpler wiring
The flow is hidden
It has higher throughput
Correct. Right: producer ignorance means the reaction chain isn't in one call stack anymore.
Practice on your own code
Find a workflow where one event triggers three or more reactions (a save that emails, logs, and syncs). Extract the fact as an event value object, emit it from the domain, and register the three reactions in the wiring. Then answer honestly: is the flow easier or harder to follow?
In the lab. The phase-2 checkpoint has the returns-resolution service sending the customer email from inside the domain method, before the save. You collect the fact on the aggregate, publish after the commit, and then decide which of the four reactions is actually a command that must not be allowed to fail quietly.
Reveal: a workflow extraction
A save_article function emailed editors, invalidated a cache, and enqueued a search reindex — three side effects, one call stack. Extracted: ArticlePublished emitted by Article.publish(); three handlers registered in build_app(). Adding "notify subscribers" is now one line of wiring, and the domain gained a published-at fact worth having.
- Explain to a junior, in two sentences, what an event buys and what it costs — using the words "call stack".
- Find one place in your own code this week where a side effect happens before the commit. Name its cost: what has already reached the outside world when the transaction rolls back?
- Predict what breaks in a year if a handler is added for every new requirement: how does somebody answer "what happens when an order is confirmed?"
Your win
You can spot the multi-reaction workflows, extract facts as events, and wire reactions at the composition root — while naming the visibility cost honestly.
Read and watch deeper
- "What Do You Mean by Event-Driven?", Fowler — event notification vs event sourcing vs CQRS: the vocabulary.
- Architecture Patterns with Python, Percival & Gregory — ch. 8–10: the message bus in working Python, events as domain objects.
- "Event Sourcing", Fowler — when events become the storage, not just the communication.
- "Dead Letter Channel", Hohpe & Woolf — Enterprise Integration Patterns, the catalogue entry for what the isolated publish above does with a failed handler.
- Watch: ArjanCodes YouTube — search "event driven architecture".
- Next: lesson 0026 — libraries vs building.
- Reference: Patterns — message bus and unit of work, the transaction the publish must wait for.
Map your workflow's reaction chain with your agent-teacher before converting it to events.
Source page: lessons/0025-event-driven-architecture.html
Lesson 0026 · System Designer · Module 3
Libraries and Packages vs Building It Yourself
Every dependency you skip is code you maintain forever. Every dependency you take is a contract you must follow. This lesson gives you the decision procedure.
Mission tie-in: "understand when to use libraries/packages vs building solutions yourself" — the last System Designer skill, and the bridge to Phase III's first trade-off, Build vs Buy.
Knowledge: three questions decide
You never avoid the code. You only choose whose bugs you inherit and on whose schedule you fix them. Taking a dependency converts unknown-unknowns — edge cases you have not met — into a known recurring cost: upgrades, advisories, API churn. Building does the reverse: it converts a known recurring cost into unknown-unknowns that you will meet one at a time, in production, at the worst moment.
That is why "generic → take, yours → build" works, and it is not really about genericness. For a generic problem a large population of users has already walked the edge cases, so the library encodes evidence you do not have. For your own domain no such population exists, so a library can only offer you someone else's model of your problem — which is worse than no model, because you will spend forever translating into it.
Where it stops applying. Four cases flip the default. The library is not actually mature (a 3-day-old release, one maintainer, no changelog). You need 10% of it and it drags forty transitive dependencies into a build you must audit. Its model disagrees with yours, so every call is a translation and the impedance layer outgrows the thing it wraps. Or your constraints — air-gapped, regulated, licence-incompatible — make the vendor's contract more expensive than the code. Note that only one of those four is about the code being hard to write.
Ask three questions about the problem you're about to solve:
- Is it generic or is it yours? Serialization, auth, date math, HTTP — generic problems with mature solutions. Pricing logic, fraud rules, workflow semantics — your domain. Generic → library. Yours → build.
- Is the library boring? Mature, widely used, slowly changing (Dan McKinley's "choose boring technology"). A library three days old, or a fashionable rewrite every quarter, is a dependency you'll maintain at its pace, not yours.
- Can you wrap it? Any dependency worth taking is worth hiding behind a port (lessons 0009, 0023) so its churn never reaches your domain.
# take: JSON Schema validation? mature, generic, boring — take it
import jsonschema
jsonschema.validate(payload, schema)
# hide: the schema library's version churn stays inside the adapter
class PayloadValidator:
def validate(self, payload: dict) -> None:
try:
jsonschema.validate(payload, self._schema)
except jsonschema.ValidationError as e:
raise InvalidPayload(str(e))
The cost of building: you own every bug, every missing edge case, every security review forever. The cost of taking: an external contract, upgrade cadence, and supply-chain surface. The tie-breaker is always the same — is this problem your business's differentiator? If yes, build (and own it deliberately). If no, take (and wrap it).
Worked example: fourteen partner feeds and "it's just CSV"
A team ingests daily CSV files from fourteen retail partners. The proposal on the table is to write the parser: it's just splitting on commas, forty lines, no dependency.
A senior designer does not argue about the forty lines. They go and get the data: pull two hundred real files and count how many violate the naive assumption. The result decides the conversation. Six partners emit a UTF-8 byte-order mark. Three quote inconsistently, so a quoted comma appears in one file a week. Two send European decimals (1.234,56). One appends a summary row that is not a record. Nine of fourteen partners break the forty-line parser, and none of those nine knows it.
The question that follows is "how many of these will I meet in production if I hand-roll, and how will I meet them?" — the answer being: all of them, one incident at a time, each discovered because a number in a report is wrong rather than because a parse failed.
The obvious library, pandas, is rejected — and the reason is the third boundary condition, not capability. It would drag a large dependency and a DataFrame model into a domain that wants one typed record per row, so every call site would translate between the two forever. The impedance layer would be permanent and would grow.
What ships is stdlib csv for tokenizing (mature, boring, zero dependencies) plus a schema library for typing and validation, both behind a FeedReader port. The partner quirks — decimal commas, the summary row — are genuinely yours and stay in your adapter, one small class per partner.
Price the difference honestly, because it is smaller than the argument suggests. The hand-rolled path met three of those edge cases in its first year; each cost roughly four hours to diagnose, two to fix, and a feed re-run, so about eighteen hours plus three out-of-hours interruptions. Learning and wiring the library cost six. The gap is not enormous — but the eighteen hours arrived as three surprises, and the six were spent on a Tuesday afternoon by choice. Surprise is the cost that no ledger line shows.
What "boring" means, made checkable: when was the last release, and the one before it? How long do issues stay open? How many maintainers can merge? Is there a changelog and a deprecation policy? Has the public API broken twice in two years? A library that fails three of these is not a dependency, it is a fork you have not taken yet.
† The AI special case: AI will happily "write a small library" for you instead of taking one — the generated code then becomes your hand-maintained dependency with zero of the library's maturity. Prefer the real library; use AI on your domain.| The thing you need | Verdict | Because |
|---|---|---|
| JWT signing, password hashing | take | A subtle bug here is a breach, not a defect. |
| date, timezone and currency arithmetic | take | The edge cases are a decade of other people’s bugs. |
| your pricing or matching rules | build | It is the product; a library makes you a reseller. |
| an AI’s 60-line retry helper | take the library | tenacity is battle-tested; the copy is not. |
| a wrapper over one library call | neither | It adds a name and contains nothing. |
- "Fewer dependencies is safer."
- Fewer dependencies means more of your own unreviewed code doing the same job. The metric that matters is not count but exposure: what each dependency can do, how it updates, and whether it is wrapped.
- "It's only forty lines, I'll write it."
- Forty lines is the happy path. The library is four thousand because the other 3,960 are the edge cases you have not met. This holds for date maths, currency rounding, retries, CSV, and every parser ever written.
- "Wrap every library — that's the rule."
- Wrap the volatile and the foreign-vocabulary ones. A stable, pure library whose vocabulary is already yours (
decimal,datetime) is not improved by a wrapper; you have added a name and a hop. - "If we build it, we control it."
- You control it while the author is still on the team and someone still budgets for it. Ownership without a named owner and a maintenance allocation is abandonment with extra steps — and abandoned internal code has none of an abandoned library's escape hatches.
When this is the wrong move
Build — or vendor a small, understood copy — when the library's model fights yours, when the transitive surface is unacceptable (regulated builds, embedded targets, cold-start-sensitive functions), or when the library is unmaintained and you would be forking it anyway. Build when you need 5% of something and that 5% is both simple and fully specified: a documented checksum is not a parser. In every one of those cases, write down that you now own it, with a name attached — the failure mode is not the decision, it is the decision nobody recorded.
Skill: take, build, or wrap?
JWT authentication for your API is:
Yours — build it
Generic — take it
Simple — inline it
Differentiator — own it
Correct. Right: generic, mature, boring — take it, and wrap it behind an auth port.
Your pricing engine is your business's edge. The right call is:
A generic mature library
Build and own it
Hide within a port
AI should write it
Correct. Right: domain differentiators are built and owned — the one place 'build' wins by default.
Every external library you take should be:
Used everywhere freely
Pinned and forgotten
Rewritten by AI
Behind a port
Correct. Exactly: wrap the volatile ones behind a port so their contract, not yours, changes.
Practice on your own code
List the last three "small utilities" you (or an AI) hand-wrote. Run the three questions on each: generic or yours? boring available? wrapable? Replace at least one hand-rolled utility with the mature library — and wrap it.
In the lab. The phase-2 checkpoint gives you a returns-resolution service that hand-rolls retry with backoff, ISO-date parsing, and money rounding. You run the three questions on each, replace the ones that fail, and put a port around whatever you take.
Reveal: a hand-rolled utility audit
An AI-generated "retry helper" with 60 lines of exponential backoff — hand-maintained, untested. The audit: generic (yes), boring (tenacity is battle-tested), wrapable (yes). Replaced with the library behind a RetryPolicy port; the AI's version went to the graveyard with its bugs.
- Explain to a junior, in two sentences, why "it's only forty lines" is an argument for taking the library rather than against it.
- Find one hand-written utility in your own code this week that a mature library covers. Name its cost: which edge case is it missing, and who will find it?
- Predict what happens in a year if its author leaves: who fixes it, and how do they know it needs fixing?
Your win
You can run the three-question procedure on any build-vs-take decision, and you know the wrap rule that contains whatever you take. Phase II complete — on to Master Designer.
Read and watch deeper
- "Choose Boring Technology", Dan McKinley — the default posture for dependencies.
- "Simple Made Easy", Hickey — the framework-flavored version of the same cost accounting.
- OpenSSF Scorecard — the checks (maintenance, review, dependency-update tooling, signed releases) that turn "is this library boring?" into a measurement rather than a vibe.
- The Pragmatic Programmer, Hunt & Thomas — the sections on reusable code and buying vs building.
- Watch: ArjanCodes YouTube — search "library vs framework" or "boring technology".
- Next: lesson 0027 — Build vs Buy, the first Master Designer trade-off.
- Reference: Trade-offs — build vs buy, the Master Designer version of this decision.
Bring your hand-rolled utilities to your agent-teacher and run the three questions on each.
Source page: lessons/0026-libraries-vs-building.html
Lesson 0027 · Master Designer · Module 1
Build vs Buy
Lesson 0026 gave the developer's version. The Master Designer's version adds the full accounting: licensing, upgrades, vendor risk, and the one question that overrules everything.
Mission tie-in: the first Complexity Trade-Off — where you learn to make decisions under constraints instead of by preference.
Knowledge: the full cost ledger
The two forces: control and fit (build) against time-to-value and inherited maturity (buy). The unit of decision: the component — never the system, and specifically the component you can draw a port around. "We are a build shop" and "we buy wherever possible" are both refusals to decide, and both are how a team ends up owning a billing engine and renting its recommendation model.
The ledger below is a proxy for the real question, which is not money. Money is fungible and, at these sizes, small. Senior engineering attention is neither. Buying converts an unbounded maintenance obligation into a bounded, renegotiable contract; building converts recurring cash into an asset you can shape — but only for as long as you keep staffing it. The ledger's job is to make the horizon visible so the attention argument can be had honestly.
Where it stops applying. The ledger decides nothing when the two options are not genuinely substitutable: if the vendor's 80% is the wrong 80%, the columns are comparing different products. It also assumes a stable horizon — a four-year break-even is noise for a business that might pivot or be acquired in eighteen months. And the differentiator test overrules it in both directions: never buy your edge because the licence is cheap this year, and never build your plumbing because the four-year arithmetic flatters you.
Both options have costs that are easy to undercount. The discipline is writing both ledgers:
| Build | Buy | |
|---|---|---|
| Visible | Dev time, tests, docs | License, subscription |
| Hidden | Every bug and edge case forever; the team must learn the domain you built | Upgrade cadence, vendor roadmap, contract lock-in, supply-chain surface |
| Control | Total — it's yours | Bounded — you follow their pace |
| Fit | Exactly your problem | Their 80% — you adapt to it |
The overruling question (from lesson 0026, now with the ledger behind it): is this your differentiator? If the thing you're building is the reason customers choose you, build — you cannot outsource the thing that makes you you. If it's plumbing — identity, billing infrastructure, file storage, a data-grid component — buy, and spend your team on the differentiator instead.
The third option, often missed: buy the boring 80% and build the 20% that matters. A vendor library + your adapter (lesson 0026's wrap rule) is usually the honest middle: their maturity, your edge.
The middle path has a shape, and it is the port from lesson 0009:
# your 20%: the workflow vocabulary, owned by you
class SignatureService(Protocol):
def request(self, doc: Document, signer: Party) -> RequestId: ...
def status(self, request: RequestId) -> SignatureStatus: ...
# their 80%: maturity, contained behind the port
class VendorSignatureAdapter:
def __init__(self, client: VendorSDK) -> None:
self._client = client
def request(self, doc: Document, signer: Party) -> RequestId:
envelope = self._client.envelopes.create(...) # their words stop here
return RequestId(envelope.id)
The port is the boundary of the purchase. Everything the vendor is genuinely good at lives on one side; everything that makes the workflow yours lives on the other. When their v3 renames envelopes to agreements, exactly one file changes — and the upgrade tax stops being a project.
A ledger with numbers in it (illustrative)
Document signing, sized by one team at a loaded rate of €90/hour:
- Build: 400 h to first release (€36,000), then ~80 h/year of maintenance and edge cases (€7,200/yr) — plus a legal review of signature validity that nobody on the team is qualified to sign off.
- Buy: €1,500/month licence (€18,000/yr), 16 h for the adapter (€1,440, once), ~8 h/year of upgrade tax (€720/yr).
| Cumulative | Year 1 | Year 2 | Year 3 | Year 4 |
|---|---|---|---|---|
| Build | €43,200 | €50,400 | €57,600 | €64,800 |
| Buy | €20,160 | €38,880 | €57,600 | €76,320 |
Break-even lands in year three. That single number reframes the argument: below a three-year horizon the ledger says buy and it is not close; above it, buying is a subscription you keep renewing. Teams argue for months about build vs buy without ever writing the two columns that make the horizon visible.
And notice what the ledger still does not settle. If signing were the product, you would pay the €43,200 in year one and call it the cost of owning your edge. If it is plumbing, the year-four crossover is an argument for renegotiating the licence, not for writing an e-signature stack. The ledger sizes the decision; the differentiator test makes it.
Worked example: what the senior looked at before the price
Same document-signing decision, watched over the shoulder of someone who has made it before. They do not open the pricing page first. They ask two questions the ledger cannot answer.
"If this vendor triples the price at renewal, or is acquired and sunset, what do we do on the Monday after?" That is the reversibility question, and it produces a number: sixteen hours to write a second adapter plus roughly forty hours to export and verify the historical signature records — about €5,000, once. A vendor whose exit costs more than a year of licence owns you, whatever the ledger says. Here it does not.
"Which of our twelve workflow steps does the vendor do natively, which need configuration, and which need a workaround?" Ten native, one configured, one workaround. That last count is the number to watch: past about two workarounds you are on the road to the failure mode where half the codebase exists to fight the 20% that never fitted.
Only then does the ledger get written, and only then does the tempting middle option get considered and rejected: a "thin build" implementing just the three signature types actually needed. It looks like the 80/20 move and it is not. Signature validity is a legal question with jurisdictional edge cases that nobody on a four-person team can evaluate, and the €36,000 build price does not include the liability transfer — which is most of what the vendor is actually selling. Recognising what is really being purchased is the skill; the arithmetic is bookkeeping.
The sequel is the part worth remembering. Three years later the vendor raised prices 22% at renewal. Because the port existed, the team ran a three-week spike against a competitor, priced the switch at the €5,000 they had estimated, and used a credible alternative to negotiate the increase down. They never migrated. The port's return was not the migration it enabled — it was the negotiating position it created, and that return does not appear on any line of the ledger.
† The upgrade tax is the most undercounted buy-cost: every vendor release is a contract negotiation between their roadmap and your code. Choose vendors with slow, boring release cadences — McKinley's boring-technology rule is a buying strategy, not just a building one.| Ask | Build if… | Buy if… |
|---|---|---|
| Is this why customers choose us? | yes — it is the edge | no — it is plumbing |
| Would a bug here be a breach or a lawsuit? | only with a real security budget | yes — you are buying the liability too |
| How fast does this part of our domain move? | faster than any vendor ships | slower than the vendor’s roadmap |
| Can we leave in a quarter if we must? | always — it is yours | only if there is an export path in the contract |
| Who is paged at 3 a.m. when it breaks? | your team, forever | still your team — vendors don’t take your pager |
- "Buying is cheaper because we don't pay engineers."
- You pay engineers for the integration, the upgrade tax, the workarounds and the incident response. The vendor does not take your pager, and a vendor outage is still your outage as far as your customers are concerned.
- "The break-even year decides it."
- The break-even sizes the decision. The differentiator test and the exit cost make it. A three-year crossover is irrelevant information if your planning horizon is eighteen months.
- "Open source is 'build' because it's free."
- Open source is buy with a different contract. You still inherit someone else's roadmap, and you may inherit their abandonment. Price is not the axis; who owns the roadmap is.
- "We'll build it and open-source it later."
- Maintaining a public project is a larger obligation than a private one, not a smaller one: issues, releases, compatibility promises and other people's use cases. "We'll open-source it" is a plan to acquire work, not to shed it.
When this is the wrong move
Don't run the full ledger on a cheaply reversible decision. If the component is a library behind a port you could swap in a week, spend an hour and take the boring option — the meeting costs more than either choice. Don't run it when the component is small enough that the evaluation dominates the work: in an enterprise with a procurement process, a forty-hour component can take six weeks to buy, and procurement time is a cost of buying that no ledger line captures. And don't run it at all before you have named the unit: "build or buy our platform" is not a decision, it is a dozen decisions wearing one coat.
Skill: which ledger wins?
Your startup's product is its recommendation engine. You should:
Buy a vendor engine
Build it entirely in-house
Buy, then wrap it
Buy now, build later
Correct. Right: the engine is the differentiator — build it, and own every part of the edge.
A billing system with vendor support and an API is best treated as:
Buy and adapt
Build it in-house
Build a competitor
Avoid the vendor
Correct. Right: billing is plumbing with a mature vendor — buy it, adapt it, move on.
The most undercounted cost in "buy" is:
The subscription fee
The upgrade tax
The integration cost
Support ticket volume
Correct. Yes: every release renegotiates the boundary between their roadmap and your code.
Practice on your own code
Pick a system component you're about to build or buy (or already built). Write both ledgers — visible and hidden — for each option. Then state, in one sentence, why the differentiator test lands where it does.
In the lab. The phase-3 checkpoint has you run the build-vs-buy ledger for the carrier-label integration on the returns-resolution service — both columns, the exit cost, and the workaround count against the workflow steps — and then write the one sentence in which the differentiator test lands.
Reveal: a worked ledger
A team needed document signing. Vendor option: mature e-signature SaaS (license + their API). Build option: 400 hours + legal review of signature validity. The differentiator test: signing is plumbing — customers buy the workflow, not the signature. Buy. The adapter around the vendor's API took two days, and the workflow team stayed on the workflow.
- Explain to a junior, in two sentences, why the exit cost matters more than the break-even year.
- Find one dependency or vendor in your own system this week and estimate the Monday-after cost of losing it. Name what that number changes about how you would negotiate.
- Predict what your team is spending its best quarters on in a year if the differentiator test is never asked out loud.
Your win
You can write the full cost ledger for build vs buy, apply the differentiator test that overrules the ledger, and recognize the 80/20 middle path.
Read and watch deeper
- "Choose Boring Technology", Dan McKinley — the buying-side posture, upgrade tax included.
- "Simple Made Easy", Hickey — the cost-of-complexity frame that makes the ledger honest.
- Software Architecture in Practice, Bass et al. — the quality-attribute trade-off analysis (ATAM) this ledger is a small version of.
- "In Defense of Not-Invented-Here Syndrome", Joel Spolsky — the primary statement of the differentiator rule: build your core competency, buy everything else.
- Watch: ArjanCodes YouTube — search "build vs buy".
- Next: lesson 0028 — Abstraction vs Duplication.
- Reference: Trade-offs — build vs buy, including the signal that you chose wrong.
Run your real ledger past your agent-teacher and let it poke the hidden columns.
Source page: lessons/0027-build-vs-buy.html
Lesson 0028 · Master Designer · Module 1
Abstraction vs Duplication
DRY (lesson 0002) says don't repeat knowledge. The Master's version: duplication is often the cheaper rent — the wrong abstraction is a mortgage you pay forever.
Mission tie-in: the second Complexity Trade-Off — where the principles of Phase I become something you balance instead of obey.
Knowledge: two kinds of rent
Duplication costs you on every change — edit N copies. Abstraction costs you on every read — everyone must understand the shape, and if the shape is wrong, every consumer pays. The trade is between these two rents.
The two forces: one source of truth (abstraction) against freedom to diverge (duplication). The unit of decision: the occurrence — this pair of call sites, and whether they share a reason to change (lesson 0006). Not the codebase, not the module: a codebase-wide "we don't repeat ourselves" is a policy, and policies cannot see reasons to change.
An abstraction is a claim that N call sites will change together forever. When the claim is true it converts N edits into one. When it is false it converts N independent edits into one negotiated edit — and negotiation grows worse than linearly with consumers, because each divergence adds a branch every consumer must read past and every test must cover. That asymmetry is the whole lesson: duplication's cost is linear in copies and paid only when you change; a wrong abstraction's cost is superlinear in consumers and paid on every read.
Where "prefer duplication" stops being true. When divergence is not a freedom but a bug. Three copies of an authorization check, a tax rule, a signing parameter or a wire format are not cheap rent — they are three places to forget, and the forgetting is a breach or a corruption, not an inconsistency. Duplication is cheap where divergence is harmless. It also fails across team boundaries, where nobody knows the other copies exist, so "edit N copies" quietly becomes "edit one". And it does not apply at all where the shape was given rather than guessed: a legal spec or a published format is not an abstraction you invented, so the usual risk of guessing wrong is absent.
Why the wrong abstraction is so expensive:
# the abstraction that guessed wrong
class FeeCalculator: # "all fees are one thing" — wrong
RATE = Decimal("0.01")
@classmethod
def fee(cls, amount: Money, kind: str) -> Money:
if kind == "bank":
return amount * cls.RATE
if kind == "wire":
return amount * cls.RATE
raise ValueError(kind)
# now wire fees change (clearing house), and the abstraction must be bent:
def fee(self, amount, kind):
if kind == "wire":
return amount * Decimal("0.02") # a branch, because one RATE was wrong
The two fees were never one knowledge (lesson 0002's false DRY) — the abstraction forced them into one shape, and every future divergence has to fight the shape. The duplicated version would have been two obvious functions, each change contained.
Sandi Metz's rule: duplication is far cheaper than the wrong abstraction. And the corollary — the abstraction isn't wrong if it was earned: three occurrences with the same change pattern, then extract. The rule of three is a rent calculation: by the third occurrence, the duplicated copies' change-rent exceeds the abstraction's read-rent.
Worked example: six copies of one authorization check
A platform team runs six independently deployed services. Each carries its own five-line copy of "can this user see this order?" — deliberately, on the reasoning that the services might diverge. They did diverge. Service four was written before a B2B feature introduced account scoping, and its copy never gained the extra clause.
The senior designer's first move is not to count the copies. It is to ask "if these two copies disagree, what is the worst outcome?" Here the answer is cross-tenant data exposure — and that answer, not the copy count, selects the strategy. This is the boundary condition made concrete: everything Sandi Metz says about the wrong abstraction is true of shapes, and an authorization rule is not a shape, it is a fact that must hold everywhere.
The second question separates the two things people conflate: is this shared knowledge, or merely shared shape? Two fee formulas that happen to look alike are shape. One policy about who may read an order is knowledge. Only knowledge must be single.
The obvious fix — extract a shared helper library and have all six services import it — is rejected, and not because it is wrong in principle. It creates a lock-step release dependency across six independently deployed services: a policy change now needs six coordinated deploys in a specific order, and until the last one lands the fleet disagrees with itself anyway (lesson 0033). Sharing code across a deployment boundary is a different trade than sharing code inside one.
What ships makes the knowledge single without making the deployments joint: the policy becomes a contract rather than a library — one authorization decision the services call, or a policy bundle shipped as data and versioned independently of the code that evaluates it. One source of truth, six independent release trains.
The bill for the seven weeks the drift went unnoticed: 340 B2B orders visible to the wrong account, contractual disclosure obligations to two customers, and a quarter of the account team's time on remediation. The engineering fix took twenty minutes. That ratio is the point — the expensive part was never the code, it was that nobody had asked what divergence would cost before choosing to allow it.
| Copies rarely change together | Copies always change together | |
|---|---|---|
| Divergence is harmless | duplicate — the default | extract when the third appears |
| Divergence is a bug | one source of truth, as data or a contract | extract now; don't wait for three |
Read the table as one rule: the cost of divergence, not the count of copies, chooses the strategy. The rule of three lives in the top row.
Pricing the two rents
Three copies of a validation routine, changed four times a year. Duplication rent: 3 edits per change × 4 changes = 12 edits a year, plus the cost of the copy someone forgets (roughly once a year, found in production).
Abstraction rent: 1 edit per change — if the three always change together. If they change independently, the abstraction converts three independent edits into one negotiated edit, and each divergence adds a branch that all three consumers must then read past.
So the deciding number is not "how many copies" but how often the copies change together. Three copies that always move as one are an abstraction waiting to be extracted. Three copies that move on separate schedules are three facts, and merging them buys 12 cheap edits at the price of every future divergence.
| You see this | What it costs | The move |
|---|---|---|
| a parameter named kind / type / mode | The abstraction is asking callers which one it really is. | Two functions with two honest names. |
| if isinstance(self, …) inside a base class | The shape was wrong and is now being bent from inside. | Split the hierarchy along the real difference. |
| a helper with six optional parameters | One function serving six callers, none of them well. | Inline it back; re-extract what actually repeats. |
| three copies that changed together, twice | The shared shape is evidence now, not a guess. | Extract it — this is what earned looks like. |
- "The rule of three is a law."
- It is a heuristic about evidence, and the evidence is copies that have changed together — not copies that exist. Three copies nobody has touched in two years are three copies, not a pattern waiting to be extracted.
- "Duplication always beats the wrong abstraction."
- Metz's line is about shape. Where the duplicated thing is a correctness invariant — authorization, tax, crypto parameters, a wire format — divergence is not freedom, it is the bug, and the top row of the table above does not apply.
- "Extracting to a shared library removes duplication."
- It relocates it and adds release coupling. Across independently deployed services, the shared library means everyone upgrades in lock-step or the fleet disagrees with itself — you traded a code problem for a coordination problem.
- "Any
kindparameter means split it." - A
kindthat selects between behaviours is the smell. Akindthat is data flowing through — a label written to a record, a tag on a metric — is fine. Read what the branch does, not that a branch exists.
When this is the wrong move
Do not prefer duplication for security and correctness invariants, for externally specified shapes you did not guess, or for code duplicated across a boundary where nobody will ever discover the second copy. In those cases pay the abstraction rent deliberately — and if the boundary is a deployment boundary, pay it as data or a contract rather than as shared code, so that "one truth" does not silently mean "one release train".
Skill: whose rent is cheaper?
Two fees look identical today but are set by different contracts. Extract?
Yes, extract now
No, keep apart
Extract with flags
Merge with comments
Correct. Right: two contracts are two knowledge; keep them apart until a third occurrence earns the merge.
The rule of three earns an extraction when:
three copies changed together
three copies merely exist
two copies look identical
one copy looks complicated
Correct. Exactly: copies that moved together are evidence of one knowledge; that is the claim the abstraction is making.
The worst position to be in is:
Plain duplication
Wrong abstraction
Earned abstraction
No abstraction
Correct. Exactly: every consumer already conforms to the wrong shape — that's the mortgage.
Practice on your own code
Find an abstraction in your codebase that was extracted before its time (a helper with a kind/type flag, a base class with branchy subclasses). Ask: was it earned by three occurrences with one change pattern? If not, sketch the duplication it should have been.
In the lab. The phase-3 checkpoint gives you the returns-resolution service with two near-identical refund calculations and one eligibility check copied into three call paths. You decide each with the divergence-cost question before the copy count, and justify why the two answers differ.
Reveal: a wrong-abstraction rescue
A Notifier base class with send() overridden by Email and SMS — extracted at two occurrences. When a third channel arrived with a different retry semantics, the base class's shape had to bend. Rescue: delete the base, keep two functions (lesson 0016's callables), let each channel evolve alone until a real shared step appears three times.
- Explain to a junior, in two sentences, why two identical-looking functions can be right and two identical-looking permission checks wrong.
- Find one abstraction in your own code this week with a
kindortypeparameter. Name its cost: which caller can no longer change without a negotiation? - Predict what that abstraction looks like in a year at the current rate of new cases: how many branches, and who still understands all of them?
Your win
You can compute the two rents — change-rent of duplication vs read-rent of abstraction — and you know the asymmetry that makes wrong abstractions the expensive mistake.
Read and watch deeper
- "The Wrong Abstraction", Sandi Metz — the essay this lesson is built on.
- The Pragmatic Programmer, Hunt & Thomas — the DRY chapter, read now with its limits visible.
- A Philosophy of Software Design, Ousterhout — ch. 6 "General-Purpose Modules Are Deeper": what an abstraction must earn to be deep.
- Refactoring (2nd ed.), Fowler — ch. 3 "Bad Smells in Code", the Duplicated Code entry, and the rule-of-three note in ch. 2.
- Watch: ArjanCodes YouTube — search "DRY" or "duplication".
- Next: lesson 0029 — Configuration vs Convention.
- Reference: Trade-offs — abstraction vs duplication; glossary — false DRY, rule of three.
Bring a flag-driven helper to your agent-teacher and decide whether it's an earned abstraction.
Source page: lessons/0028-abstraction-vs-duplication.html
Lesson 0029 · Master Designer · Module 1
Configuration vs Convention
Every config knob is a promise that the system varies in that way. Convention says "we already decided" — and removes a class of decisions. Choose by measuring real variation.
Mission tie-in: the third Complexity Trade-Off — deciding where the system stays open and where it decides for you.
Knowledge: knobs are promises
Configuration — the system exposes a setting, an env var, a parameter. The cost: every knob must be documented, validated, tested, and understood; every combination is a potential state the code must survive.
Convention — the system decides: "tables named by the model", "endpoints in api/", "config from config.py". The cost: the decision is invisible; newcomers must learn the convention; and when the convention doesn't fit, you fight it.
The two forces: explicit flexibility (configuration) against predictable defaults (convention). The unit of decision: the setting — one knob at a time, decided on its own evidence. "We are configuration-driven" is not a decision, it is forty decisions nobody made.
The mechanism is combinatorial. Ten boolean knobs describe 1,024 reachable systems; you test three of them. A configuration option is therefore not a feature — it is a promise to support a family of systems, and you only ever collect on that promise during an incident, when the running configuration turns out to be one of the 1,021 nobody exercised. Convention removes a decision from the world, and that, not the typing saved, is its value: one less thing that can differ between two environments at three in the morning.
Where it flips. Convention wins when you control the whole stack and the population of users is your own team. It stops applying when your users are outside your building — in a library or a platform, your convention is somebody else's constraint, and the seam you refuse to expose becomes the fork they maintain. It also stops applying when an operator must change behaviour without a deploy: a log level, a kill switch, a rate limit. There the knob is bought with minutes of recovery time, which is the strongest case any knob can make for itself.
The deciding question is measured variation:
# convention: one decision, made once, everywhere
class OrderService:
TIMEOUT_SECONDS = 30 # the team decided; no knob to mis-set
# configuration: real, measured variation — per deployment, per tenant
class OrderService:
def __init__(self, timeout: timedelta, retries: int) -> None:
self._timeout = timeout # differs between prod and a slow partner
The Twelve-Factor rule gives the boundary: settings that vary between deploys (credentials, hosts, feature flags) are configuration. Settings that don't vary are convention — hardcode the decision, don't expose a knob. And the framework lesson (Rails' "convention over configuration"): when you control the whole stack, conventions buy productivity for the 80% case; configuration exists only where the 20% genuinely diverges.
Worked example: forty-one settings and one that mattered
A payments service carries 41 settings. Nobody proposes deleting any, because each one might be needed.
A senior designer does not open the settings file. They dump the effective configuration from production, from staging, and from each tenant, and diff them — because the file is an inventory of possibilities and the diff is evidence about reality. The result: 34 of the 41 are identical everywhere. Of the seven that differ, three differ by accident, left over from hotfixes nobody reverted.
The question they ask next is answered from the config repository's history, not from opinion: "which of these has a human ever deliberately changed, and what happened when they did?" The git log answers in five minutes what a meeting would not settle in an hour.
The tempting response is to document all 41 and add validation. It is rejected because it addresses the wrong risk. Documentation makes a knob explicable and validation makes a bad value fail loudly, but neither shrinks the state space, and the incidents come from combinations of individually valid values that nobody ran together.
Here is what one of those combinations cost. CONNECTION_POOL_SIZE was 5 in one region and 50 everywhere else — drift from a two-year-old hotfix, never intended as a variation. A traffic spike queued checkout requests behind the small pool; p99 latency went from 240 ms to about 9 s for 38 minutes. At roughly 1,900 checkouts an hour and an observed 12% rise in abandonment, that is about 145 lost checkouts at a €62 average basket: near €9,000 of revenue, plus the incident review, plus the two days spent proving it was not the database. The knob had never been deliberately varied. It was a convention wearing a knob's clothes, and the disguise is what made the diagnosis slow.
What ships: delete the 34, keep the 7, give the survivors a typed settings object that fails at startup rather than at first use, and log the effective non-default settings on boot so the next incident responder can see the actual configuration in one line instead of inferring it.
# config.py — the knobs that survived, and the defaults that are conventions
@dataclass(frozen=True)
class Settings:
database_url: str # varies per deploy: genuine configuration
log_level: str = "INFO" # varies during incidents: earns its knob
pool_size: int = 20 # a default that IS the team's decision
@classmethod
def from_env(cls, env: dict[str, str]) -> "Settings":
try:
url = env["DATABASE_URL"]
except KeyError:
raise ConfigError("DATABASE_URL is required") from None
return cls(
database_url=url,
log_level=env.get("LOG_LEVEL", "INFO"),
pool_size=int(env.get("POOL_SIZE", 20)),
)
def log_effective(settings: Settings) -> None:
defaults = Settings(database_url=settings.database_url)
changed = {f.name: getattr(settings, f.name) for f in fields(Settings)
if getattr(settings, f.name) != getattr(defaults, f.name)}
logger.info("effective non-default settings: %s", changed or "none")
One more knob deserves naming: the feature flag. A flag is configuration with a scheduled death — it exists to carry a transition and it should be removed when the transition ends. Without a removal date it becomes a permanent branch in the code and a permanent row in the test matrix, and the branch that was never meant to survive is the one running in production two years later.
† The anti-pattern is the config graveyard: knobs nobody varies, kept "just in case" — every one is a YAGNI (lesson 0002) wearing env-var clothes. Grep for unused settings; delete them.| The setting | Verdict | Because |
|---|---|---|
DATABASE_URL | configuration | It differs in every environment — measured variation. |
MAX_PAGE_SIZE = 100 | convention | One number the team chose; nobody has ever varied it. |
LOG_LEVEL | configuration | Operators change it during an incident, without a deploy. |
TEMPLATE_DIR = "templates/" | convention | A knob that has never been turned in the project’s life. |
ENABLE_NEW_CHECKOUT | configuration, with an expiry date | A flag is a transition (lesson 0035), not a permanent setting. |
- "Configuration is free — it's just a variable."
- Each option multiplies the reachable configuration space and adds a line to every incident's "what is different here?" investigation. The cost is not the variable; it is the combinations nobody tested and the diagnosis time they buy.
- "Making it configurable means we can decide later."
- It means you decided never to decide, and delegated the decision to whoever edits the YAML at two in the morning. A knob without a documented owner and a tested range is an unowned decision.
- "Convention just means hardcoding, which is bad practice."
- A named constant in one place with the reason beside it is a decision anyone can find and change. An environment variable read in nine places is the same decision, diffused, with no single place to read it.
- "Feature flags are configuration."
- They are temporary configuration with an owner and a removal date. A flag older than the feature it guarded is a permanent, rarely exercised branch — and the untested path in a two-year-old flag is where the interesting outages live.
When this is the wrong move
Expose the knob anyway when your users are not your team: a library or platform that hardcodes your convention forces its users to fork it, and the seam you saved costs them a maintenance burden. Expose it for genuinely per-tenant behaviour. And expose it for incident controls — kill switches, log levels, rate limits, circuit-breaker thresholds — where the knob's value is measured in recovery time rather than in variation. A knob that has never been turned but can be turned in thirty seconds during an outage is earning its keep in a currency the variation test does not measure.
Skill: knob or decision?
A value that never varies across deployments should be:
A configuration knob
A convention choice
A documented config
A feature flag
Correct. Right: no measured variation, no knob — the team's decision, hardcoded once.
The strongest argument for keeping a knob you almost never turn is:
It shortens an incident
It might vary someday
It documents the design
It replaces a convention
Correct. Right: a kill switch or log level is paid for in recovery time, a currency the measured-variation test cannot see.
A config graveyard forms when:
Knobs never vary
Knobs get varied
Conventions are used
They are documented
Correct. Exactly: knobs added 'just in case', never varied — YAGNI in env-var clothing.
Practice on your own code
List the config settings in your system. For each, answer: does it vary per deployment or per tenant? If it doesn't, convert it to a convention — hardcode the decision and delete the knob. Grep for settings read nowhere and delete those too.
In the lab. The phase-3 checkpoint has you diff the effective configuration of the returns-resolution service across its environments, delete every setting that has never varied, and give the survivors a typed settings object that fails at startup.
Reveal: a graveyard audit
An app exposed MAX_PAGE_SIZE (never varied), RETRY_DELAY (varied between prod and staging), and FEATURE_X_ENABLED (dead). Audit: MAX_PAGE_SIZE became a constant; RETRY_DELAY stayed config, now validated at startup; the dead flag was deleted. Settings count: 14 → 5, and the survivors all varied.
- Explain to a junior, in two sentences, why a knob is a promise rather than a convenience.
- Find one setting in your own system this week that has never been varied. Name its cost: what would it add to the next incident's "what is different here?" list?
- Predict what your settings file looks like in a year at the current rate of additions — and which of today's flags will still be there.
Your win
You can audit any setting with the measured-variation test, convert non-varying knobs into conventions, and clear the graveyard.
Read and watch deeper
- The Twelve-Factor App — Config: the canonical statement of what belongs in configuration.
- "Convention over Configuration", The Rails Doctrine — the strongest statement of the trade, from the framework that made it a movement.
- "Feature Toggles", Pete Hodgson on martinfowler.com — the taxonomy of flags by lifespan, and why a toggle without a removal plan becomes permanent configuration.
- Software Architecture in Practice, Bass et al. — configurability as a quality attribute with a price.
- Watch: ArjanCodes YouTube — search "configuration" or "12 factor".
- Next: lesson 0030 — Generality vs Specificity.
- Reference: Trade-offs — configuration vs convention; glossary — config graveyard.
Bring your settings inventory to your agent-teacher and run the variation test on every knob.
Source page: lessons/0029-configuration-vs-convention.html
Lesson 0030 · Master Designer · Module 1
Generality vs Specificity
A general module is a bet on the future; a specific module is a fact about today. Ousterhout's law: generality is earned when it makes the specific case simpler — not when it adds cases.
Mission tie-in: the last Complexity Trade-Off — and the one that decides how much machinery every module carries.
Knowledge: generality that pays vs generality that costs
The two forces: reusable and future-proof (general) against simple and fit-for-purpose (specific). The unit of decision: the module — or more precisely one interface at a time, since generality is bought and paid for at interfaces.
The currency is parameters. Every parameter is a question the caller must answer and the reader must resolve, so the real axis is not general-versus-specific but interface width against implementation coverage. Ousterhout's test works because a deep module hides complexity: a general interface that is narrower than the specific one — fewer decisions pushed onto callers — is deeper, and therefore cheaper for everyone. A general interface that is wider has simply moved complexity out of the module and onto every call site.
Where "stay specific" stops applying. The rule assumes the decision is cheap to reverse, which is true of a function signature with one caller and false of a published boundary. A public API, a wire format, an on-disk schema, or a plugin contract for third parties cannot be narrowed later without versioning and migrating everybody, so a little generality there — a version field, a tolerant reader, an extension point — is cheap insurance rather than speculation. Two other exemptions: a second case that is known and dated ("the Germany launch is in the Q3 plan") is evidence, not a maybe; and specificity that bakes a false constraint into data — one currency in a schema, one address shape in a table — is not simplicity, it is a migration you have scheduled without telling anyone.
Two kinds of generality:
- Earned generality — the module solves today's case through a shape that naturally covers tomorrow's. It makes the current code simpler because the general interface is cleaner than the specific one.
- Speculative generality — parameters, hooks, and abstractions for cases that don't exist. It taxes every read of the current code to pay for futures that may never arrive.
# speculative: a "pipeline" framework for one transformation
class Transformer:
def __init__(self, steps: list[Step], concurrency: int = 1,
retry: RetryPolicy | None = None, metrics: Metrics | None = None):
...
Transformer(steps=[normalize], concurrency=1, retry=None, metrics=None).run(data)
# earned: the general name covers the specific case, no extra machinery
def transform(data: list[dict]) -> list[dict]:
return [normalize(row) for row in data]
The specific version is also the general one: any future step is a new function, composed (lesson 0016). The framework added configuration (lesson 0029), concurrency, and retries — none of which the one case needs.
The test, from Ousterhout: does the generality make the current problem simpler? If the general module is harder to use for today's case than the specific one would be, the generality is speculative. Generalize only when the second use case is real — then the general shape is usually visible (rule of three again).
Worked example: the export framework that was bypassed
Product asks for a CSV export of returns. The engineer proposes an Exporter framework: pluggable formats, streaming, configurable columns, so that "adding XLSX later is one class".
The senior designer's first move is to sort the decisions by reversibility, because that is the axis on which this trade actually turns. Three decisions are hiding in one ticket. The function signature has one caller and is free to change. The file format is written, downloaded and forgotten, so nobody is holding an old one. The stored per-tenant export configuration is neither: it is persisted, so every change to its shape is a migration across every tenant. The question they ask is "which of these can I not take back?" — and the answer is only the third.
So the framework is rejected, with a number attached. It is roughly 380 lines and five new concepts, read by everyone who touches export for as long as it exists. The second format arrived nine months later — and wanted a workbook rather than a stream of rows, so it did not fit the framework's row-at-a-time assumption anyway. The machinery was paid for over nine months and then bypassed. That is the characteristic failure of speculative generality: not that the future never came, but that it came in a shape the guess did not cover.
What shipped was one 30-line function. When XLSX arrived it became a second function and a two-line dispatch — about 75 lines in total, with the two formats' differences visible rather than dissolved into a shared shape they do not share (lesson 0028):
def export_returns(rows: list[ReturnRecord], fmt: str) -> bytes:
if fmt == "csv":
return _to_csv(rows) # a stream of rows
if fmt == "xlsx":
return _to_xlsx(rows) # a workbook: genuinely a different shape
raise UnsupportedFormat(fmt)
Two branches and two honest functions. When a third format arrives and all three share a real step — say, redacting personal fields before writing — extract that step. Not the framework.
The rule the team wrote down afterwards is the one worth stealing: generalise code on the second real case; design persisted shapes and public contracts for the third. The two halves differ only in what it costs to be wrong.
† AI's default is speculative generality: ask for "a function" and get "a configurable framework". The prompt discipline from lesson 0010 — "solve this case, no parameters beyond it" — is the countermeasure.| You see this | What it costs | The move |
|---|---|---|
| a default that no caller ever overrides | Generality nobody asked for, read by everybody. | Delete the parameter; hardcode the one value. |
| **kwargs forwarded “for flexibility” | The contract is now unknowable without reading the callee. | Name the arguments you actually accept. |
| an interface with one implementation | Indirection with nothing on the other side to vary. | Call the concrete thing until case two exists. |
| a plugin system with two in-tree plugins | Machinery for an ecosystem that does not exist. | Two functions and a lookup. |
- "Speculative generality means writing too much code."
- It means interface width. A twenty-line function with five optional parameters is more speculative than a two-hundred-line one with two required arguments, because the twenty-line one made five decisions everybody else's problem.
- "YAGNI means never plan ahead."
- YAGNI is about machinery, not about reversibility. Plan ahead precisely where you cannot take it back: persisted schemas, public APIs, message formats, anything a third party has already integrated against.
- "An interface with one implementation is always wrong."
- The fake in your tests is the second implementation. A port justified by determinism (lesson 0023) is earned even when exactly one adapter ever reaches production.
- "The rule of three applies to generality too."
- It applies to extraction from evidence you already have. For a published boundary, waiting for the third case means three incompatible versions in the wild and a migration you have to run for other people.
When this is the wrong move
Pay for generality up front at published boundaries — public APIs, wire formats, persisted schemas, plugin contracts — where the cost of being wrong is everyone else's migration rather than your afternoon. Pay for it when a second consumer already exists and is dated. And accept that a library has a larger generality budget than an application, because serving callers you have not met is its whole job: the same optional parameter that is speculative in your service is the reason someone can adopt your package without forking it.
Skill: earned or speculative?
Generality is earned when it:
Simplifies today's case
Pays for tomorrow
Matches the future
Adds more parameters
Correct. Right: the general shape that makes today's case simpler is the only generality that pays now.
A one-use transformation wrapped in a "pipeline framework" is:
Truly earned generality
A safe investment
Purely speculative generality
A simplicity win
Correct. It taxes today's readers for tomorrow's maybe — the speculative shape.
The honest trigger for generalizing is:
A someday use case
What AI prefers now
A second genuine case
A framework's default shape
Correct. Right: a real second use case — then the general shape is visible and earned.
Practice on your own code
Find a module with parameters or machinery no caller uses (defaults never overridden, hooks never hooked, config from lesson 0029 that's always the same). Remove the machinery — make the specific case the whole module — and check that every caller got simpler.
In the lab. The phase-3 checkpoint gives you the returns-resolution service with a configurable "resolution strategy" registry holding exactly one strategy. You strip the machinery, then argue the counter-case: which one of its shapes is persisted, and therefore should keep its version field.
Reveal: a de-generalization
A PaymentProcessor accepted webhook_url: str | None and idempotency_store: ...; every caller passed the same values. De-generalized: the webhook and idempotency behaviors became hardwired parts of the processor's contract; the parameters vanished; the constructor shrank from six lines of options to two required arguments.
- Explain to a junior, in two sentences, why generality is measured at the interface rather than in lines of code.
- Find one parameter in your own code this week that no caller ever overrides. Name its cost: how many readers have resolved that question, and what did it buy them?
- Predict what happens in a year to a persisted shape you designed for exactly today's case: what migration are you scheduling for your future self?
Your win
You can distinguish earned from speculative generality with the simplification test, and strip unearned machinery without losing capability.
Read and watch deeper
- A Philosophy of Software Design, Ousterhout — ch. 6 "General-Purpose Modules Are Deeper": the simplification test this lesson uses.
- "Simple Made Easy", Hickey — the distinction between complexity carried and complexity deferred.
- Refactoring, Fowler — the "speculative generality" smell entry.
- "Hyrum's Law" — the one-sentence primary statement of why a published interface is not cheap to narrow later, whatever the contract says.
- Watch: ArjanCodes YouTube — search "YAGNI" or "simplicity".
- Next module: lesson 0031 — System Trade-Offs.
- Reference: Trade-offs — generality vs specificity; glossary — speculative generality.
Bring a never-overridden parameter to your agent-teacher and de-generalize it together.
Source page: lessons/0030-generality-vs-specificity.html
Lesson 0031 · Master Designer · Module 2
Synchronous vs Asynchronous Systems
Sync is simple until it isn't fast; async is fast until it isn't simple. The Master's move: choose per workflow, not per system — and know the failure modes you're buying.
Mission tie-in: the first System Trade-Off — where lesson 0025's event-driven machinery becomes a measured decision instead of a default.
Knowledge: the ledger of the two modes
| Synchronous | Asynchronous | |
|---|---|---|
| Caller | Gets the result now | Gets an acknowledgment; result later |
| Flow | Visible in one call stack | Distributed across handlers (lesson 0025's honest cost) |
| Failure | Fails in your face, retryable by the caller | Ordering, retries, dead letters, visibility — a new failure zoo |
| Wins | Simplicity, debuggability, consistency | Decoupling, resilience, latency hiding, scale |
The two forces are immediacy — the caller holds the answer inside one call stack, so the flow is visible, debuggable and consistent — and decoupling — the caller stops inheriting the callee's latency and downtime. The unit of decision is the workflow step: one caller, one obligation. Never the system.
The mechanism is arithmetic. A synchronous call multiplies availabilities. Chain five dependencies at 99.9% each and the workflow is 0.999⁵ ≈ 99.5% available: on 40,000 checkouts a day that is roughly 200 failures a day that no single component caused, and every team can honestly say their service was up. Async replaces the multiplication with durable storage. The queue holds the obligation while the callee is down, so the caller's success no longer depends on the callee being up at that instant. That is the entire benefit — and it is paid for in state: a message that exists, is not yet applied, and may be applied twice or out of order.
So the rule stops applying at two boundaries. Where the caller needs the result to choose its next step, async does not remove the wait; it relocates the wait into a state machine you now own, debug and staff. And where volume is low, the coupling you removed was cheap, while the queue, its dead letters and its correlation ids are a permanent operational cost that arrives every week regardless of traffic.
The deciding question is about the workflow: does the caller need the result to continue? Confirming an order needs the payment result now — sync. Sending the confirmation email doesn't gate anything — async. Most systems are a mix:
def place_order(cart: Cart, gateway: PaymentGateway) -> Order:
charge = gateway.charge(cart.total, cart.token) # sync: caller needs it
order = order_service.confirm(cart, charge.id)
bus.publish(OrderConfirmed(order)) # async: reactions don't gate
return order
The async failure modes you must be able to name before you adopt it: ordering (handlers may see events out of order), at-least-once delivery (idempotency needed), dead letters (what happens when a handler keeps failing), and observability (the flow isn't in one stack trace). If you cannot answer "what happens when the handler dies mid-way?" — the workflow isn't ready for async.
Worked: one step, reasoned out loud
A returns flow: the customer files a return, the service decides eligibility, then a carrier API mints a shipping label. Volume is 3,000 requests a day. The carrier's label endpoint answers in 240 ms at p50 and 2.4 s at p99, and fails about 0.3% of the time. Where does the sync/async boundary go?
The first thing a lead looks at is not the latency graph. It is the customer's next action. Eligibility decides what the customer does next — pack the item, or call support — so it gates. The label does not: the customer's next action is to wait for an email, and a label that arrives ninety seconds later changes nothing they do. That one question, what does the caller do differently with this result?, settles more sync/async arguments than any percentile.
# the shape that follows from the question
def request_return(req: ReturnRequest, bus: Bus) -> ReturnCase:
case = policy.decide(req) # sync: the customer acts on this now
bus.publish(ReturnApproved(case.id)) # async: label, restock, email react
return case
# the rejected shape: tidy, straight-line, and on the customer's path
def request_return_inline(req: ReturnRequest, carrier: CarrierApi) -> ReturnCase:
case = policy.decide(req)
label = carrier.create_label(case) # p99 2.4 s, 0.3% failure, not gating
return case.with_label(label)
The alternative the lead rejects is the tidy one: both calls inline, so the response carries the label and the code reads as a straight line. It is rejected on arithmetic. 0.3% of 3,000 is nine customers a day told their return failed when the return had in fact succeeded — eligibility was already decided; only the label step broke. Their recovery is to refile, which produces exactly the support contact the work was meant to remove.
The mirror-image move is rejected too: making eligibility async as well, "so everything is uniform". Uniformity is not a force. Async eligibility turns a two-second answer into a case the customer must come back for, raises the contact rate rather than lowering it, and buys the team a status-polling screen nobody asked for.
The consequence shows up over the following year, not on launch day. In the event-shaped version, every new reaction — restock, fraud scoring, warehouse pre-notice — is one more subscriber to a fact that has already happened, and the customer's path stays one call long. In the inline version, each new reaction adds a call to the critical path: after four features the p99 is the sum of five p99s, and any one of those five dependencies can now fail a return that the business had already approved.
† The asymmetric default: sync unless a concrete reason demands async. Async-everywhere is the pattern that turns every feature into a distributed-systems problem (lesson 0025's cost, now at system scale).| Failure mode | The question you must answer first | A concrete answer |
|---|---|---|
| out-of-order delivery | What if OrderShipped arrives before OrderConfirmed? | Key handlers by order id; ignore events older than the state. |
| at-least-once delivery | What happens when this handler runs twice? | An idempotency key, or INSERT … ON CONFLICT DO NOTHING. |
| a handler that keeps failing | Where does message five go after three attempts? | A dead-letter queue that a human actually watches. |
| no single stack trace | How do you follow one order across six handlers? | A correlation id on every event and every log line. |
- "Async is faster."
- End-to-end it is usually slower: you added a broker hop and a scheduling delay. What async changes is who waits — the caller is released, the work still takes as long. Say "the customer stops waiting", not "the system got faster", or you will be measured on a number you did not improve.
- "A queue makes the system more reliable."
- A queue makes failure quiet. Reliability arrives only with the thing nobody budgets for: an owner who reads the dead-letter queue on a schedule. An unmonitored DLQ is silent data loss with extra infrastructure.
- "We publish events, so the services are decoupled."
- You swapped compile-time coupling for schema coupling. Consumers still depend on the fields you emit; the difference is that breaking them now fails in production at 3 a.m. instead of in CI. Decoupling is what the compatibility rules buy (lesson 0033), not what the transport buys.
- "Sync means HTTP and async means a message broker."
- The mode is about gating, not transport. An HTTP call that returns
202 Acceptedwith a case id is async; a broker request/reply where the caller blocks on the response is sync with more moving parts.
When this is the wrong move
The sync-first default is the wrong starting point in three recognisable situations. When a step fans out to several downstream systems, sync makes you inherit the union of their downtime — five 99.9% dependencies is a worse promise than any of them made. When the callee is a third party whose latency you do not control and whose contract gives you no percentile, a synchronous call exports their bad day onto your customers. And when the work is inherently bulk — reprocessing 40,000 rows, regenerating every label — a synchronous request times out before correctness is even in question.
There is also a team-shaped exception. If the organisation already runs a bus with dead-letter monitoring, replay tooling and correlation ids in every log line, the marginal cost of one more consumer is near zero, and the "async is complex" argument is quoting a price the team has already paid. Judge the machinery you actually have, not the machinery a blog post assumes.
In the lab: the companion Phase 3 exercise hands you a returns service that awaits a carrier call inside the customer's request. You classify each step by whether the caller gates on it, move the non-gating work behind an event, and answer the four failure questions before the change counts as done.
Skill: which mode for this workflow?
What decides whether a workflow step may run asynchronously?
Whether the caller needs it
How long the step takes
Whether the team runs queues
How often the step fails
Correct. Right: if the caller's next decision needs the result, it gates, and gating steps stay synchronous whatever their latency.
The confirmation email doesn't gate anything. It should be:
Published as an event
Awaited inline before responding
Retried inline until sent
Dropped when it fails
Correct. Right: a reaction with no result the workflow needs — publish the fact and let the handler run when it can.
Before adopting async, you must be able to answer:
Who is the consumer
How fast it runs
What happens on failure
Which bus to use
Correct. Exactly: handler failure behavior, ordering, and delivery guarantees are the async risk inventory.
Practice on your own code
List the workflows in your system. For each, mark: does the caller gate on the result? Where failure modes exist (ordering, duplicates, dead handlers), write the answer to "what happens if the handler dies mid-way?" — on paper, before you build more async.
Reveal: a workflow-by-workflow decision
An e-commerce system: place order — sync (payment gates confirmation). Send receipt — async (event, idempotent by order id). Update inventory — async but ordered per SKU, with a dead-letter queue for the warehouse sync. Analytics — async, best-effort, no dead-letter (loss is acceptable). Four workflows, three modes, each decision written down with its failure answer.
- In two sentences, explain to a junior why "make it async" is not a performance fix.
- Find one step in your own system that sits on a caller's path without gating anything. Name its cost in the units your team already tracks — p99 milliseconds, failed requests per day, or pages per month.
- Predict the one-year state if nobody moves it: how long is the critical path after four more features, and what is the first incident that comes from it?
Your win
You can classify each workflow by its gating requirement, and you carry the async failure inventory — ordering, at-least-once, dead letters, observability — as a checklist before adopting it.
Read and watch deeper
- Designing Data-Intensive Applications, Kleppmann — ch. 11 "Stream Processing": async systems and their guarantees, rigorously.
- "What Do You Mean by Event-Driven?", Fowler — the vocabulary for choosing between the modes.
- Release It!, Nygard — 2nd ed., ch. 4–5 (stability antipatterns and patterns): where the availability multiplication and its circuit breakers come from.
- Enterprise Integration Patterns, Hohpe & Woolf — the messaging vocabulary (channel, dead letter, idempotent receiver) this lesson's inventory uses.
- Watch: ArjanCodes YouTube — search "async" or "event driven".
- Next: lesson 0032 — Consistency vs Availability.
- Reference: Trade-offs — synchronous vs asynchronous; glossary — idempotent, dead-letter queue, correlation id.
Walk your workflow list past your agent-teacher and classify each one together.
Source page: lessons/0031-sync-vs-async-systems.html
Lesson 0032 · Master Designer · Module 2
Consistency vs Availability
When the network fails, a distributed system must choose: answer with possibly-stale data, or wait for the truth. The Master's skill is choosing per operation — not per database.
Mission tie-in: the second System Trade-Off — where lesson 0011's invariants meet the real world of partitions and timeouts.
Knowledge: the choice under failure
CAP says: during a partition (network failure between nodes), you choose between consistency (every read sees the last write) and availability (every request gets an answer). The catch — Kleppmann's correction: almost no system is purely one or the other, and CAP is a framing, not a product picker. The real question per operation: what does a stale answer cost, and what does a timeout cost?
The two forces are agreement — every read reflects the last write, so no one acts on a fact that is no longer true — and answering — every request gets a response, so no one is blocked by a machine they cannot see. The unit of decision is the operation, and on a good day the field: one read, one thing the caller will do with it.
The mechanism is an epistemic limit, not an engineering weakness. A node that stops hearing from its peer cannot tell "the peer is down" from "the network is slow" from "the peer is fine and my reply is queued". Facing that ambiguity it has exactly two moves: wait for confirmation it may never get, or answer from what it holds locally. Every consistency protocol ever written is a rule for choosing between those two, and both are correct — they differ only in which failure the business can absorb.
The cost is not only paid during outages, which is what most teams miss. Strong consistency means a round trip to a quorum on the critical path, so the latency floor of a read is the network distance between replicas, on every ordinary day. That is why the rule stops applying in two places. If your data lives on one node in one region, there is no partition to trade around and a consensus layer buys you its costs with none of its benefits. And if a stale answer is illegal rather than merely expensive — a regulated disclosure, a credit decision — this is not a cost comparison at all; the answer is fixed before the discussion starts.
| Operation | Stale read costs… | Timeout costs… | Lean |
|---|---|---|---|
| Account balance | Overspending, legal exposure | Frustration, retry | Consistency |
| Product stock display | A wrong "in stock" hint | Lost sale, empty page | Availability |
| Order status | Confused customer | Retry-able annoyance | Availability |
| Fraud decision | Chargeback | Blocked purchase | Consistency |
The engineering that makes this a design instead of a gamble:
- Decide at the data boundary: what must be strongly consistent (a balance, a reservation) vs what tolerates eventual consistency (a stock hint, a cache, a search index).
- Make staleness visible: "as of 2 minutes ago" — an honest UI beats a silently stale one.
- Prefer constraints that work offline: an idempotency key on writes (charge once, even if retried) buys a lot of the safety without sacrificing availability.
- Contain the strong-consistency core: the smaller the always-consistent surface (lesson 0012's aggregates), the less the system pays for it.
Both of the middle two are code you can point at. The idempotency key buys availability without giving up write safety:
def charge(self, amount: Money, token: str, key: IdempotencyKey) -> ChargeId:
existing = self._charges.find_by_key(key)
if existing is not None:
return existing.id # the retry returns the first answer
charge_id = self._gateway.charge(amount, token, key)
self._charges.record(key, charge_id)
return charge_id
The detail that makes this work: the key is generated by the caller and reused across every retry of the same intent. A client that mints a fresh key per attempt gets two charges, and the mechanism has bought nothing — the safety lives in the caller's discipline as much as in the server's table.
And staleness, made visible instead of silent:
@dataclass(frozen=True)
class StockHint:
quantity: int
as_of: datetime # the UI can say "as of 2 minutes ago"
A number with a timestamp is an honest answer. The same number without one is a promise the system cannot keep — and the customer discovers that at checkout instead of on the product page.
Worked: the same call, two different answers
A returns service checks eligibility by reading the order system, which is authoritative for orders. The check needs two facts: when was this ordered (the 30-day window) and has it already been refunded. One API call, so most teams treat it as one consistency decision. The order system degrades for 40 minutes. What should the returns service do?
The first thing a lead looks at is not the endpoint — it is each field's mutability and the action it authorises. The order date is immutable: once written it never changes, so a copy from an hour ago is as true as a copy from a millisecond ago. The refund status is mutable and it authorises money leaving the company: a stale "not yet refunded" is the precise input that produces a second refund. Same call, same partition, opposite answers.
def check_eligibility(order_id: str, orders: OrderReader) -> Eligibility:
snapshot = orders.local_snapshot(order_id) # date: immutable, cache freely
if not snapshot.within_days(30):
return Eligibility.rejected("outside the window")
try:
status = orders.refund_status(order_id, max_staleness=timedelta(0))
except Unavailable:
return Eligibility.deferred("decision within 4 hours") # the third answer
return Eligibility.approved() if status.none_yet else Eligibility.rejected("already refunded")
The alternative a lead rejects is the one that sounds like availability: cache the whole order response for five minutes so the flow keeps working. Price it. At 3,000 returns a day, a 40-minute degradation covers about 83 requests; roughly 4% are re-filings of a request the customer already made, so about three of them can be approved twice. At an average refund of €62 that is €186 — trivial money, and entirely the wrong number to look at. Each duplicate is a manual clawback, twenty minutes of an agent's day, an awkward conversation, and a measurable move in the fraud-loss guardrail the initiative promised not to worsen.
The opposite alternative is rejected too: block every check until the order system returns. Those 83 customers see a spinner and then an error. If only half of them contact support — conservative, since the baseline contact rate for returns is 41% — that is 34 contacts at roughly six minutes each, 3.4 agent-hours bought in exchange for a strictness the date field never needed.
Which is why the honest design has a third answer. "Stale or timeout" is a false binary produced by assuming every request must resolve now. A deferred case — "received, decision within four hours" — keeps the promise the outcome actually made, needs no stale read, and blocks no one. It comes with its own arithmetic: 83 deferred cases in one outage would swamp a manual review queue capped at 50 a day, so deferrals must route to an automatic retry once the dependency recovers, with the agent queue as the exception path, not the default.
Over a year the difference is which conversation the team keeps having. With per-field decisions written down, a new field arrives and someone asks "immutable or authorising?" — a two-minute conversation. With one blanket TTL on the whole response, every new field silently inherits a staleness budget nobody chose for it, and the next duplicate-refund incident is discovered by finance, not by monitoring.
† The framer's trap: saying "our database is CP" and moving on. Products have configurable knobs (sync vs async replication, quorum sizes); the per-operation choice is yours, and most operations in most systems can afford availability with an idempotent write path.- "CAP means we pick C or A for our database."
- CAP describes behaviour during a partition, which is a rare minority of your uptime. The rest of the time the live trade is latency versus consistency — the "else" half of PACELC — and that one you pay on every single read, forever.
- "Eventual consistency means the data is wrong for a while."
- It means the replicas converge with no promised bound unless you specify one. The design work is the bound and the disclosure: "at most 60 seconds behind, and the screen says so". Undisclosed staleness is not eventual consistency; it is a bug with a fashionable name.
- "Adding a cache is a performance decision."
- A cache is a consistency decision wearing a performance costume. Choosing a five-minute TTL is choosing a five-minute staleness budget for every field behind it — including the fields that authorise money.
- "Strong consistency is the safe default."
- Refusing to answer is also a failure, just one that shows up in a different team's dashboard. A checkout that waits for a quorum it cannot reach has not protected the customer; it has moved the damage from finance to revenue.
When this is the wrong move
Per-operation reasoning is overhead you should decline in three cases. A single-node, single-region system has no partition to reason about: the ceremony buys nothing, and the cache you add "for availability" only adds a way to be wrong. A legally constrained read — a regulated balance, a consent record, a credit decision — is not a cost comparison; the stale answer is not cheaper, it is prohibited. And a user reading back their own action is a special case worth knowing: a technically defensible stale read of a change they just made reads as a broken product. Session (read-your-writes) consistency for that one caller is far cheaper than making the whole operation strongly consistent for everyone.
In the lab: the companion Phase 3 exercise gives you a returns service that caches an authoritative read wholesale and auto-approves from it. You separate the immutable fields from the authorising ones, add the deferred third answer, and prove with a test that a duplicate submission during a simulated outage cannot produce a second approval.
Skill: which way does this operation lean?
During a partition, an eligibility check needs the order date and the refund status. Choose:
Serve the date, defer the status
Serve both from the cached copy
Block both until the partition clears
Fail the request and log it
Correct. Right: the date is immutable so a copy is as true as the original, while the status authorises money and must not be guessed.
A balance read leans towards consistency because a stale answer:
Authorises spending money twice
Costs more to compute
Violates the replication protocol
Irritates the waiting customer
Correct. Right: the stale read authorises a second irreversible action, and that failure is dearer than any timeout.
An idempotency key on charges buys safety by:
Blocking all retries
Making retries harmless
Guaranteeing fresh reads
Serialising concurrent writes
Correct. Right: a retried request returns the first answer instead of creating a second charge — charge-once safety with retry-anytime availability.
Practice on your own code
List your system's read operations. For each, write the two cost sentences: "a stale read costs…" and "a timeout costs…". Mark the lean. Then check: where staleness is served, is it visible to the user?
Reveal: an operation ledger
A booking system: seat availability — stale costs a bad hint, timeout costs the sale → cache with "live availability" refresh. Booking confirmation — stale costs double-booking → strict, with an idempotency key per booking attempt. Price display — stale costs a wrong quote → cache with a visible "as of" timestamp. Three reads, three leans, all written down.
- In two sentences, explain to a junior why "we chose a CP database" is not an answer to "can this read be stale?"
- Find one cached or replicated read in your system that authorises an action — a refund, a discount, an entitlement. Name the staleness budget somebody implicitly chose for it, and what a wrong answer costs once.
- Predict the one-year state if it is left alone: which team discovers the first bad decision, and how many of them will have happened by then?
Your win
You can write the two-cost analysis for any read operation, lean per operation instead of per database, and use idempotency keys to buy availability without losing write safety.
Read and watch deeper
- "Please stop calling databases CP or AP", Kleppmann — the framing this lesson's per-operation choice comes from.
- Designing Data-Intensive Applications, Kleppmann — ch. 5 (replication), 8 (the trouble with distributed systems), 9 (consistency and consensus).
- Release It!, Nygard — 2nd ed., ch. 5: graceful degradation, the "third answer" this lesson uses.
- Daniel Abadi, "Problems with CAP, and Yahoo's little known NoSQL system" — the original PACELC argument: consistency also costs latency when there is no partition at all.
- Watch: ArjanCodes YouTube — search "CAP theorem" or "eventual consistency".
- Next: lesson 0033 — Coupling vs Coordination.
- Reference: Trade-offs — consistency vs availability; glossary — eventual consistency, idempotency key.
Bring your read-operation ledger to your agent-teacher and cost the failures together.
Source page: lessons/0032-consistency-vs-availability.html
Lesson 0033 · Master Designer · Module 2
Coupling vs Coordination
Decouple two systems and they stop breaking each other — but someone must keep them aligned. Conway's law says the alignment cost follows team boundaries. This is the trade where people are a force.
Mission tie-in: the third System Trade-Off — where architecture stops being code-only and starts being about teams.
Knowledge: the two costs
Coupling — two components share a contract: an interface, a schema, a synchronous call. When one changes, the other must change with it. Cost: change amplification (lesson 0006) — a deploy on one side is a coordinated deploy on both.
Coordination — two components are independent, but someone must keep them aligned: versioned contracts, migration windows, compatibility rules, message schemas with both-ways guarantees. Cost: an ongoing conversation that never ends.
The two forces are change amplification — one edit forces a second edit somewhere you do not own — and alignment cost — the standing human work of keeping two independent things agreeing. The unit of decision is the boundary: one pair of parts, one contract between them.
The mechanism is that information has to cross the boundary somehow, and there are only two carriers. Either the agreement is compiled into both sides (a shared type, a schema, a synchronous call), in which case a change is cheap to make and expensive to make separately; or it is maintained by people (versions, compatibility windows, deprecation notices), in which case separation is cheap and each change costs a negotiation. You are choosing which of those two bills you would rather receive, and there is no third option where neither arrives.
The two bills scale differently, which is what actually decides most cases. Coupling cost grows with call sites × change frequency: a stable contract with a hundred callers costs nearly nothing, because the multiplier is zero. Coordination cost grows with the number of parties that must agree — n(n−1)/2 pairs — and it is charged whether or not anything changed, because staying aligned is itself the work. So the rule inverts at two boundaries: where the contract barely changes, coupling is nearly free and coordination machinery is pure overhead; and where both sides sit inside one team with one deploy train, the "coordination" is a sentence at standup, and paying for versioned contracts buys independence nobody will use.
You cannot have neither. Zero coupling with zero coordination is two systems that stopped agreeing. The design question is which currency you pay in:
- Coupling pays when the contract is stable: a shared library for a domain concept used by two services, versioned together, deployed together.
- Coordination pays when the sides evolve at different speeds: an event contract (lesson 0025) where consumers tolerate unknown fields and producers keep compatibility.
Conway's law is the tiebreaker: systems mirror the communication structure of the people who build them. A boundary that separates two teams is a coordination boundary whether you design it or not — design it deliberately. And team topologies adds the human cap: every boundary is cognitive load on the teams that must hold both sides of it in their heads.
Coordination only exists if someone writes it down. Here it is as code both sides can run:
# the published contract — the coordination artifact, versioned
@dataclass(frozen=True)
class OrderConfirmedV1:
order_id: str
confirmed_at: datetime
def parse_order_confirmed(payload: dict) -> OrderConfirmedV1:
return OrderConfirmedV1( # a tolerant reader
order_id=payload["order_id"], # required: removing it breaks us
confirmed_at=datetime.fromisoformat(payload["confirmed_at"]),
) # unknown keys: ignored, on purpose
Three rules turn a schema into a contract: producers may add fields and never remove or repurpose them; consumers ignore fields they don't recognise; both sides support N−1 versions for a stated window. A consumer-driven contract test in each team's suite makes those rules fail the build rather than fail in production.
Written down and tested, that is coordination doing its job. Left in a chat thread — "we agreed the field is optional" — it is coordination debt: person-dependent, undiscoverable, and payable at 3 a.m.
Worked: pricing the boundary instead of arguing about it
Checkout (six engineers) calls warehouse (five engineers) for a stock check, synchronously, against a shared schema with no versioning. Every checkout release waits for a warehouse release: two release trains a month, and each one costs two people roughly three hours of sequencing, verification and Slack — 12 person-hours a month, 144 a year, about three and a half person-weeks burned on a runbook line that says "deploy warehouse first".
The first thing a lead looks at is not the diagram; it is git log on the contract. How often has this schema actually changed? Eleven times in twelve months. That number is the whole decision. At eleven changes a year the coupling multiplier is live and the bill is real, so the machinery pays: a written compatibility contract plus consumer-driven tests costs about three days to set up and two hours per subsequent change — 24 hours once, plus 24 hours a year — against 144 hours a year saved. It breaks even in roughly four months. Had the answer been "changed once, in 2023", the same machinery would have been an expensive way to make a stable thing look modern.
The alternative the lead rejects is the popular one: "split them into proper microservices so the teams are decoupled". Rejected because the coupling is not in the deployment unit — it is in the shared schema and the shared concept. Splitting the process without splitting the data ownership adds a network hop, a retry policy and a new failure mode to exactly the same negotiation. Deployment independence is not change independence, and only the second one was costing 144 hours.
The second rejected alternative is subtler: keep the synchronous call but "just version the endpoint". Versions with no stated support window are names, not commitments. Without "we support N−1 for 90 days" written down and tested, both sides still deploy together — you have added ceremony to the runbook and removed nothing from it.
A year on, the difference is visible in what a new engineer has to know. With the contract written and tested, warehouse can ship a field on a Tuesday and checkout finds out from the test suite, not the incident channel; onboarding reads one document. Without it, the alignment lives in the heads of two long-serving engineers, and the day one of them leaves is the day you discover which rules were never rules at all.
† The compatibility contract is the coordination artifact: a schema/API with documented rules — "consumers must tolerate unknown fields", "producers support N versions back". If no one owns that document, you have coordination debt: silent, person-dependent, and discovered in production.| Signal | The currency you are paying | The move |
|---|---|---|
| a runbook that says “deploy A before B” | Both currencies at once — coupling and coordination. | Version the contract, or merge the two into one deploy. |
| one table written by two services | Coupling through the schema, invisible in the code. | One owner; the other reads a projection or an event. |
| the API rules live in a Slack thread | Coordination debt: person-dependent and undiscoverable. | A compatibility document plus a contract test. |
| one team blocked on another’s release | Coupling drawn straight across a team boundary. | Decouple with events, or move the boundary. |
- "Decoupled means we no longer have to talk."
- Decoupling converts a synchronous conversation into an asynchronous, written one. The talking does not stop; it becomes a document with an owner. Teams that skip the document have not decoupled — they have gone quiet, which is a different and worse thing.
- "Microservices decouple teams."
- They grant deployment independence, which is not change independence. Two services sharing one table, or one concept with contested rules, are as coupled as two modules — with a network in the middle so the coupling now fails at runtime.
- "Conway's law means we have to reorganise the company."
- It means whichever boundary you do not choose deliberately, you get accidentally. Moving a boundary in the code to match how people already communicate is usually far cheaper than moving the people, and it is available to you this sprint.
- "We version our API, so compatibility is handled."
- A version number without a support window, a deprecation date and a test that fails when you break N−1 is a label, not a guarantee. The commitment is the part that costs something; the number is free.
When this is the wrong move
Do not buy coordination machinery for a boundary inside one team. When the same six people own both sides and ship on one train, the versioned event contract, the compatibility window and the consumer-driven test suite are ceremony around a conversation that already happens at standup — and worse, they make the cheap move (change both sides in one commit) feel illegal.
Nor should you freeze a contract early. In the first months of a product the model is wrong in ways you cannot yet name; a compatibility document written then locks in that wrongness and charges you a migration every time you learn something. Couple hard, refactor freely, and write the contract when the concept stops moving — the signal is usually a second consumer you do not control.
In the lab: the companion Phase 3 exercise gives you a returns service whose agreement with the order system exists only as field names copied between two modules. You write the compatibility rules down, add a contract test that fails when a required field disappears, and decide out loud which side pays when the two disagree.
Skill: which currency do you pay?
Two services share one domain concept, one team, one deploy train. Choose:
Couple them tightly
Decouple with events
Pay coordination costs
Share one database
Correct. Right: a stable shared contract with coordinated deploys is cheaper than an event handshake nobody needs.
Two teams evolve at different speeds and deploys are independent. Choose:
Couple them tightly
Decouple with events
Couple and coordinate
Ignore the teams
Correct. Right: an event contract with compatibility rules lets each side evolve at its own pace.
Conway's law means boundaries should follow:
The tech stack
Team boundary lines
The file history
The database schema
Correct. Right: systems mirror the teams' communication; drawing boundaries across team edges is the honest design.
Practice on your own code
Map your system's service boundaries onto your team structure. For each boundary: who changes on each side, who must talk when a contract changes, and is that conversation written down anywhere? The gaps you find are coordination debt — write the compatibility contract.
Reveal: a boundary audit
A checkout service and a warehouse service are split across two teams but share a synchronous stock-check call with no versioning. Audit: the deploy coupling was real (checkout deploys blocked on warehouse), and the compatibility rules existed only in a chat thread. Fix: an event contract (StockReserved, tolerance rules documented) + a compatibility document owned by both teams.
- In two sentences, explain to a junior why "we removed the dependency" and "we removed the coordination" are different claims.
- Find one boundary in your own system that crosses a team edge. Count its contract changes in the last year from version control, and multiply by what one coordinated release actually costs in people-hours.
- Predict the one-year state if the rules stay in chat: who is the single person who still knows them, and what happens the week they are on holiday?
Your win
You can choose the payment currency — coupling for stable co-evolving contracts, coordination for independent evolution — and you know Conway's law as a design input, not an excuse.
Read and watch deeper
- Team Topologies, Skelton & Pais — ch. 3–4: cognitive load as the cap on how many boundaries a team can hold, and the four interaction modes.
- "Consumer-Driven Contracts", Robinson on martinfowler.com — the test that turns an agreement into something a build can fail on.
- Conway's law — the original observation, read as a design principle.
- Designing Data-Intensive Applications, Kleppmann — ch. 11: the coordination guarantees (at-least-once, ordering) behind event contracts.
- Watch: ArjanCodes YouTube — search "conway's law" or "team boundaries".
- Next: lesson 0034 — Performance vs Maintainability.
- Reference: Trade-offs — coupling vs coordination; glossary — tolerant reader.
Map your boundaries and teams with your agent-teacher and find the undocumented contracts.
Source page: lessons/0033-coupling-vs-coordination.html
Lesson 0034 · Master Designer · Module 2
Performance vs Maintainability
The fast version is usually the ugly version. The Master's move: keep the system clean, measure, and hide the few hot paths behind deep modules — so the ugliness has a small address.
Mission tie-in: the last System Trade-Off — where premature optimization and cargo-cult caching both get their verdicts.
Knowledge: clean until measured
The two forces are machine cost — latency, throughput, the monthly compute bill — and human cost — the minutes each future reader spends understanding this before they may safely change it. The unit of decision is the hot path: a specific code path with a measured share of the time, not a module and never a codebase.
The mechanism is an asymmetry in how the two costs are distributed. Execution time is wildly uneven: a small fraction of the code accounts for nearly all of it, and Amdahl's law puts a ceiling on what optimising the rest can ever return — make a component that is 5% of runtime infinitely fast and you have bought 5%, forever, no matter how clever the code is. Comprehension cost is distributed evenly: every reader pays for every line they must read to make a change, hot or cold. Optimise cold code and you buy roughly nothing at full price. That ratio, not taste, is why "measure first" is true.
It stops being true where the distribution flattens. In a numeric kernel, a game loop, a codec or a hardware-constrained device, the hot path is the program: there is no cold majority to keep clean, and the data layout has to be chosen first because it cannot be hidden behind an interface later. It also stops being true when speed is a correctness requirement — a real-time deadline, a payment authorisation window — because then performance is an architecture driver to design for, not an optimisation to earn.
The sequence that keeps both forces happy:
- Build clean. Correct, clear, well-bounded code (everything Phase I taught). Performance is not yet a requirement — it's a guess.
- Measure. A profiler, a load test, a real metric — not "this feels slow". The requirement is a number: "the checkout must return in 300ms p95".
- Find the hot path. Usually 2–3% of the code does 95% of the work. Only those places trade maintainability for speed.
- Hide the trade behind a deep module. The fast implementation lives behind the same clean interface — caches, indexes, and cleverness contained where no one else must read them.
# the interface stays clean:
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
# the hot path gets its speed, hidden:
class CachedOrderRepository:
def __init__(self, inner: OrderRepository, cache: Cache) -> None:
self._inner = inner
self._cache = cache
def get(self, order_id: str) -> Order:
cached = self._cache.get(order_id)
if cached is not None:
return cached
order = self._inner.get(order_id)
self._cache.set(order_id, order)
return order
The domain never learns about the cache (lesson 0023's ports at work). The fast-but-complex code has one address: the adapter. Knuth's verdict stands: premature optimization is the root of much evil — because it spends maintainability before the measurement exists. But the measured optimization, contained behind a boundary, spends a little maintainability for a lot of performance, deliberately.
Worked: the same optimisation, two opposite verdicts
A nightly reconciliation job takes four hours on eight vCPUs. An engineer proposes rewriting its inner loop — three days of work, and the result is code that only they will enjoy reading — for a measured 20% saving.
The first thing a lead does is convert both sides into the same unit. Twenty per cent of four hours is 48 minutes a night: 8 vCPU × 0.8 h = 6.4 vCPU-hours, at roughly €0.04 per vCPU-hour, about $0.26 a night, $93 a year. Three days of loaded engineering time is roughly €1,920, plus a permanent readability tax on whoever debugs the job at 2 a.m. Payback: about twenty years. The proposal is not wrong about the speed-up; it is wrong about the scale, and no amount of profiling would have revealed that, because the profiler measures the job, not the business.
Now change one fact. The same 20% applied to a fleet of 400 always-on instances is 3,504,000 instance-hours a year; at €0.09 an hour that is €315,360, so 20% is about €63,000 a year. The same three days now pays for itself in under a fortnight, and a lead who refused it on principle ("premature optimisation") would be wrong by two orders of magnitude. Identical code change, identical measurement, opposite verdict — decided entirely by the multiplier the code sits behind.
The alternative a lead rejects in both cases is the generalising one: "the ORM is slow, let's drop it across the service". The profile said 78% of one endpoint's time went into serialising a 5,000-row response. That finding does not travel: the other 140 endpoints return a handful of rows each, so raw SQL buys them nothing measurable while charging every one of their future readers. Fix the one path, behind the one interface, and leave the other 140 alone.
The consequence over time is the thing seniors actually protect. Maintainability is what buys the option to optimise later: an implementation hidden behind a port can be replaced in an afternoon, whereas a data model that has leaked into forty call sites cannot be replaced at all without a migration project. "We will optimise it later" is only true if someone kept the boundary that makes later possible.
† The corollary for AI: AI will "optimize" on request — producing clever code with no measurement behind it. Require the number first; then let the cleverness be contained in an adapter.| The “optimization” | The number that would justify it | If there is no number |
|---|---|---|
| a hand-rolled cache | p95 latency, and the share of it spent in the cached call. | Delete it — the hidden state costs more than it saves. |
| a denormalized column | The query plan before and after, at production row counts. | Revert; a covering index is cheaper to maintain. |
| __slots__ on every class | Measured memory, at the object count you actually create. | Remove it; it constrains subclasses for nothing. |
| a batch size of 1000 | Throughput measured at 100, 1 000 and 10 000. | It is a guess wearing a constant’s clothes. |
- "Premature optimisation is evil, so performance is a later problem."
- Knuth was talking about micro-tuning individual statements. The decisions that actually determine performance — the data layout, the chattiness of a boundary, whether a loop crosses the network — are architectural and are the most expensive things to change afterwards. Guessing at line level is premature; thinking about shape at design time is not.
- "The profiler tells you what to fix."
- It tells you where time went under the workload you gave it. Profile with 50 rows and you will confidently optimise the wrong function for the production case with 50,000. The workload is the measurement; the tool is just arithmetic.
- "Clean code is slow code."
- Layers cost nanoseconds. Real slowness is almost always shape: an N+1 query, a serialisation of everything, a chatty call inside a loop, a missing index. Those are easier to see, not harder, when the code is clean — which is why the fastest teams profile clean systems.
- "We can always optimise it later."
- Only if a boundary survives to optimise behind. The maintainability you keep is not sentimentality; it is the option you will exercise on the day the number finally arrives.
When this is the wrong move
"Clean first, measure, then optimise the few hot paths" assumes a cold majority exists. Three situations where it does not: a compute kernel, codec or simulation loop where essentially all of the time is in one place and the memory layout must be chosen before the first line is written; an embedded or battery-constrained target where the budget is fixed by physics rather than by preference; and any deadline that is part of the contract — a fraud check that must answer inside the authorisation window is not slow-but-correct, it is wrong. In those cases performance is a quality-attribute scenario with a number attached from day one (lesson 0043), and the design is built around it rather than cleaned up afterwards.
In the lab: the companion Phase 3 exercise puts a hand-rolled cache and a per-row lookup into the same returns service. You profile it against a realistic case volume, find which of the two the numbers actually accuse, and either delete the cache or hide the fix behind the existing interface — with the measurement recorded next to the change.
Skill: where does the speed live?
What justifies spending maintainability on speed?
A measured share of runtime
A user complaining about slowness
A benchmark on a laptop
A framework's published performance claims
Correct. Right: a measured share of the runtime under a realistic workload is the only evidence that bounds what the change can win.
The optimized implementation belongs:
In the domain core
Spread across the modules
Behind a deep module
In every single caller
Correct. Right: behind a deep module — the fast code has one address and everyone else reads the clean side.
"Premature optimization is evil" because it:
Always makes code slow
Compilers undo it anyway
Spends before any measuring
Adds untested code paths
Correct. Exactly: maintainability is spent before any requirement proved the speed was needed.
Practice on your own code
Find the "optimization" in your codebase — the cache, the micro-optimized loop, the clever data structure. Ask: what number justified it? If there is no number, delete it or write the number that would justify it. If there is a number, confirm the fast code is hidden behind a clean interface.
Reveal: a measured verdict
A report module had a hand-rolled LRU cache added "because reports were slow". The number: an instrumented run showed the bottleneck was a N+1 query, not the formatting the cache wrapped. The cache was deleted; the query was fixed in the repository adapter. Result: faster, and one less hidden state (lesson 0019's greps came out clean).
- In two sentences, explain to a junior why making a component that is 5% of the runtime twice as fast is almost never worth three days.
- Find one optimisation in your own codebase and convert both sides into one unit: what does it save per year in money or milliseconds, and what did it cost in engineering time plus every future reader's minutes?
- Predict the one-year state if unmeasured cleverness keeps landing: which part of the system will nobody volunteer to change, and what will the team call that reluctance instead of "we cannot read it"?
Your win
You can demand a number before any optimization, contain the measured hot paths behind deep modules, and audit existing "optimizations" for the evidence they never had.
Read and watch deeper
- Designing Data-Intensive Applications, Kleppmann — ch. 1 (reliability, scalability) and ch. 12: what performance actually means and how to measure it.
- A Philosophy of Software Design, Ousterhout — ch. 20 "Designing for Performance": keep the clean design, then optimize the hot paths.
- Knuth, "Structured Programming with go to Statements", ACM Computing Surveys 6(4), 1974, pp. 261–301 — the premature-optimization quote in its original context, three sentences that are almost always cut short.
- Brendan Gregg, Systems Performance — 2nd ed., ch. 2: workload characterisation, and why the workload is the measurement.
- Watch: ArjanCodes YouTube — search "profiling" or "optimization".
- Next module: lesson 0035 — Evolution Trade-Offs.
- Reference: Trade-offs — performance vs maintainability; Design Principles — deep modules.
Bring a cache or clever loop to your agent-teacher and hunt for its justifying number.
Source page: lessons/0034-performance-vs-maintainability.html
Lesson 0035 · Master Designer · Module 3
Stability vs Speed of Change
Stable systems resist change; fast systems court it. The master's toolkit — feature flags, strangler fig, branch by abstraction — exists to buy both at once.
Mission tie-in: the first Evolution Trade-Off — where the techniques from lessons 0009 and 0025 become instruments of organizational speed.
Knowledge: speed is a design property
The naive framing: stable or fast, pick one. The reframe: change speed is a property of the design, not a personality. A system changes fast when changes are small, local, and reversible (Phase I's whole point). A system changes slowly when every change is big, cross-cutting, and risky.
The two forces are stability — the system keeps the promises it already made — and change speed — how fast a new fact about the world can reach production. The unit of decision is the change: one change, one instrument.
The reason the trade dissolves is that both forces are downstream of a single variable: batch size. Risk per release does not grow linearly with the number of changes in it, because changes interact and because diagnosis cost grows with the size of the set you must bisect. Halve the batch and you have both reduced the chance of a fault and shortened the time to find the one you get — safer and faster from the same move. Every instrument in this lesson is a way of making the deployed batch smaller than the unit of work: a flag separates deploy from release, a seam separates structure from behaviour, expand/contract separates a schema change from a code change.
Underneath the instruments sits one hard fact of modern deployment: during any rolling release, two versions of your code are live at the same time. That is not an edge case to handle, it is the normal state for several minutes of every deploy, and it is the reason each step must be compatible with the step before it. A change that is only correct once every instance has it is a change with a guaranteed broken window.
The mechanism has an edge. Machinery is not free: a flag is a permanent branch, and twelve live boolean flags describe 4,096 configurations of which your test suite exercises perhaps two. So the rule stops paying when the plumbing outlives the transition, and when the change is too small or too atomic to decompose — at which point the safest thing really is one careful deploy.
The three instruments that buy both stability and speed:
- Feature flags — ship the code dark; the behavior's visibility, not its presence, is the deploy. Roll back by flipping a flag, not reverting a release.
- Strangler fig (lesson reference: patterns sheet) — replace a system incrementally: new behavior routes to the new implementation; the old one shrinks slice by slice. The system stays stable while it's being replaced.
- Branch by abstraction — insert an interface over a component, migrate all users to it, swap the implementation behind it. Every step is a small, mergeable, reviewable change.
# feature flag: the code is in production; the behavior waits
def checkout(cart: Cart, flags: FeatureFlags) -> Order:
if flags.is_enabled("new_pricing"):
return new_pricing_checkout(cart) # dark: no users see it yet
return legacy_checkout(cart)
# branch by abstraction: every consumer migrates to the seam first
class StockService(Protocol): # the seam
def reserve(self, sku: str, qty: int) -> None: ...
# then: legacy impl swapped for new impl behind the same seam
The honesty clause: flags and seams are machinery — each carries complexity (lesson 0029's knob cost). A flag that lives forever is config debt; a seam for a component that never changes is indirection. The master removes the machinery when the transition is done.
There is a fourth instrument, for the change the other three cannot touch — data:
# expand / contract: never rename a column in one step
# 1. expand add `delivery_address`; write BOTH; keep reading `address`
# 2. backfill copy the historical rows, in batches, idempotently
# 3. switch read `delivery_address`; still writing both
# 4. contract stop writing `address`; only then drop it
Four deploys instead of one, and every single one is reversible. The one-step rename is the change that looks fastest and is the only one you cannot roll back once the first write lands — the exact profile lesson 0037 calls irreversible.
Worked: why the one-step rename is the expensive one
The returns service stores a shipping address in address; a new carrier integration needs it split out as delivery_address. The table holds 4.2 million rows. An engineer proposes the obvious thing: a migration that renames the column, deployed with the code that uses the new name.
The first question a lead asks is not "how long does the migration take?" but "what is running while it runs?" Deploys roll: six instances, roughly thirty seconds each, so for about three minutes half the fleet writes address and half writes delivery_address. Even at two requests a minute, that is a handful of returns whose address lands in a column the other half of the fleet is no longer reading — and you will not find them now. You will find them weeks later, by their absence, when a warehouse agent cannot print a label.
The second question is what an undo looks like. The four-step version is reversible at every step: stop the rollout, and the old column is still there, still written to. The one-step rename is reversible only until the first new write lands, after which restoring means a backup restore and reconciliation of everything that happened in between — the change has quietly moved from lesson 0037's reversible class into the irreversible one, in exchange for saving three deploys.
The alternative the lead rejects is the compromise that feels responsible: a 3 a.m. maintenance window. Price it. Backfilling 4.2 million rows in batches of 5,000 at about two seconds a batch is 840 batches, roughly 28 minutes — before verification, and before anything goes wrong. So the window is either too short to finish or long enough that its own failure is the incident. The maintenance window buys the illusion of atomicity and charges for it in the one currency that matters at 3 a.m.: nobody rested is making the rollback decision.
The four-step version is three more deploys and a backfill script, perhaps a day of work spread across a week, during which the service never stops being correct. A year later the difference is cultural rather than technical: teams that have done expand/contract twice stop treating schema changes as events, and schema changes stop being the reason a release is delayed to a Thursday.
† The un-safety of the alternative: a big-bang rewrite is the speed play that buys neither — the system is unstable for months and changes slower (every migration conflict lands in one pile). Stability and speed are both served by small reversible steps.| The change | The instrument | Remove it when… |
|---|---|---|
| a feature not ready for users | feature flag | The day it is on for 100% of traffic. |
| swapping a component with 40 callers | branch by abstraction | When the last caller uses the seam and the old implementation is deleted. |
| replacing a whole legacy system | strangler fig | When the last route stops going to the old system. |
| renaming or dropping a column | expand / contract | After the read path has stopped using the old column. |
- "A feature flag means we can ship without testing it."
- A flag adds a branch; it removes none. You now owe tests for both sides and, more importantly, for the transition — the request that starts on the old path and finishes on the new one. Untested flags convert a release risk into a runtime risk and call it progress.
- "We can always roll back, so it is reversible."
- Reverting code does not revert consequences. Emails have been sent, webhooks fired, rows written in the new shape, downstream systems have already believed you. Reversibility is a property of the whole change including its side effects, not of your deployment tool.
- "Long-lived branches are safer than unfinished code in main."
- They trade a visible risk for an invisible one. The unmerged branch accumulates integration debt that arrives all at once, at the worst possible moment, in the largest possible batch — the exact shape this lesson exists to avoid.
- "The strangler fig is a microservices pattern."
- It is an incremental-replacement pattern and works just as well inside one process: route calls through a seam, move one behaviour at a time, delete the old branch when the last caller stops using it. No network required.
When this is the wrong move
Slicing is not free, and there are changes where the machinery costs more than the risk it removes. A one-line copy fix behind a flag is theatre. A two-week migration that needs forty hours of flag plumbing has spent its own budget. And a rewrite is occasionally the honest answer — not when the code is ugly, but when the constraint is not code at all: a platform going end-of-life, a licence expiring, a compliance regime the old design cannot satisfy, or a domain that changed so much that preserving the old behaviour is the mistake rather than the goal.
The other counter-case is self-inflicted. A codebase with thirty long-lived flags is not agile; it is a configuration graveyard where no two environments run the same program and no one can say what production actually does. When flags outnumber transitions, the instrument has become the instability, and the fix is a deletion sprint rather than another flag.
In the lab: the companion Phase 3 exercise asks you to change the shape of a field the returns service already persists, without a maintenance window. You plan the expand, backfill, switch and contract steps, and demonstrate that stopping after any one of them leaves the service correct.
Skill: which instrument, when?
You want to ship code that isn't visible to users yet. Choose:
A feature flag
A strangler fig
Branch by abstraction
A big-bang release
Correct. Right: the code deploys; a flag controls its visibility — rollback is a flip.
A one-step column rename breaks during a rolling deploy because:
Both code versions run at once
The migration always locks the table
The new column is not indexed
Rollbacks cannot restore the dropped data
Correct. Right: for the minutes of a rollout both versions are live, so a column only one of them knows about loses writes.
A permanent flag is best treated as:
A permanent feature
Debt to remove
A state model
Free to keep
Correct. Right: once the choice is made, the flag is debt — remove it when the transition completes.
Practice on your own code
Find a change you're about to make that feels "big". Split it: what's the smallest invisible slice (flag), the smallest structural seam (branch by abstraction), or the smallest routed slice (strangler)? Write the slice list — then do the first slice.
Reveal: a sliced transition
Replacing a payments provider: (1) insert the PaymentGateway seam (already there from lesson 0009's ports — the seam was the whole point); (2) migrate callers to it behind a flag that still uses the old provider; (3) swap the adapter, flag on for 1% of traffic; (4) 100%, remove the flag. Four slices, each mergeable, each reversible.
- In two sentences, explain to a junior why smaller deploys are simultaneously safer and faster rather than a trade between the two.
- Find one change your team is currently calling "big". Name what makes it big — a schema, a shared component, an unslice-able behaviour — and what one incident during it would cost in hours and customer contacts.
- Predict the one-year state if the machinery is never removed: how many live flags will exist, and what will the team say when asked what production is actually running?
Your win
You can slice any big change into small, reversible, mergeable steps using flags, seams, and strangler routing — and you know to remove the machinery when the transition ends.
Read and watch deeper
- "Strangler Fig Application", Fowler — the incremental-replacement pattern.
- "Technical Debt", Fowler — the loan framing for what flags and shortcuts accumulate.
- Working Effectively with Legacy Code, Feathers — ch. 1–2: changing software without breaking it, slice by slice.
- Monolith to Microservices, Newman — ch. 3: strangler fig, branch by abstraction and parallel run, with their failure modes.
- Accelerate, Forsgren, Humble & Kim — ch. 2 and 4: the measured relationship between small batches, deploy frequency and change-failure rate.
- Next: lesson 0036 — Isolation vs Integration.
- Reference: Trade-offs — stability vs speed of change; Patterns — strangler fig, branch by abstraction, expand / contract, feature flag.
Bring your big change to your agent-teacher and slice it together before writing code.
Source page: lessons/0035-stability-vs-speed-of-change.html
Lesson 0036 · Master Designer · Module 3
Isolation vs Integration
Isolated parts evolve independently; integrated parts stay consistent. The bounded context is the master's unit of this trade: isolation where meanings differ, integration where they can't.
Mission tie-in: the second Evolution Trade-Off — lesson 0009's boundaries, now at the scale of whole contexts and their shared meanings.
Knowledge: the context is the unit
Isolation — a context owns its model, its schema, its vocabulary. It evolves at its own speed; other contexts can't break it, and it can't break them. Cost: duplicated concepts across contexts (lesson 0028's duplication — but here it's earned: the "customer" in sales and the "customer" in support are genuinely different knowledge).
Integration — contexts share a model, a schema, an API. Consistency is guaranteed; divergence is impossible by construction. Cost: change amplification — one context's change ripples through all the others (lesson 0006 at system scale).
The two forces are independent evolution — each side can change its model without asking permission — and guaranteed agreement — the two sides cannot drift, because there is only one thing to drift from. The unit of decision is the concept: one word, and the rules that hold for it.
The mechanism is that a model is a compression of the domain for a purpose, and different purposes compress differently. Merge two purposes into one model and every future change must satisfy the union of both constraint sets. Add a third purpose and the union grows again. This is why the one-true-model does not become unchangeable through size: it becomes unchangeable because the set of people who can veto a change keeps growing, and each of them is right about their own half. Isolation buys back changeability by keeping each constraint set small, and pays for it in translation and in facts that exist in more than one place.
Notice what that mechanism does not depend on: how many fields the two sides share. Two contexts can overlap in 90% of their columns and still be two concepts, because the deciding test is whose rules must hold, not whose data looks similar. And the mechanism inverts where the purposes are genuinely one: if both sides enforce the same rules and change at the same time, isolation duplicates knowledge rather than compressing it, and you have bought translation cost with no changeability in return.
The deciding test is meaning, not overlap. When two teams use the word "order" to mean the same thing with the same rules, integration is honest. When their rules differ — sales' "confirmed order" vs warehouse's "picking order" — they are two concepts wearing one name, and forcing a shared model means every change negotiates the difference forever.
# integration where meaning is shared: a contract both sides truly share
class OrderConfirmed(Event): # both contexts agree on this fact
...
# isolation where meaning diverges: each context owns its vocabulary
# sales.Order has "confirmed", warehouse.PickingOrder has "ready to pick"
# they meet only through the event — the anti-corruption boundary
The instruments: bounded contexts (isolation drawn at meaning), an anti-corruption layer (the adapter that translates between two vocabularies — lesson 0008's Adapter at context scale), and shared kernels (the small, stable set of concepts that genuinely must be shared, integrated on purpose).
Worked: one word, three sets of rules
A retailer has one returns table with 34 columns, written by three teams. Support treats a return as a case: notes, reasons and outcomes stay editable, because agents amend them while talking to the customer. Finance treats it as a refund transaction: once the refund posts, the record is immutable, because the tax position depends on it. The warehouse treats it as an expected parcel: it cares about a tracking number and an arrival, and nothing else on the row.
The first thing a lead looks at is not the column list — eleven of the 34 columns are genuinely used by all three, and that overlap is what keeps the shared table looking reasonable. The first thing to look at is the invariants: support requires editability, finance requires immutability. Those two rules cannot both hold for one record, so the shared table has already resolved the conflict, silently, with a locked flag and a convention. Every support feature now needs a finance reviewer, not because finance owns the feature, but because finance owns a rule that lives in the same row.
The cost is visible in lead time, which is the number worth collecting before the meeting. Migrations on the shared table need three sign-offs and take about nine days from proposal to production; migrations on a single-owner table take about one. With fourteen migrations across the three teams last year, that is roughly 112 days of waiting — not of work — spent on a coupling nobody chose.
The alternative the lead rejects first is the compromise: extract a shared returns-core library so everyone uses the same model "properly". That does not remove the negotiation; it relocates it into a release cycle. Three teams now coordinate on a version bump instead of a migration, with the added charm that upgrading is optional, so the drift becomes invisible instead of merely expensive.
The second rejected alternative is maximal isolation: three fully independent models with no shared anything. Something genuinely is shared — the return's identity and the fact that a return was requested. Without a common identifier and a common event, no one can join the three views, and the finance reconciliation that used to be a query becomes a project. So the shared kernel is exactly two things, deliberately: the return id, and ReturnRequested. Everything else — status vocabulary, editability rules, retention — diverges, and each side translates at its own edge.
A year later, the metric that shows it worked is not "number of services". It is the count of nullable columns that exist only for another team's purpose, and how often a code change waits on a stranger's calendar. Support ships copy changes daily; finance's model stops accreting fields it does not understand; and the words status2 never enter the schema.
† The failure pattern is the "one true model": a single schema everyone must fit — integration by decree. It works until the first genuine divergence, then every change becomes a committee meeting (lesson 0033's coordination debt).| You see this | What it costs | The move |
|---|---|---|
| one table read by two teams’ services | Integration by schema: every migration is a negotiation. | One owner; the other consumes events. |
| a shared “core” package that only grows | The one-true-model, accreting by default. | Split it; keep only what both sides genuinely share. |
| a field named status2 or type_for_billing | One model bent to hold two meanings. | Two models, one translation at the seam. |
| an import from another context’s internals | The anti-corruption layer was skipped. | Route through the published contract instead. |
- "Isolation means duplicating data, and duplication is bad."
- DRY is about knowledge that changes together, not about facts that appear twice. A projection each context owns, fed by an event and derived from one authoritative source, is not duplicated knowledge — it is one truth with several readings, which is what a read model is.
- "A bounded context needs its own service and database."
- A bounded context is a boundary of meaning. Two packages inside one deployable, with no imports between their internals, is a perfectly good bounded context. Whether to split the deployment is a separate question decided by different forces (lesson 0033) — and conflating the two is how teams end up with distributed systems they did not need.
- "If we design the canonical model carefully enough, translation goes away."
- Translation is not a symptom of insufficient care; it is the permanent cost of having more than one purpose. The canonical model is not wrong once and then fixed — it is wrong continuously, because each purpose keeps evolving in its own direction.
- "A shared kernel is a compromise we should feel bad about."
- It is a deliberate, small, jointly owned integration, and it is the right answer for the handful of concepts both sides genuinely share. The failure mode is not having one; it is letting it grow, because everything added to it acquires two owners and a veto.
When this is the wrong move
Drawing contexts too early is the common self-inflicted wound. In a young product the language is still moving; boundaries drawn now cut in the wrong place, and the anti-corruption layer becomes a museum of a model you abandoned six weeks later. One team, one product, unstable vocabulary: keep one model and refactor freely until a second consumer or a second set of rules shows up.
Analytics is the honest counter-case for integration. Cross-context questions — returns per region, refund value by cohort — need conformed dimensions and a single shape, and answering them from four isolated models is exactly the pain that data warehouses exist to remove. Isolate the transactional models; integrate deliberately downstream, in a place where a shared schema costs no one their autonomy.
In the lab: the companion Phase 3 exercise gives you a returns module where one class serves the customer flow, the finance record and the warehouse's expectations at once. You list the rules each purpose enforces, split what the rules force apart, and keep the smallest honest shared kernel between them.
Skill: same meaning or same name?
Two contexts share 90% of an entity's fields. That fact:
Says nothing about the deciding question
Proves the two models should merge
Proves the two contexts are duplicated
Means one team should own both
Correct. Right: the deciding test is whose rules must hold, and identical-looking columns routinely carry contradictory invariants.
Two contexts meet through events and a translating adapter. That adapter is:
An anti-corruption layer
A shared kernel
A merged model
A global registry
Correct. The anti-corruption layer is the translation boundary — lesson 0008's Adapter at context scale.
A concept both contexts share with identical rules deserves:
Isolation after all
A shared kernel
A translating adapter
A new context
Correct. A shared kernel is integration by design — small, stable, deliberate.
Practice on your own code
Find two modules (or teams' code) that share a name for a concept. Write the rules each side holds for it. If the rules match — deliberate shared kernel or honest merge. If they differ — draw the bounded context line and the translating adapter.
Reveal: a meaning audit
"Invoice" in billing and "invoice" in reporting: billing holds the legal lifecycle; reporting holds the display data. The shared table forced every billing change through reporting's migrations. Split: billing owns the invoice lifecycle; reporting consumes InvoiceIssued events and owns its own projection. One table became two contexts, and the migration coupling died with the shared schema.
- In two sentences, explain to a junior why two teams sharing most of a table's columns can still need two models.
- Find one concept in your own system that two purposes share. Write the rule each side enforces, and measure the cost: how many days does a change to that table or class wait for someone else's approval?
- Predict the one-year state if it stays merged: how many nullable columns will exist for another team's benefit, and which change will finally be declared "not worth doing"?
Your win
You can audit any shared concept for meaning, draw bounded contexts at the divergence, and integrate deliberately where meaning is truly one.
Read and watch deeper
- Domain-Driven Design, Evans — ch. 14 "Maintaining Model Integrity": bounded contexts, shared kernels, anti-corruption layers — the originals.
- "Bounded Context", Fowler — the concept and its signals, in five minutes.
- Implementing Domain-Driven Design, Vernon — ch. 3 (context maps) and ch. 13 (integrating bounded contexts): the relationship patterns, including shared kernel and published language.
- Team Topologies, Skelton & Pais — context boundaries as team boundaries (Conway's law, lesson 0033).
- Watch: ArjanCodes YouTube — search "bounded context".
- Next: lesson 0037 — Correctness vs Delivery Speed.
- Reference: Trade-offs — isolation vs integration; Patterns — anti-corruption layer; glossary — bounded context, shared kernel.
Bring your shared-schema pain points to your agent-teacher and draw the context lines together.
Source page: lessons/0036-isolation-vs-integration.html
Lesson 0037 · Master Designer · Module 3
Correctness vs Delivery Speed
Right or now — the oldest trade. The master's resolution: classify the change as reversible or irreversible. Reversible changes may go fast; irreversible ones can't afford to be wrong.
Mission tie-in: the third Evolution Trade-Off — where Phase I's fail-fast and Phase II's tests meet the real pressure of shipping.
Knowledge: reversible or irreversible?
Every change has a failure cost: what happens when it's wrong? Classify:
- Reversible — wrong means a rollback, a revert, a fix-forward. The blast radius is a deploy cycle. These can ship fast: tests cover the essentials, the team watches the metrics, and the correction loop is cheap.
- Irreversible — wrong means money lost, records corrupted, legal exposure, data destroyed, trust burned. These ship with the full apparatus: invariants (lesson 0011), fail-fast (lesson 0014), tests around the domain rules, and a slow, staged rollout (lesson 0035).
The two forces are confidence before release — verification you pay for up front — and learning from release — information only production can give you, because only production has real users, real data and real load. The unit of decision is the change, classified by reversibility × blast radius.
The mechanism is that both forces buy the same thing — information — and they differ only in when you buy it and what it costs. Where being wrong costs a revert, the cheapest information is usually bought afterwards: ship, watch, correct. Where being wrong moves money, destroys records or sends 40,000 emails, the "afterwards" option does not exist at any price, so the whole information budget must be spent before the release. That is the real content of the trade-off, and it explains why the answer changes per change rather than per team.
The boundary condition is the one most teams miss: reversibility is a function of detection time, not of your deployment tooling. A change you could revert in thirty seconds but will not notice for nine days is irreversible in every way that matters, because nine days of consequences are already in the world. Two corollaries follow. A reversible change with no telemetry is an irreversible change wearing a disguise — you will hear about it from customers, late. And a stream of individually reversible changes can aggregate into an irreversible state: three weeks of rows written in a subtly wrong shape is a migration project, not a revert.
The trick that collapses the trade: tests are the speed technology. A suite that locks the domain rules (characterization tests from lesson 0005, TDD for new rules) makes fast shipping safe — the team's speed comes from the safety net, not from skipping it.
# reversible: a display change — ship fast, watch the metric
def render_header(title: str) -> str: # tests: the essentials
...
# irreversible: a charge — ship with the apparatus
class ChargeService:
def charge(self, amount: Money, token: str) -> str:
charge_id = self._gateway.charge(amount, token) # idempotency key
self._ledger.record(charge_id, amount) # invariants, fail-fast
return charge_id
The discipline is naming the class out loud: before cutting the corner, say "this is reversible — a rollback fixes it" or "this is irreversible — it needs the apparatus." The silent version is where the trade goes wrong: irreversible changes shipped at reversible speed.
Worked: the same bug, priced by how long it hides
An automated returns-eligibility rule is being changed. If it is wrong in the permissive direction it will approve items that fall outside the policy window. Volume is 3,000 returns a day; suppose a bad rule wrongly approves 6% of them — 180 a day — at an average refund of €62.
The first thing a lead asks is not "how many tests do we have?" but "if this is wrong, how long until we know?" That single number sets the price of the mistake. Caught within an hour: 125 returns pass through, 7.5 of them wrongly approved, about €465. Caught after nine days: €11,160 a day, roughly €100,000, plus a reconciliation project and a conversation with finance about a guardrail the initiative promised to hold. Same bug, same code, same tests — the entire difference is detection time.
So the apparatus this change earns is not simply "more tests". It is a daily reconciliation that recomputes approvals from the policy independently and reports disagreements, which caps detection at 24 hours by construction. A lead will often take that over three extra unit tests, because the tests only encode what somebody already thought of, whereas the reconciliation catches the thing nobody thought of at all.
The alternative the lead rejects is the traditional one: a manual QA pass before every release. It lengthens the feedback loop for every change, including the 90% that are reversible, and it cannot see what production data actually looks like — the case that breaks the rule is a 2019 order with a null shipping date, and no one writes that test by hand.
The more interesting rejection is "ship it, it's behind a flag". A flag at 1% does not make the money reversible; it makes the loss bounded — about €111 a day instead of €11,160. That is not nothing: it is a known price for information, and choosing to pay it deliberately is a legitimate senior move. Pretending it makes the change reversible is not.
Over a year, the teams that classify out loud diverge from the ones that do not in a specific way. Both ship at similar speed most weeks; the difference is that one of them has never had an incident it could not undo, because the two or three genuinely irreversible changes each quarter were recognised in advance and got the apparatus. Wisdom here is not caution — it is knowing which three.
† AI accelerates both sides: it ships fast code faster, and fast wrong code faster too. The classification is exactly the judgment AI can't provide — state the class in the prompt (lesson 0010) and let the generated tests match it.| The change | Class | What the class earns it |
|---|---|---|
| a colour, a label, a copy tweak | reversible | Ship it; watch the metric for an hour. |
| an outbound email to every user | irreversible — you cannot unsend | A dry run, one small cohort, a kill switch. |
| a migration that drops a column | irreversible | A backup, expand/contract (lesson 0035), a rehearsed rollback. |
| a pricing rule change | irreversible — the money already moved | Tests on the rule, staged rollout, a reconciliation report. |
| an endpoint nobody calls yet | reversible | Ship it; the users are the flag, not the code. |
- "Reversible means we can revert the deploy."
- Reverting the code does not unsend the emails, unmove the money, or rewrite the rows other systems have already read. Reversibility is a property of the change plus its side effects plus how fast you would notice — the deploy button is only one of the three.
- "More tests mean more correctness."
- Tests encode the failures somebody imagined. For irreversible changes the marginal value usually sits elsewhere: in a reconciliation that computes the same answer a second way and shouts when the two disagree, which catches the failure nobody imagined.
- "Quality and speed are a permanent trade-off."
- Not at team level. The practices that shorten the feedback loop — small batches, automated checks, observable releases — improve both, and the measured research says so. The trade is real per change, not per culture, and teams that treat it as cultural end up slow and wrong.
- "Classifying the change is a formality."
- Unsaid, everything is implicitly reversible, because that is the assumption that lets work continue. Saying "this one is irreversible" out loud is the entire mechanism; it is also the sentence that makes a deadline conversation possible before the incident rather than after it.
When this is the wrong move
The classification is overhead for a team whose real problem is elsewhere. If you release once a quarter, every release is a giant irreversible batch and the useful work is shrinking it (lesson 0035), not labelling it. If you are pre-launch with no users, nothing is irreversible because nobody depends on anything yet — the apparatus is pure cost, and speed of learning is the only force in the room. And in a regulated or safety-critical domain the class is fixed in advance by policy: everything is irreversible, so the discipline that pays is the evidence trail, not the sorting.
There is also a failure mode on the cautious side. A team that classifies everything as irreversible has not become careful; it has stopped deciding, and it will spend its apparatus budget on colour changes and have none left for the migration that actually deserved it.
In the lab: the companion Phase 3 exercise asks you to classify each pending change in a returns service — a copy tweak, an eligibility rule, a refund calculation, a schema drop — and to add, for one irreversible change, the detection mechanism that bounds how long it can be wrong.
Skill: which class is this change?
What makes a technically revertible change effectively irreversible?
Nobody notices it for days
The deploy pipeline is slow
The change touches shared code
Two engineers reviewed the diff
Correct. Right: nine days of consequences are already in the world, and the revert button does not reach any of them.
A charging flow whose error corrupts the ledger is:
Irreversible, whatever the deadline
Reversible, since deploys revert
Reversible with enough tests
Irreversible only at scale
Correct. Right: corrupted money records are not undone by a revert, so a deadline changes the plan and never the class.
Tests make fast shipping safe because they:
Slow the team
Replace the review
Contain the risk
Decide the class
Correct. Exactly: a suite locking the rules turns 'fast but risky' into 'fast and contained'.
Practice on your own code
Take your last three shipped changes. Classify each: reversible or irreversible? For any that shipped fast without the apparatus while being irreversible — name the loan you took (and the next lesson's ledger will make it honest).
Reveal: a classification audit
Three changes: a navbar tweak (reversible — shipped fast, correct call); a new discount rule (irreversible — shipped with tests, correct call); a migration that dropped a column (irreversible — shipped without a backup rollback path; the loan: recovery took a day of manual repair). The audit names the loan; lesson 0038 tracks it.
- In two sentences, explain to a junior why "we can roll it back" is not the same claim as "it is reversible".
- Find one change shipping this week in your own work. Write its detection time — how many hours until you would know it was wrong — and multiply the hourly damage by it.
- Predict the one-year state if nothing is ever classified: which single category of change will cause the incident that gets a postmortem, and who will first notice it?
Your win
You can classify any change by its failure cost, apply the apparatus only where the class demands it, and use tests as the speed technology instead of the speed tax.
Read and watch deeper
- Test-Driven Development by Example, Beck — the discipline that makes speed safe: tests as the design and delivery instrument.
- Accelerate, Forsgren, Humble & Kim — ch. 2: the evidence that throughput and stability rise together, and the four measures that expose it, including time to restore.
- Working Effectively with Legacy Code, Feathers — ch. 1–2: characterization tests as the bridge into changes you can't fully verify.
- A Philosophy of Software Design, Ousterhout — ch. 10 "Define Errors Out of Existence": design the irreversible class so it can't go wrong.
- Watch: ArjanCodes YouTube — search "testing" or "TDD".
- Next: lesson 0038 — the final trade-off.
- Reference: Trade-offs — correctness vs delivery speed; Patterns — characterization test.
Classify your pending changes with your agent-teacher before you start — the out-loud version is the discipline.
Source page: lessons/0037-correctness-vs-delivery-speed.html
Lesson 0038 · Master Designer · Module 3
Short-Term Productivity vs Long-Term Maintainability
You may borrow from the future — every team does. The master's rule: record the loan. Debt that is invisible is never repaid; debt that is named gets a repayment plan.
Mission tie-in: the final trade-off closes the Master Designer phase — maintainability is not the enemy of productivity; it is productivity, compounded. The Initiative Lead phase applies that judgment to a whole project.
Knowledge: the loan, the ledger, and the compounding
Ship the quick version now and the slow version later? That is a loan — you are borrowing future maintenance time to buy present shipping time. Loans are legitimate. What is never legitimate is a loan with no ledger entry: the debt exists, invisibly, and compounds through every future change (lesson 0035's flags, lesson 0037's skipped apparatus).
The two forces are present throughput — value delivered this week, when it is worth most — and future change cost — the price of every edit this code will receive for the rest of its life. The unit of decision is the loan: one shortcut, one entry.
The interest is not a metaphor. The cost of a change is roughly the amount of code and coordination a person must hold in their head to make it safely; a shortcut enlarges that amount for every subsequent change that passes through the same place. And it compounds rather than accumulating, because two shortcuts in one area interact: you must now understand both, and the interaction between them, before you may touch either.
Which is exactly why the ledger, not the discipline, is the lesson. Unrecorded debt is not priced into estimates, so it does not appear as debt at all — it appears as the team getting slower for no visible reason. The signal degrades uniformly ("everything takes longer now"), the cause becomes unattributable, and the conversation turns into a personality argument about rigour instead of a cost conversation about a specific decision that a specific person made for a specific reason.
The mechanism has a sharp boundary, and it is the most useful sentence in the lesson: interest is only charged on code you touch. A gnarly module nobody has opened in two years has a large principal and an interest rate of zero. It is not debt; it is just old code, and repaying it converts real hours into no saved hours. Equally, code that will be deleted — a three-week campaign page, a migration script, a spike — has no future to borrow from, so investing in its structure is not prudence, it is waste.
The ledger entry has three lines:
- What was borrowed. The corner cut, named precisely: "the pricing logic lives in the endpoint, not the domain."
- What it costs. The interest: "every pricing change now edits a route handler and needs a manual regression check."
- The repayment. The trigger: "repay when the third pricing variant arrives."
# borrowed: a quick, coupled implementation
def endpoint(request: Request) -> Response:
if request.json["tier"] == "gold": # pricing logic in the route
price = request.json["amount"] * 0.9
...
# ledger entry:
# borrowed: pricing rules in the endpoint (not the domain)
# interest: every pricing change edits routes + manual QA
# repay when: third pricing variant arrives → extract PricingStrategy
# (lesson 0004's shape, already visible)
And the compounding works the other way too: the clean code from Phase I isn't a luxury — it is future productivity purchased at small present cost. Ousterhout's point, now fully earned: a working system that's hard to change is not an asset, it's a liability that grows. The final summary of this course: maintainability is productivity over time, and every trade-off in this phase was the same trade in different clothing.
Worked: pricing one loan, and choosing which to repay
The pricing rule lives in the route handler because the first version needed to ship for a launch date. A year on, pricing changes arrive about three times a quarter. Each one now edits a route, cannot be unit-tested without spinning up the web layer, and needs a manual regression pass: roughly three hours. Extracted behind a strategy, the same change is about half an hour.
That is the interest, and it is arithmetic, not opinion: 2.5 hours × 12 changes a year = 30 hours a year, around €2,400 of loaded engineering time, charged annually and rising as variants multiply. The extraction costs about six hours once. Payback lands in roughly ten weeks, so this loan should be repaid at the next pricing change rather than scheduled, discussed, or added to a backlog where it will lose to a feature every time.
The part worth noticing is that taking the loan was correct. At the time there was one pricing rule, the launch date was real, and the extraction would have been speculative structure for a case that had not appeared (lesson 0030). What separates engineering from drift is not refusing to borrow; it is that the entry named a trigger — "the third variant" — and the third variant is the thing that arrived.
Now the harder decision. A team of six keeps a ledger with fourteen entries, and can repay perhaps two this quarter. The instinct is to rank by ugliness, which is why most debt lists are sorted by how much they annoy the person who wrote them. A lead ranks by rate instead, and gets it from version control: how many commits touched each file in the last ninety days. Two entries turn out to sit in files touched forty and thirty-one times; four sit in files touched once. The four are not repaid, and saying so out loud is the point — they are not debt this quarter, and pretending otherwise would spend a quarter of the team's repayment budget on hours that were never going to be charged.
The alternative a lead rejects is the tidy policy: "we dedicate 20% of every sprint to technical debt". It sounds disciplined and it is untargeted — it funds repayment of principal in code with no interest, and it lets genuinely expensive debt survive because it was not this sprint's turn. Repayment is triggered by the change that makes the debt expensive, and the trigger belongs next to the code, not in a document nobody opens at the moment of the edit.
† AI is the productivity multiplier that makes this trade acute: it generates the loan and the repayment, at speed. The AI workflow from lesson 0010 — structure first, review after — is the compounding protection.| Borrowed | The interest it charges | Repay when |
|---|---|---|
| pricing logic in the route handler | Every pricing change edits a route and needs manual QA. | The third pricing variant arrives. |
| no test on the new adapter | Every refactor within reach of it needs a manual check. | The first bug it lets through. |
| a copy-pasted service, “we’ll merge later” | Two places to fix every shared bug, forever. | The second shared bug. |
# TODO: remove after launch | One extra branch in every future read of the function. | Launch plus two weeks — put it in the calendar. |
- "Technical debt means bad code."
- Debt is a deliberate trade with a repayment plan. Code that is bad because nobody thought about it is not debt — it is mess, and calling it debt lends it a respectability that stops anyone asking who decided, and why.
- "Mature teams pay down debt continuously."
- Mature teams pay down the debt they are actually being charged for. A fixed 20% tithe spends real hours on principal in files nobody opens, while the expensive entry waits its turn. Repayment is triggered by the next change through that code, not by the calendar.
- "The ledger is a backlog of tickets."
- Debt tickets lose to features forever, because they are compared on the wrong axis. A ledger works when it is read at the moment of the change — a comment at the site, a named test, a line in the module's README — so the person about to pay the interest is the person who sees the entry.
- "Clean code costs more up front."
- Usually it costs about the same up front and much more to retrofit. What is genuinely expensive is not the first clean version; it is extracting a boundary after forty call sites have grown through the place where it should have been.
When this is the wrong move
Do not open a ledger entry for code with no future. Spikes, migration scripts, a promotional page with a fixed end date and anything you will delete before the next quarter charges no interest, and structuring it carefully is a cost with no matching return. Nor does the discipline pay before product-market fit: when the odds are good that the whole feature is deleted, the option value of shipping now exceeds the interest you would avoid.
And there is a repayment that is itself the wrong move: the one whose risk exceeds the interest. A rewrite of a critical path to remove an inconvenience you pay three hours a year for is not repayment; it is a new, larger loan taken to settle a small one — lesson 0037's irreversible class, entered voluntarily.
In the lab: the companion Phase 3 exercise closes the phase by having you write the debt ledger for the returns service you have been repairing — three entries with what was borrowed, what it charges, and the trigger — then rank them by how often version control says those files are touched.
Skill: name the loan
A quick implementation that cuts a corner is best described as:
A recorded loan
A free shortcut
A permanent win
A one-time cost
Correct. Right: borrowing future time to buy present time — legitimate when recorded, toxic when not.
Two loans sit in the ledger. Which is repaid first?
The one in code changed weekly
The one with the largest principal
The one the newest engineer wrote
The one in the oldest module
Correct. Right: interest is charged per touch, so the rate is how often the code changes — repay where the charges actually land.
Clean, well-bounded code is best understood as:
A present cost
Future productivity gains
A senior luxury
Present speed cost
Correct. Right: maintainability is productivity over time — the course's central claim.
Practice: close the trade-off phase
Write the ledger for your own codebase's three largest invisible loans — each with borrowed / interest / repay-when lines. Then carry the largest one into lesson 0039: it is part of the constraint and risk context for the next initiative you lead.
Reveal: the first three phases' closing argument
Three phases, one system of thinking: Core Designer taught you to see and repair structure; System Designer taught you to grow it safely; Master Designer taught you to choose under pressure. Every decision shared one aim: keep the future cheap. Phase IV now asks you to make that judgment visible and actionable for a whole cross-functional team.
- In two sentences, explain to a junior why ugly code that nobody opens is not technical debt.
- Find one loan in your own codebase, price its interest in hours per year from the real change frequency, and compare it with the hours repayment would take.
- Predict the one-year state if it stays unrecorded: what will the team say about their estimates, and what will they blame instead of the loan?
Your win
You can take productivity loans deliberately, record them in a three-line ledger, and repay them on schedule — the discipline that lets you ship fast and stay master of the system. Next, you lead the initiative that changes it.
Read and watch deeper
- "Technical Debt", Fowler — the loan metaphor, properly: principal, interest, and the decision to borrow.
- "Technical Debt Quadrant", Fowler — deliberate versus inadvertent, prudent versus reckless: the distinction between a loan and a mess.
- A Philosophy of Software Design, Ousterhout — ch. 2–3: the final statement that complexity is the enemy and working code isn't enough.
- "Simple Made Easy", Hickey — the closing keynote for everything this course taught.
- Watch: ArjanCodes YouTube — Arjan's own summary videos on software design; and the official sdm examples as living reference.
- Take your learning to the communities in Resources — trade-offs become judgment only in the wild.
- Reference: Trade-offs — short- vs long-term, and the three moves that run every trade-off in this phase.
Your agent-teacher remains available for any lesson — re-explanations, code reviews, or designing your next feature together. Ask anything.
Source page: lessons/0038-short-term-productivity-vs-long-term-maintainability.html
Lesson 0039 · Initiative Lead · Module 1
Turn a Product Brief into an Initiative Frame
A brief is an invitation to investigate, not a specification to estimate. Your first leadership move is to turn requested output into a decision-ready frame.
Mission tie-in: architecture starts before boxes and arrows. The outcome, constraints, uncertainty, and cost of failure decide which architecture is responsible.
Knowledge: make the problem testable
Imagine product brings this brief: “Build a self-serve returns portal by Q3. It needs automated approvals, carrier labels, and status notifications.” It sounds precise because it names features. It is not. You do not yet know whose problem matters, what improvement counts, which policies must hold, or whether a portal is the cheapest intervention.
A lead does not reject the brief or silently accept it. Product, design, and engineering rewrite it together into six decisions:
- Outcome and baseline. Whose behavior changes, from what to what, by when? “Reduce median resolution from 72 hours to under 4 hours for eligible domestic returns by the end of the pilot.”
- Guardrails. What must not get worse? Fraud loss, support contacts, accessibility, manual workload, and carrier cost are measures too.
- Scope boundary. Name the first users and situation, plus explicit non-goals. International returns and exchanges are out of the pilot.
- Constraints. Facts the design cannot negotiate: the existing order system remains authoritative; labels come from a contracted carrier; PII is retained for 30 days.
- Assumptions. Beliefs that might be false: customers want self-service; policy rules are codifiable; the carrier can meet the latency target.
- Decision and ownership. Who decides scope, experience, architecture, and rollout—and what evidence will reopen each decision?
The leadership force here is preserved freedom to change the solution. A commitment is a constraint on future decisions, so what you commit to determines what evidence is allowed to do to you. Commit to an output — "a portal by Q3" — and you have chosen a solution before you have the evidence that would let you choose one; every later discovery is then a threat to the plan, and a team defends a plan instead of learning. Commit to an outcome — "under four hours for eligible domestic returns" — and the same discovery becomes an input, because the target survives while the solution is free to move.
There is a second mechanism, less often named. An outcome is falsifiable, and a falsifiable statement is the only kind that lets product, design and engineering resolve a disagreement by measurement rather than by seniority. That is what the frame is for: not to inform stakeholders, but to make the team's disagreements decidable.
So the principle has a boundary, and it is sharp. When the output is the obligation — an accessibility regulation with a date, a contractual deliverable, a platform going end-of-life — the deliverable is genuinely the point, and asking "but what outcome does this drive?" spends three weeks proving something everyone already knows. And an outcome nobody can measure is worse than an output, because it cannot be checked at all; when the instrumentation does not exist, the first slice of the initiative is getting the baseline, and the frame should say so out loud.
The distinction that protects the whole initiative is outcome versus output. “Ship a portal” is output. “Resolve eligible returns in four hours” is an outcome. Output can be on time while the customer problem remains untouched. The outcome lets the team change its solution when evidence changes.
- Problem
- Eligible returns wait a median 72 hours for an agent decision; 41% produce a support contact.
- Outcome
- Under 4 hours median and 25% fewer contacts for the domestic pilot by 30 September.
- Guardrails
- Fraud loss ≤ current baseline; manual review queue ≤ 50/day; WCAG 2.2 AA critical flows.
- First slice
- Signed-in domestic customer, unopened item, within 30 days, refund to original payment method.
- Non-goals
- Exchanges, international shipping, guest checkout, redesigning warehouse intake.
- Constraints
- Order system owns order truth; carrier contract is fixed; no payment data enters the new module.
- Unknowns
- Policy exception rate, carrier label latency, customer comprehension, agent override needs.
Worked: the arithmetic that changes the frame
Take the brief as given — portal, automated approvals, carrier labels, notifications — and watch where a lead's attention goes first. Not to the feature list. To the baseline and the population: 72 hours median, 41% of returns producing a support contact, and roughly 3,000 returns a day across the whole book.
Then the question that reshapes the frame: what share of the problem does the proposed output actually touch? The pilot cohort is signed-in domestic customers with unopened items inside 30 days — about 38% of volume, some 1,140 returns a day. Suppose the initiative works perfectly and those 1,140 resolve in four hours. The median across all returns barely moves, because the other 62% still take 72 hours and the median sits among them. An initiative whose stated success metric it cannot move by arithmetic is not an ambitious initiative; it is a mis-specified one, and it will be judged in October by a number that was never in its reach. That is why the outcome in the frame reads "for eligible domestic returns" — the population is not a caveat, it is the difference between a measurable claim and an unwinnable one.
The same arithmetic converts a guardrail into a design driver. The frame caps the manual review queue at 50 cases a day. If automation handles 90% of the cohort — the figure the team believes but has not evidenced — the remaining 10% of 1,140 is 114 cases a day, more than double the cap. So the guardrail silently requires the policy engine to decide at least 95.6% of pilot cases automatically, or requires the cohort to be narrower. Nobody wrote that requirement down; it fell out of two numbers already in the frame. Finding it in week one costs a conversation. Finding it in week nine costs the pilot.
The alternative a lead rejects is the appealing one: widen the pilot to all returns so the headline metric moves. It multiplies the policy surface — international shipping rules, exchanges, guest checkout — while the review-queue guardrail is already stretched, and it converts a testable bet into a programme. The narrower cohort is chosen precisely because being wrong about it is cheap.
What the frame buys is visible on the other side of the pilot. If contact rate falls 25% and that holds across the book, 3,000 × 41% = 1,230 contacts a day become about 307 fewer; at six minutes each that is roughly 31 agent-hours a day, near four full-time roles. Stated as an outcome, that is a result the team can claim, argue about and extend. Stated as "we shipped the portal", it is an activity report, and the first person to ask "did it work?" will have no way to answer.
A useful frame is short enough to argue with. Attach evidence and longer research; do not bury the decision beneath it. Version the frame as facts change. A changed frame is learning, not failure.
| You hear this | What is missing | Ask instead |
|---|---|---|
| “We need a dashboard.” | The decision it should improve. | Who will do what differently after seeing it? |
| “This must scale.” | A load and a response target. | Which peak, which operation, and how fast? |
| “Launch by Q3.” | The smallest evidence worth launching. | Which cohort proves or disproves the bet? |
| “Everything is priority one.” | A stated cost of delay. | Which failure hurts the outcome first? |
- "An outcome is a KPI with a target on it."
- An outcome names four things: whose behaviour changes, from what baseline, by how much, and by when. Drop the population and you get a number arithmetic will not let you move; drop the baseline and nobody can say afterwards whether it worked.
- "Constraints and assumptions are two words for the same list."
- A constraint is a fact you design around; an assumption is a belief you test. Mislabelling an assumption as a constraint — "the order system cannot expose that" — is how teams spend a quarter designing around something that was never true, and nobody re-checks it because constraints are not supposed to be questioned.
- "The frame is a document for stakeholders."
- It is a working instrument for the team. If it is not reread at the weekly review, and if no decision this month cited it, it has become a launch artifact — and the initiative is now steered by whatever the loudest recent message was.
- "Framing outcomes means engineering gets no requirements."
- The opposite: an outcome plus guardrails plus constraints gives engineers the architecture drivers a feature list never contains. "Under four hours, fraud loss flat, queue under 50 a day" tells you what to design for. "Build a portal" tells you what to type.
When this is the wrong move
Three situations where reframing is the wrong instinct. When a regulator, a contract or an end-of-life date makes a specific deliverable the obligation, the output is the point and the useful leadership work is sequencing and risk, not outcome discovery. When the initiative is a like-for-like migration whose entire success criterion is "nothing changes for users", an outcome statement adds a layer of translation over a goal everyone already shares. And when nothing is instrumented, an outcome is unfalsifiable — the honest frame then commits to producing the baseline first and names the date on which the real target will be set.
There is also a timing failure. Reframing a brief that arrives the same week as a hard deadline can be correct engineering and bad leadership: if the conversation cannot change the decision, run it as a risk conversation instead, and save the framing energy for the initiative that is still shapeable.
In the lab: the companion Phase 4 exercise starts from the flawed returns service you repaired in Phase 3 and asks you to write the frame the code should have been built from — outcome, guardrails, cohort, non-goals — then to name which module in the existing code contradicts each guardrail. For a fully worked version of this artifact, see the capstone's initiative frame.
Skill: separate the bet from the build
Which statement is an outcome?
Launch the returns portal
Cut eligible resolution time
Create an approval service
Complete the carrier integration
Correct. It names the affected workflow, baseline, target, population, and time boundary.
A fixed carrier contract belongs under:
Assumptions
Outcomes
Constraints
Guardrails
Correct. A constraint is a fact the initiative must design around rather than optimize away.
Why write explicit non-goals?
To improve estimation accuracy
To document rejected requests
To prevent silent expansion
To freeze product strategy
Correct. Non-goals stop adjacent needs from silently becoming commitments during design and delivery.
Practice: rewrite a real brief
Take one initiative in your organization. In one page, write its baseline, target outcome, guardrails, first cohort, non-goals, constraints, top five assumptions, and decision owners. Circle every number without a source and every “must” without an owner. Those circles are your first discovery backlog.
Reveal: the frame quality test
Give the page to someone outside the initiative. They should be able to answer: why now, for whom, what measurable change, what is deliberately excluded, what cannot move, what might be false, and who can decide. If they can only describe the proposed features, the brief has not yet become a frame.
- In two sentences, explain to a junior engineer why committing to an outcome gives the team more freedom than committing to a feature.
- Take the initiative you are on this week. Find one number in it with no source and one "must" with no owner, and name what each will cost when it turns out to be wrong.
- Predict where the initiative lands in a year if it stays framed as output: what will be shipped, what will be claimed, and which question will nobody be able to answer?
Your win
You can convert a feature-shaped request into a measurable initiative frame that gives product freedom to learn and engineering enough context to design responsibly.
Read and watch deeper
- Continuous Discovery Habits, Teresa Torres — ch. 3–6: outcomes, opportunity mapping, and turning assumptions into tests.
- Inspired, Marty Cagan — product discovery chapters: separating the problem to solve from the feature to ship.
- Software Architecture in Practice, Bass, Clements & Kazman — 4th ed., ch. 3 and 19: business goals, stakeholders, and turning them into architecture drivers.
- Escaping the Build Trap, Melissa Perri — ch. 1–4: the organisational reasons output commitments survive even when everyone agrees outcomes are better.
- Reference: Initiative Playbook · frame template and the trade-off method.
Bring a brief to your agent-teacher and ask it to label every sentence outcome, output, constraint, assumption, guardrail, or unresolved.
Source page: lessons/0039-framing-an-initiative.html
Lesson 0040 · Initiative Lead · Module 1
Facilitate Discovery and Brainstorming
“Get everyone in a room” is not a workshop design. A strong facilitator makes evidence, independent thinking, disagreement, and a decision happen in that order.
Mission tie-in: design quality depends on which options and risks become visible. Facilitation is how a technical lead increases the search space without surrendering the decision.
Knowledge: design the conversation
Unstructured brainstorming rewards the fastest speaker, the highest-ranking person, and the first plausible idea. The group then spends an hour decorating an anchor. For the returns initiative, that anchor is already “portal.” Your job is to make other interventions—policy clarification, proactive status, agent tooling, automated eligibility—available for comparison.
The leadership force is widening the search space without giving away the decision. Those two halves fail in opposite directions — a lead who decides alone searches a space of one, and a lead who lets the room decide gets the option with the least resistance rather than the most merit — and every move in this lesson exists to hold both at once.
The mechanism behind silent divergence is mundane and reliable. In a talking group only one person speaks at a time, so contributions queue; and each contribution after the first is conditioned on what was already said. The result is that a room of eight does not produce eight independent samples of the solution space, it produces something closer to one sample with seven elaborations. Silence restores independence, which is the only thing that makes the group larger than its loudest member.
Convergence has its own mechanism: criteria chosen before the options are visible. Criteria chosen afterwards are selected, unconsciously and honestly, to favour the option someone already likes. And the decision needs a named owner, because consensus-seeking has two exits and both are bad — the lowest-common-denominator option, or a silent dissenter whose objection reappears as an implementation surprise in week six.
The boundaries: this machinery assumes shared context. A group that does not know the domain will silently generate confident nonsense, so a pre-read or a facts round has to come first. And it assumes the decision is genuinely open. Running divergence on a question that is already settled is not facilitation; it is theatre, and it spends the trust you will need on the next real one.
Use a deliberate diamond:
- Frame. Put one decision at the top: “What is the smallest intervention that can cut eligible resolution time during the pilot?” Share the frame and evidence before the session.
- Diverge silently. Each person writes observations, questions, and options alone. Silent first work prevents anchoring and gives design, operations, and quieter engineers equal surface area.
- Make meaning together. Share round-robin, cluster without judging, then name themes. Separate evidence (“38% are unopened domestic items”) from belief (“customers will trust automatic approval”).
- Converge with criteria. Score options against outcome impact, confidence, effort, reversibility, and guardrails. A dot vote may reveal sentiment; it does not make the decision.
- Close. Record the decision, dissent, unknowns, owners, and next evidence. A workshop without a closed loop is a meeting-shaped delay.
| Minutes | Activity | Output |
|---|---|---|
| 0–10 | Read outcome, evidence, constraints; clarify only | Shared decision frame |
| 10–20 | Silent: facts, assumptions, questions | Independent raw material |
| 20–35 | Round-robin share and cluster | Named opportunity themes |
| 35–50 | Silent solution sketches, then combine | At least three real options |
| 50–65 | Score against agreed criteria | Trade-offs and dissent |
| 65–75 | Choose tests, owners, deadline | Discovery actions and decision date |
Worked: choosing the test that could change the decision
The room's strongest belief is that "90% of returns match codifiable policy". Everyone nods; support's most experienced agent said it, and it sounds right. It is the assumption the whole automated-eligibility option rests on, and it has no evidence behind it at all.
The first thing an experienced facilitator does with a belief like that is not to argue with it. It is to ask what decision would change if it were false — and then to size the cheapest test that could distinguish the two worlds. Sampling 200 recent agent decisions takes an analyst about a day. If the true rate really is near 90%, a sample of 200 pins it to roughly ±4 percentage points at 95% confidence, so the answer comes back as a range like "86–94%".
Now put that beside the frame from lesson 0039. The manual review queue is capped at 50 cases a day and the pilot cohort is about 1,140 returns a day, so automation must decide at least 95.6% of them without a human. A measured 86–94% does not merely fail to confirm the plan — it refutes it, decisively, for one analyst-day. That is what a well-chosen test looks like: not "learn more about returns", but "a result that forces a different design".
And the design does change, in a way the workshop can now discuss with something real on the table: narrow the cohort until the exception volume fits the cap, or add an agent-assist path that clears exceptions faster than a full manual review, or renegotiate the guardrail with the operations lead who set it. Three options, all defensible, none of them available while the assumption was still a nod.
The alternative the lead rejects is the impressive one: a five-day design sprint, next week, to produce a prototype of the portal. It spends the most expensive week of the initiative decorating an option whose feasibility is unknown, and at the end of it the same unmeasured 90% is still holding up the whole structure. The other rejected move is quieter and more common: accepting the anecdote because it came from the most senior person in the room, which converts the highest-status guess into the initiative's foundation.
Over a few months the compounding shows up in calibration. A team that closes every session with an owner, a test and a date accumulates a record of which of its confident beliefs were wrong — and starts discounting the next confident belief appropriately. A team that closes with a photograph of a wall has no such record, and will have the same argument about the same assumption next quarter, with the same confidence.
Map the most consequential assumptions across two axes: impact if false and evidence strength. Test the high-impact, weak-evidence corner first. Label assumptions by risk:
- Value: will the user choose or understand this?
- Usability: can they complete it without assistance?
- Feasibility: can the system and team make it work?
- Viability: can the organization support its policy, cost, and operation?
For returns, “90% of requests match codifiable policy” is high-impact and weakly evidenced. Before designing a rules engine, sample two weeks of agent decisions. “Carrier supports label creation” is evidenced; “carrier returns within two seconds at peak” is not. Different uncertainty deserves different tests.
| Failure | Signal | Facilitator move |
|---|---|---|
| Anchoring | Every option resembles the brief. | Restate the outcome; require three mechanisms. |
| Authority bias | The room waits for the director. | Silent work; leader shares last. |
| False consensus | No disagreement is recorded. | Ask: “What would make this fail?” |
| Workshop theatre | Board full; owners empty. | End with action, owner, evidence, date. |
- "Brainstorming means no criticism."
- The no-criticism rule belongs to the generation phase only, and it exists to protect half-formed ideas for twenty minutes. Carrying it into convergence produces a wall of unexamined options and a decision made on enthusiasm. Divergence protects ideas; convergence is where they earn their place.
- "The goal is to reach consensus."
- The goal is a decision with the dissent written down. A room that agrees unanimously has usually lost its dissent rather than resolved it, and unresolved dissent does not disappear — it returns in week six as a surprise, held by the person who could have told you in week one.
- "More people in the room means more ideas."
- Past roughly seven, coordination cost and anchoring both rise while independent contribution falls. Invite people because the decision needs what they know, not because exclusion feels impolite — then share the output widely, which is what most people actually wanted.
- "The wall of sticky notes is the output."
- The output is the closing paragraph: direction, dissent, the tests, their owners, and the date the decision gets made. The wall is raw material, and a photograph of raw material has never once changed what a team built.
When this is the wrong move
Do not run this when the decision is already made. Staging divergence to produce a conclusion you have chosen is manipulation with a whiteboard, and people can tell; announce the decision, explain the reasoning, and spend the workshop budget on a question that is still open. Do not run it when the group lacks the context either — the silent phase then produces confident guesses, and clustering guesses makes them look like findings. Run a facts round, or send a pre-read and delay a week.
Two smaller counter-cases. With two or three people who already speak daily, the ceremony costs more than the anchoring it removes: talk, decide, write the paragraph. And when the question already has an answer somewhere — a documented rate limit, a legal constraint, a published benchmark — a workshop is an expensive way to reinvent it. Go and read, then bring the answer to the room as a constraint.
In the lab: the companion Phase 4 exercise asks you to list the beliefs the existing returns implementation silently encodes — about eligibility, exception rates and carrier behaviour — rank them by impact and evidence, and design the cheapest test for the top one. For a fully worked version, see the capstone's assumption map.
Skill: choose the facilitation move
Why generate ideas silently first?
It shortens every workshop
It reduces group anchoring
It eliminates team disagreement
It documents every idea
Correct. Independent work prevents the first or most senior voice from defining everyone else's search space.
Which assumption should be tested first?
High impact, strong evidence
High impact, weak evidence
Low impact, weak evidence
Low impact, strong evidence
Correct. This is where the initiative can be wrong in a costly way and does not yet know it.
A dot vote should determine:
The final architecture choice
The committed delivery date
Where discussion should focus
The entire product strategy
Correct. Votes efficiently expose where energy and disagreement sit, giving the decider useful input.
Practice: design the workshop before inviting people
Choose one unresolved initiative decision. Write the decision question, participants and why each is needed, pre-read evidence, divergent prompt, convergence criteria, decider, and the exact closing artifact. If you cannot name the decision, cancel the meeting and find it first.
Reveal: a useful close for the returns session
Direction: test automated eligibility plus proactive status before committing to a full portal. Dissent: support believes guest checkout will dominate. Evidence: analyst samples 200 cases by Tuesday; designer tests policy comprehension with five customers; engineer spikes carrier latency for four hours. Decision date: Thursday. The board is not the output; this paragraph is.
- In two sentences, explain to a junior why a room of eight talking people does not search eight times as much of the solution space.
- Take one belief your current initiative is resting on. Name the cheapest test that could refute it, and what the team would build differently if it came back at half the assumed value.
- Predict the one-year state if assumptions keep going untested: which decision will be revisited for the third time, and what will people call the reason?
Your win
You can run a discovery session that produces independent options, explicit assumptions, useful dissent, and owned next evidence—not just a wall of notes.
Read and watch deeper
- Gamestorming, Gray, Brown & Macanufo — opening, exploring, and closing a collaborative session.
- Sprint, Knapp, Zeratsky & Kowitz — ch. 2–4: structured divergence, sketching, and decision-making.
- Continuous Discovery Habits, Teresa Torres — ch. 7–9: assumption mapping, the four product risks, and sizing a test to the decision it informs.
- Thinking, Fast and Slow, Kahneman — the anchoring and availability chapters: why the first number said aloud in a room keeps its grip on everyone in it.
- Reference: Initiative Playbook · workshop script.
Ask your agent-teacher to act as the sceptic in a pre-mortem, then cluster its risks with the team's rather than treating generated ideas as evidence.
Source page: lessons/0040-facilitating-discovery-and-brainstorming.html
Lesson 0041 · Initiative Lead · Module 1
Map the Workflow Before the Components
The fastest route to a bad architecture is to turn nouns in a brief into tables and services. Start with what happens, who decides, and where the work breaks.
Mission tie-in: good boundaries follow behavior, language, policy, and ownership. Workflow discovery supplies the evidence that folder trees and system diagrams cannot invent.
Knowledge: behavior before structure
For the returns initiative, a noun-first design finds Return, Customer, Label, and Refund, then draws a service around each. A workflow tells a different story:
Return requested → order verified → policy interpreted → evidence requested → eligibility decided → label created → parcel received → refund authorized → customer notified
Actors: customer, support agent, policy owner, warehouse clerk. External systems: order platform, carrier, payments. Hotspots: policy interpretation, missing evidence, warehouse-to-refund handoff.
A noun is a compressed summary of behavior, and everyone decompresses it differently. Six people can agree on the word Return for a year while holding six different models of it. An event cannot be agreed to that cheaply: it is a fact with a time, an actor, and a precondition, so people who disagree about one are forced to discover that they disagree. That is the mechanism—mapping does not produce a diagram, it produces externalized disagreement, early, where it costs an hour instead of a release. Time order does the rest: it forces sequence, hand-offs, and waiting into view, and those three drive coupling far more than nouns do.
It stops applying when there is nothing to observe or nothing to disagree about. A genuinely new workflow has no current state. A formally specified one—a card-scheme settlement protocol, a customs declaration—is already written down more precisely than a workshop will produce, so read the specification instead. And a “workflow” that is one function call with no hand-off and no exceptions yields nothing: mapping pays in proportion to variation, and a single-path process has none.
Build this with the people who do the work, not only their managers. Ask for the last real case, including spreadsheets, copied identifiers, manual messages, exceptions, and waiting. The “unofficial” steps often contain the actual domain rules.
A lightweight EventStorming sequence helps a cross-functional group uncover the model:
- Write domain events—facts that happened—in past tense and order them.
- Add commands that caused them and the actors who initiated those commands.
- Add policies: “when parcel received, authorize refund if inspection passed.”
- Add external systems, time, manual steps, questions, and failures.
- Mark hotspots where language, rules, responsibility, or confidence changes.
Those hotspots are design evidence. “Approved” means policy eligibility to support, warehouse inspection to operations, and settled money to finance. One global status would collapse three models. The workflow suggests separate states and a translation boundary, just as lesson 0036 predicts.
Map the current state before the desired state. Otherwise the team will unknowingly preserve invented steps and miss real constraints. Then design the target workflow by removing waiting, duplicate entry, and decisions the system can safely make—not by automating every current step.
How the lead actually ran it
Week 2. Seven people: product, design, the tech lead, four engineers. The brief already says “portal” and two engineers have started sketching tables. The lead does not open the brief. She asks the support lead for one thing—the case that went badly last week—because exceptions are where the rules live and a representative case has had its exceptions averaged away. She rejects the obvious starting point, the support ticket taxonomy: tidy, available, and encoding the current tool's dropdown rather than the domain.
Ninety minutes, two rules: one real case, no whiteboard theory. Tomas objects that this is a two-day tax on a ten-week pilot. The lead does not overrule him; she accepts the timebox as the price of the objection and asks him to write the events. He finds the three meanings of “approved” himself. Two further findings changed the design:
- The warehouse clerk keeps a spreadsheet reconciling parcels against a printed sheet. It was the only place the fact parcel received had ever been recorded—a system of record nobody had listed as a system. It is why, in lesson 0045, a warehouse event is what moves a return to
received. - The policy is less codifiable than the brief assumes. The 200-case sample commissioned in lesson 0040 came back at 143 clean matches against written policy and 57 needing human judgement—71%, not the 90% the brief implied. At the pilot's expected 160 requests a day, a 29% exception rate is roughly 46 manual cases daily against a guardrail of 50. The manual review queue is not a fallback here; it is load-bearing, and it has almost no headroom.
Nine weeks on, the payoff of the first hotspot is invisible—one module writes the refund state, and a class of “both components set refunded” defects never happened. The second is highly visible: it caps the pilot cohort, and it is why lesson 0050's week-four review watches queue depth as closely as median resolution time.
| Listen for | It reveals | Capture as |
|---|---|---|
| “Usually, except when…” | A policy and its exceptions. | Rule plus counterexample |
| “Then we wait for…” | A dependency or missing event. | Time and external owner |
| “Approved means…” | Competing domain meanings. | Vocabulary boundary |
| “I keep a spreadsheet…” | Unserved state or coordination. | Manual system of record |
- EventStorming is a workshop format.
- It is an evidence-gathering technique. The stickies are residue; the model is the output. Run it without someone who has personally done the work and you have produced a large, colourful diagram of your own assumptions.
- Mapping the current state means we intend to rebuild it.
- The current map records constraints and exceptions. The target map is designed by deleting waiting, duplicate entry, and decisions the system can safely make—not by automating each existing step. Teams that skip the current map usually automate the invented steps and miss the real ones.
- Hotspots are problems to resolve in the room.
- They are design evidence to carry forward. Resolving a vocabulary collision live almost always means the most senior person's meaning wins, which destroys exactly the information you convened to collect.
When this is the wrong move
Skip the session when a formal specification already governs the process more precisely than a workshop can, when the process is a single automated step with no human hand-off, or when this team has mapped this workflow twice in six months and nothing changed—that last case means the blocker is an unmade decision, not missing knowledge, and a third map is a way of avoiding it. Where there is genuinely no current state, prototype and observe real behavior instead; there is nothing yet to elicit.
Skill: read design evidence from a workflow
Why begin with domain events?
They define database tables
They expose actual behavior
They guarantee full automation
They eliminate policy exceptions
Correct. Past-tense facts build a shared timeline without prematurely choosing components or technologies.
Two teams use “approved” differently. This signals:
A simple naming defect
A shared database opportunity
A likely context boundary
A failed workshop session
Correct. Divergent meanings are strong evidence that one universal model should not cross this line.
Why map the current state first?
To replicate today's process
To reveal actual constraints
To choose service boundaries
To baseline resolution times
Correct. Real cases expose hidden work, exceptions, delays, and constraints the brief omits.
Practice: tell one real story end to end
Invite one domain expert, product, design, and two engineers. Map a recently completed real case as events. Add commands, actors, policies, external systems, waiting time, manual work, failures, and vocabulary collisions. Finish by writing three candidate boundaries and the evidence for each—do not draw deployables yet.
In the lab and the capstone. The lab's phase-4 checkpoint asks you to do this against code rather than a whiteboard: recover the workflow the existing returns package implements, and mark where its vocabulary changes meaning between modules. The finished artifact is the workflow map in the strong capstone packet—every hotspot it records is used later by a driver, a contract, or a rollout gate. Compare with the weak version, which lists the same steps with no actors, no waiting, and no collisions, and so supports no later decision.
Reveal: candidate boundaries from the returns map
Eligibility owns policy interpretation and a decision with reasons. Return journey owns customer-visible progress and communication. Refund settlement belongs to the existing payments context. These are model boundaries, not necessarily three services: a modular monolith can enforce all three while one small team owns the initiative.
- In two sentences, explain to a new lead why a list of past-tense facts finds better boundaries than a list of nouns.
- Take one initiative you are on this week. Name the workflow you would map, the one real case you would ask for, and the specific design decision the map would change.
- Predict the failure: if that team never maps the workflow, what breaks in a quarter? Name the field, the two components that will both write it, and the incident that follows.
Your win
You can use a real workflow to discover domain language, rules, waiting, external dependencies, and candidate boundaries before committing to data models or deployables.
Read and watch deeper
- Introducing EventStorming, Alberto Brandolini — the canonical guide to events, commands, policies, hotspots, and collaborative modeling.
- User Story Mapping, Jeff Patton — ch. 3–5: tell the whole story and build shared understanding through activity.
- Domain-Driven Design, Eric Evans — ch. 2 and 14: ubiquitous language and maintaining model integrity.
- EventStorming, Alberto Brandolini — the technique's own site: workshop formats, notation, and the big-picture-to-design progression.
- Reference: Initiative Playbook · workflow map, and glossary: bounded context.
Give an anonymized workflow transcript to your agent-teacher and ask it to extract events, commands, policies, ambiguities, and missing actors; validate every extraction with the domain expert.
Source page: lessons/0041-mapping-workflows-and-domain.html
Lesson 0042 · Initiative Lead · Module 1
Shape User Stories into Vertical Slices
A flat backlog hides the user journey and rewards component work. A story map lets the team cut the smallest end-to-end path that can teach something.
Mission tie-in: slicing is software design. It decides dependency order, integration timing, feedback speed, and whether the architecture grows around real behavior or imagined reuse.
Knowledge: the story is a conversation
A user story sentence is a placeholder, not a miniature contract. “As a customer, I want to create a return so that I can receive a refund” omits policy, failure, evidence, state, accessibility, and the outcome being tested. Its value is the conversation it starts.
Turn the workflow from lesson 0041 into a two-dimensional story map:
| Start request | Check eligibility | Choose resolution | Return parcel | Track outcome |
|---|---|---|---|---|
| signed-in order | domestic / unopened | refund to original | carrier label | status page |
| guest order | opened / damaged | exchange | QR drop-off | email and SMS |
| gift order | international | store credit | home collection | agent contact |
Pilot slice: the first row, with manual warehouse confirmation behind the scenes. It is narrow in variation and complete across the journey.
A vertical slice crosses the system and produces observable behavior. “Create return table,” “build rules API,” and “design status component” are horizontal layers; none helps a user or tests the integration alone. “Eligible signed-in customer receives a label and can see status” crosses UI, policy, persistence, carrier, and telemetry—but only for one controlled case.
Thin vertical slices reduce risk by converting integration risk into early evidence. Almost all schedule risk in software is unknown interaction—the mismatch between what one part assumes and what another provides—and an unknown interaction can only be observed by running the whole path. A horizontal layer defers every one of those observations to a single late integration moment, and by then the cost of each discovered mismatch is multiplied by all the work already built on the wrong assumption. A vertical slice pays a small fixed cost—touching every layer for one narrow case—to buy a cheap early failure. You are not buying progress; you are buying the right to be wrong while being wrong is still affordable.
The second mechanism: a layer cannot be judged, but a demonstrable behavior can be measured, shown to a user, and used to choose what to build next. It stops paying where there is no interaction to discover—a pure algorithm change inside one module—or where the path cannot yet be exercised at all, in which case slice against a contract-level fake and say plainly that the integration risk is still open.
Attach examples before implementation. Use Given/When/Then when it clarifies, but the syntax is less important than the specificity:
Happy: Given an unopened domestic item delivered 12 days ago, when its purchaser requests a return, then approve it and create one label.
Boundary: Given the same item delivered 31 days ago, then refuse it with reason outside return window and offer agent contact.
Retry: Given an approved request whose label call timed out, when the call is retried with the same intent, then show the original label rather than buy another.
Guardrail: A keyboard-only customer can complete the happy path and understand any refusal.
Good acceptance examples cover the rule, its boundary, a meaningful failure, and a guardrail—not every implementation detail. Refine them with product, design, engineering, and whoever tests or operates the flow. This “three amigos” conversation catches ambiguity when it is still cheap.
Do not create a bureaucratic “Definition of Ready” gate. Use a readiness question: does the team understand the user and outcome, examples, dependencies, design state, telemetry, and unresolved risk well enough to start while preserving room to learn?
How the lead actually cut it
Week 3, seven people, ten weeks to the 30 September pilot. Product wants rows one and two, because guest orders are 29% of demand and the sponsor keeps asking about them.
The lead's first question is not “what can we build?” but “if this slice ships and the median does not move, what will we have learned?” Row one answers it: automatic eligibility plus visible status is either the cause of faster resolution or it is not. Guest orders would double the identity and fraud surface without changing what the slice can teach, so guest becomes an explicit non-goal with a revisit trigger rather than a silent omission.
Karl argues the opposite order: eligibility engine first, because policy is the hard part. The lead rejects it for a specific reason rather than a preference—policy correctness can be proven cheaply with executable examples, while nothing but a live path can prove the carrier call, the retry, and the status loop. Sequence what only production-shaped evidence can settle. Bea, the support lead, dissents on the cohort; her belief that guests will dominate the queue is written down with the test that would settle it, and it resurfaces in lesson 0050's week-four review.
The manual seam is chosen by arithmetic. With 71% of sampled cases matching written policy, roughly 46 of the pilot's 160 daily requests need a human against a guardrail of 50 per day—so the slice automates the clean 71%, routes the rest to an agent queue, and instruments queue depth from day one. Slice one shipped in nine working days, and on day four it surfaced the carrier's duplicate-label behavior on retry: a defect that, under a layered plan, would have arrived in week seven with a policy engine, a UI, and a notification service already built on top of it.
| Slice by | Keep | Defer |
|---|---|---|
| persona or cohort | one user's whole path | guest, admin, international |
| business-rule variation | one complete policy case | exceptions and alternate resolutions |
| operational mode | manual step behind a seam | automation after evidence |
| quality threshold | safe pilot volume | global scale after measurement |
- A vertical slice has to be small in value.
- It has to be small in variation and complete in path. One cohort, one rule, one resolution—but all the way through. Teams that shrink value instead of variation end up shipping a whole journey badly rather than one journey properly.
- Layers are fine as long as we integrate weekly.
- Weekly integration of layers still defers the interaction discovery; it just batches it into smaller surprises that arrive after the assumptions have already spread. The unit that retires risk is a demonstrable behavior, not a merge.
- Given/When/Then is what makes an example good.
- Specificity is what makes it good; the syntax is packaging. A team that adopts the keywords and keeps vague examples now has ambiguity and a test framework to maintain.
- Manual seams are technical debt.
- A manual seam with an owner, a volume limit, and a removal trigger is a deliberate deferral. The version without those three is the debt—and it is how a pilot workaround becomes someone's permanent job.
When this is the wrong move
Do not force a vertical slice when there is no integration to discover: a batch recalculation inside one service, a change with no user-visible surface. Do not force one when the scaffolding costs more than the risk—if a slice needs a fake adapter, a flag, a seeded cohort, and a data migration to demonstrate anything, the honest move may be a spike instead. And in a release that legally cannot expose partial behavior to anyone, slice for internal evidence and accept that the external release is a single event with a rehearsed reversal.
Skill: find the learning slice
Which item is a vertical slice?
Create the return table
Build the eligibility API
Approve one return cohort
Design the status component
Correct. This is a deliberately narrow cohort completing an observable path through the system.
Acceptance examples should primarily specify:
Observable rules and outcomes
Internal classes and methods
Individual tasks and owners
Storage engines and schemas
Correct. Examples make business behavior, boundaries, and important failures concrete enough to discuss and test.
Why keep manual work in an early slice?
To shrink the estimate
To defer unproven automation
To create permanent operations
To maximize initial coverage
Correct. Manual execution can validate value and workflow before the team invests in uncertain automation.
Practice: cut a journey, not a layer
Build a story map for one initiative: backbone activities left to right, variations beneath. Draw a line across the smallest slice that reaches an outcome or answers a risky assumption. Write four examples: happy path, rule boundary, meaningful failure, and guardrail. Then identify the manual seam that could remove the largest unproven automation.
In the lab and the capstone. The lab's phase-4 checkpoint makes you cut one slice through the existing returns code and prove it end to end—the rollout gate for that slice is written as a test rather than a paragraph. The worked artifacts are the story map and its executable examples in the strong capstone packet; trace how the boundary example there becomes a refusal reason in the contract and a segment in the rollout telemetry. Compare with the weak version's story map and examples, where the backbone is a component list and every example describes a happy path.
Reveal: the slice quality test
A slice is useful when it can be deployed independently, demonstrated in user language, observed with a named measure, and used to decide what to do next. If its demo is “the endpoint returns 200” or “the schema migrated,” it is a task bundle, not yet a product slice.
- In two sentences, explain to a new lead why a narrow end-to-end slice is safer than a finished layer, without using the word “agile”.
- Take the next item on your own backlog. Rewrite it as a slice: name the cohort, the one rule, the manual seam, and the measure that would move.
- Predict the failure: if a team plans by layers for a quarter, what does the first integration week look like, and which assumption will turn out to have been copied into four places?
Your win
You can turn a workflow into a story map, capture behavior with concrete examples, and cut a narrow end-to-end slice that earns evidence before infrastructure.
Read and watch deeper
- User Story Mapping, Jeff Patton — ch. 4–8: the backbone, release slices, and using maps for discovery and delivery.
- Specification by Example, Gojko Adzic — ch. 2–4: deriving scope and tests from concrete examples collaboratively.
- Essential XP: Card, Conversation, Confirmation, Ron Jeffries (2001) — the origin of the three Cs, and why the sentence on the card is the least important part.
- Reference: Initiative Playbook · story-map and example templates.
Ask your agent-teacher for five smaller slices, then reject every proposal that does not reach observable behavior end to end.
Source page: lessons/0042-story-mapping-and-vertical-slices.html
Lesson 0043 · Initiative Lead · Module 2
Find the Architecture Drivers
“Fast, scalable, secure, maintainable” is not a set of requirements. Architecture begins when a quality has a situation, a response, and a number.
Mission tie-in: every architecture is good for some conditions and poor for others. Drivers make those conditions explicit enough to design and test.
Knowledge: the few requirements that shape the system
Most requirements do not change the architecture. A new return reason may add a rule inside an existing boundary. A requirement to keep accepting requests while the carrier is unavailable changes data ownership, failure handling, and workflow shape. Architecture drivers are the small set of functional needs, quality attributes, constraints, and risks with enough force to shape the structure.
Turn vague “non-functional requirements” into quality-attribute scenarios with six parts:
- Source: who or what causes the condition?
- Stimulus: what happens?
- Environment: under which operating condition?
- Artifact: what part of the system is affected?
- Response: what must the system do?
- Measure: how will you know it did so?
| Quality | Scenario | Design pressure |
|---|---|---|
| Availability | When the carrier is unavailable for 30 minutes during peak, accept eligible requests and create 99% of labels within 15 minutes after recovery. | Persist intent; idempotent retry; visible pending state. |
| Performance | At 40 requests/second, show an eligibility decision within 800 ms at p95, excluding label creation. | Separate decision from slow vendor; measure the boundary. |
| Security | If one customer changes an order identifier, disclose no other customer's order or return data in every environment. | Object-level authorization; negative tests; audit trail. |
| Modifiability | When policy adds one eligibility rule, one engineer can release it within one day without changing carrier or UI adapters. | Cohesive policy module; stable result contract. |
| Operability | When label failures exceed 2% for five minutes, on-call can identify carrier impact and disable label attempts within ten minutes. | Vendor metric, alert, flag, runbook. |
A quality-attribute scenario works because it is a discriminator. By the time you are choosing an architecture, every candidate already satisfies the functional requirements; what separates them is behavior under conditions. An adjective cannot separate two designs—“scalable” is true of both, so it cannot inform a choice and cannot later be violated. A scenario with a stimulus, an environment, and a measure can rank the candidates before they are built and can fail after they are. That is the whole trick: you are not writing requirements, you are manufacturing the ability to be wrong.
The second effect is social. A scenario moves the argument from people to conditions. “Your design is over-engineered” and “yours won't scale” are unresolvable; “which scenario pays for this queue?” and “what happens to the 800 ms target when the carrier is slow?” resolve in minutes, because both sides can now point at the same sentence.
Scenarios stop helping in three situations. When the measure cannot be obtained before the decision must be made, a scenario is a wish—treat it as an assumption and buy evidence with a spike. When the number is invented to look rigorous, it is worse than an honest gap: an unsourced p95 will be defended by someone in six months as though it came from a customer. And when the dominant risk is problem uncertainty rather than solution uncertainty, no latency scenario helps; you cannot tune a system nobody has agreed to want.
Prioritize five to seven drivers, not fifty. Rank by business impact, uncertainty, and architectural reach. If two drivers conflict—800 ms response and a live carrier confirmation—write the trade-off rather than letting an engineer discover it mid-sprint. Here the team returns eligibility quickly and makes label creation a separately observable state.
Separate constraints from preferences. “Orders remain in the existing platform during the pilot” is a constraint. “Use Kafka because we already know it” is a proposed decision. Treating preferences as facts is how architecture becomes cargo cult.
Every selected driver needs an evidence mechanism: load test, architecture fitness function, contract test, recovery exercise, security test, cost alert, or production measure. A requirement that cannot fail a check will eventually lose to a feature that can pass a demo.
How the lead actually narrowed it
Week 4. Twenty-three “requirements” arrive in a shared document, contributed by four groups who have never had to reconcile them. The lead does not start writing scenarios. She applies one filter to each line: name two designs that would respond to this differently. Nineteen fail the filter. They are real needs—audit logging, sensible error messages, a metrics dashboard—but every plausible architecture handles them the same way, so they are backlog items, not drivers.
The alternative she rejects is a full structured architecture evaluation with all fifteen stakeholders. It would be defensible and it would cost two days plus the calendar to convene it, against a seven-person, ten-week pilot. She takes the scenario form and drops the ceremony: five scenarios, one hour, the four people who carry the consequences.
Sam, from security, wants a scenario for every category in the team's threat checklist. The lead keeps one—object-level authorization on order identifiers—and moves the rest into a review checklist attached to the design. The reason is stated out loud so it can be argued with: a driver must change the structure, and most of that checklist changes only the code. Sam's dissent is recorded; if a scenario in that list later forces a boundary, it is promoted.
The conflict that matters shows up immediately. The 800 ms eligibility target and “confirm the label with the carrier before responding” cannot both hold: the four-hour spike in lesson 0046 measured the carrier sandbox at 1.9 s p95 and 6.4 s p99. Rather than let an engineer discover this in week eight, the driver table writes the trade explicitly—eligibility answers fast, label creation becomes a separately observable state. That one sentence is why the availability scenario exists, why the ADR is decidable at all, and why the rollout in lesson 0049 has a “pending label” gate to watch.
| Vague claim | Missing decision | Make concrete |
|---|---|---|
| “Highly available” | Which operation may degrade? | Failure, duration, response, recovery target. |
| “Scalable” | Scale of what, by when? | Load shape, growth horizon, response measure. |
| “Secure” | Against which abuse? | Actor, asset, trust boundary, forbidden result. |
| “Easy to change” | Which likely change? | Change case, person, elapsed effort, blast radius. |
- Non-functional requirements are the operations team's list.
- They are the primary shapers of structure. Functional requirements mostly fit inside whatever structure you pick; qualities are what make one structure right and another expensive. Treating them as a late checklist is how a system gets built twice.
- More drivers means more rigour.
- A list of twenty unranked drivers has no priority, so in practice the engineer's own preference becomes the tie-breaker. Prioritization is the artifact; the list is just its input.
- A driver is satisfied when the design addresses it.
- It is satisfied when a check can fail. “Addressed” is a claim; a load test, fitness function, or production measure is evidence. Systems pass reviews and fail production precisely in this gap.
- Constraints are just requirements we haven't pushed back on.
- Some are negotiable and some are not; the point of labelling them is to know which. Pushing hard on a genuine constraint—“the order system stays authoritative”—burns the credibility you will need for the fake one.
When this is the wrong move
Skip formal scenarios when the initiative's dominant risk is whether anyone wants the thing: write assumptions and buy user evidence instead, because a latency target for an unvalidated product is precision in the wrong place. Skip them for genuinely short-lived work—a two-week internal migration tool does not need a modifiability scenario. And refuse to write one when the honest answer to “what is the measure?” is “nobody knows”: record the unknown, name the spike that would settle it, and leave the row empty rather than filling it with a number the team will later defend.
Skill: make the driver measurable
Which requirement can drive architecture?
The system feels responsive
Eligibility p95 under 800ms
Policy changes stay easy
Use a modern stack
Correct. This identifies an operation, load condition, percentile, and measurable response target.
Why prioritize only a few drivers?
To reduce documentation effort
To expose shaping tradeoffs
To hide implementation details
To guarantee less code
Correct. Architecture cannot maximize every quality; priority makes the consequential trade-offs and evidence visible.
“Use the existing order platform” is a:
Business outcome
Quality attribute
Design constraint
Acceptance example
Correct. The initiative is not authorized to replace it, so solution design must work around it.
Practice: write the five scenarios
For your initiative, select the five qualities with the greatest combined impact, uncertainty, and structural reach. Write source, stimulus, environment, artifact, response, and measure for each. Add one evidence mechanism and one owner. Delete every generic “scalable” or “secure” statement left over.
In the lab and the capstone. The lab's phase-4 checkpoint turns one of these scenarios into outcome telemetry and a rollout gate that fails as a test rather than as an opinion. The worked artifact is the driver table in the strong capstone packet—each row there names an evidence mechanism and an owner, and each is referenced later by an ADR or a rollout stage. Compare with the weak version, whose qualities are adjectives with no environment, no measure, and therefore nothing a design review can disagree with.
Reveal: the architecture-driver test
A driver is ready when two plausible architectures would respond differently to it, the team can explain why it matters to the outcome, and a check can prove whether the chosen design meets it. If no decision or test changes, it is context—not a driver.
- In two sentences, explain to a new lead why “name two designs that would differ” is a better filter for a driver than “is this important?”.
- Take the loudest quality claim on your current initiative. Write its six parts, then say which design decision this week it would change.
- Predict the failure: if a team ships with five adjectives instead of five scenarios, what is the first production surprise, and who will be blamed for a target nobody ever wrote down?
Your win
You can identify the few requirements that should shape a system and turn vague qualities into scenarios that guide design, tests, and operations.
Read and watch deeper
- Software Architecture in Practice, Bass, Clements & Kazman — ch. 3 and 13: the six-part quality-attribute scenario, and architecturally significant requirements.
- Documenting Software Architectures, Clements et al. — the quality-attribute scenario as a precise design input.
- The Elephant in the Architecture, Cartwright — justify cross-functional requirements with business value.
- Quality Attribute Workshops (QAW), Third Edition, Barbacci et al. — SEI technical report CMU/SEI-2003-TR-016: how to elicit and refine scenarios with stakeholders.
- Reference: Initiative Playbook · driver table, and fitness function.
Ask your agent-teacher to attack each quality statement with “under what condition, which response, and how measured?” until no adjective survives alone.
Source page: lessons/0043-architecture-drivers-and-quality-attributes.html
Lesson 0044 · Initiative Lead · Module 2
Draw the Context, Then Choose Boundaries
The valuable architecture diagram is not the most detailed one. It is the smallest view that lets its audience inspect a decision.
Mission tie-in: system design turns drivers and workflows into boundaries with controlled dependency direction. Diagrams are review instruments, not wall art.
Knowledge: three views, three questions
Start with a system context view: the system in scope, the people who use it, and external systems it touches. This answers what is ours, who needs it, and where are the external dependencies and trust boundaries?
Then draw a container view—C4's word for a deployable application or data store, not specifically Docker. This answers what runs, what stores data, how do they communicate, and who owns each part? Finally, draw one dynamic view for the risky workflow. This answers what happens in time when the happy path or failure case runs?
| View | Elements | Decision exposed |
|---|---|---|
| Context | customer, support agent, returns system, order platform, carrier, payments | Scope, actors, external ownership, PII and trust crossings |
| Container | existing web app, returns module, existing database schema, background worker | One deployable plus worker; no new service fleet |
| Dynamic | request → decision → persisted intent → label attempt → notification | Carrier failure cannot lose the accepted request |
For a 6–8 person cross-functional team, a modular monolith is the responsible default: one deployable, explicit modules, one composition root, and enforced dependency rules. It minimizes coordination and operational surface while preserving seams. Choose another deployable only when a driver requires independent scaling, isolation, security, availability, release cadence, technology, or ownership—and record the cost.
A diagram is a compression of a system for one audience's decision, and compression only helps when the discarded detail is irrelevant to that decision. Mixing levels forces every reader to filter, and filtering is where misreading happens. That is why C4 separates context, container, and component: not as a documentation standard to complete, but so each picture can throw away everything the current argument does not need.
The deeper mechanism is about the cost of a boundary over time. A boundary you can change in a pull request costs a code review, once. A boundary you can only change by coordinating two deployments costs a release plan, every time, forever. Crossing a process boundary converts compile-time errors into runtime errors, refactorings into migrations, and function calls into partial failures you must now design for. So the price of a module seam is paid once and the price of a service is paid weekly—which is why, for one team, the module seam is the default and the service needs a driver to justify its rent.
The default flips under named pressure: genuinely different load shapes that must scale independently, a regulatory or blast-radius isolation requirement, a release cadence owned by someone else, or a technology that cannot live in the same process. It also flips on people. Around three teams sharing one deployable, the coordination cost inside the codebase starts to exceed the operational cost of separate ones, and the same seam that was an asset becomes a queue.
Inside the deployable, draw modules around four kinds of evidence:
- Language: eligibility, journey, and settlement mean different things.
- Change: policy changes weekly; carrier integration changes on vendor cadence.
- Invariants and state: the eligibility decision must be internally consistent.
- Ownership: one owner can change a module without negotiating every edit.
Keep technology at the edges, as lesson 0023 established. The domain-facing interface says create_label(intent), not carrier_sdk.shipments.post(payload). On the diagram, label every arrow with the interaction and protocol. Unlabelled arrows conceal assumptions.
A diagram should carry a title, scope, audience, date/version, legend, ownership, and the significant qualities or risks it addresses. Delete boxes that do not help the stated audience make a decision. Code-level detail belongs in code unless it explains an unusually important mechanism.
How the lead actually reviewed it
Week 5, container view on the screen. The lead looks at the arrows first, not the boxes, and specifically at which arrows cross an ownership line. Her question to the room is operational, not aesthetic: who is on call when this arrow fails at two in the morning? Three arrows have an answer. One—the call into the order platform—does not, which is how Asha's team becomes a named adviser on the contract rather than an assumed dependency.
Dana proposes extracting an Eligibility Service. Her reasoning is good: policy changes weekly and the rest of the system does not, so why should a policy change redeploy the carrier code? The lead rejects the extraction and says why in the same terms. Release cadence is only a driver when the cadences belong to different owners; here one team owns both, so an enforced module seam buys the same isolation of change for none of the operational cost. Dana's counter is recorded rather than dismissed: if returns policy moves to a second team next quarter, the seam must become a service. That sentence becomes the revisit trigger on the ADR in lesson 0046.
The seam is then made real, because an agreed boundary that nothing enforces is a naming convention. The team writes an import rule as a test that fails the build:
import ast
import pathlib
ALLOWED = { # a module may import only what is listed here
"journey": {"eligibility", "shared"},
"eligibility": {"shared"},
"carrier": {"shared"},
"shared": set(),
}
def imported_roots(path):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
yield node.module.split(".")[0]
def test_modules_depend_only_downward():
root = pathlib.Path("returns")
for path in root.rglob("*.py"):
owner = path.relative_to(root).parts[0]
for target in imported_roots(path):
if target in ALLOWED and target != owner:
assert target in ALLOWED[owner], f"{path}: {owner} -> {target}"
Ten weeks later the system is one deployable and one worker: two processes on call rather than five, and the journey module still cannot reach the carrier adapter directly, because the build says so. The cost the team accepted is visible too—a policy release redeploys everything, and twice the team waited for an unrelated change to clear the pipeline.
| You see | What it hides | The move |
|---|---|---|
| one diagram with 70 boxes | Audience and decision are undefined. | Split by scope and question. |
| arrows without labels | Protocol, direction, and meaning. | Name every interaction. |
| database shared by “services” | State ownership is fictional. | Name the writer or merge boundaries. |
| a service per domain noun | Deployment cost has no driver. | Return to workflow and change. |
- A modular monolith is a stage on the way to microservices.
- It is a design in its own right. The seam is the asset; extracting it is one option the seam keeps open, not a destination. Many systems should never take the second step, and saying so out loud prevents a team from building for a migration that will not happen.
- Microservices give teams autonomy.
- Services give deployment independence. Autonomy comes from owning a boundary with stable contracts. Split a tangle into services and you get the same coupling with network calls and a release calendar on top.
- The C4 levels are a documentation set to complete.
- You draw the level whose decision is live. A container view for a system nobody is arguing about is wall art that will be stale in a month.
- The diagram should match the code exactly.
- It should match the decision. A diagram maintained at code fidelity is either generated or lying; a diagram maintained at decision fidelity stays useful for years.
When this is the wrong move
Do not draw the packet when the audience already shares the model—redrawing the context view for the third time to the same four people is ceremony. Do not draw it when the question can be settled by a twenty-line spike, and do not draw a container view for a system with one container. The counter-case for the enforced import test is smaller than it looks, but it exists: in a codebase of three modules whose owners sit together and review everything, the test costs more attention than the mistake it prevents. Add it when the team grows past the point where every import is seen by someone who cares.
Skill: draw only what earns its place
A context diagram primarily establishes:
Classes and method calls
Scope, users, external systems
Tables, indexes, data types
Tasks, owners, delivery dates
Correct. The context view establishes the system boundary, its users, and neighboring external systems.
When should this team add a deployable?
When a domain noun appears
When the codebase grows large
When a driver requires it
When the roadmap promises scale
Correct. Independent deployment is bought when a measured quality or ownership pressure requires it.
The critical dynamic view should show:
Component names and versions
Interaction order and failure
Every internal function call
All future vendor costs
Correct. The dynamic view earns its place by exposing interaction order and behavior under consequential failure.
Practice: make a three-view architecture packet
Draw a context diagram, a container diagram, and one dynamic diagram for your riskiest workflow. On each: state audience and question; label every boundary, owner, arrow, and data store; annotate which driver it addresses. Run a review where every comment must point to a driver, workflow, or risk.
In the lab and the capstone. The lab's phase-4 checkpoint asks you to record one boundary decision as an ADR and to enforce it with a test like the one above, so the seam survives the next contributor. The worked artifact is the three-view architecture packet in the strong capstone packet, where every arrow is labelled and every view names the driver it answers. Compare with the weak version: one crowded diagram, a service per noun, unlabelled arrows, and a shared database drawn as though ownership were obvious.
Reveal: the service-extraction test
Before extracting a deployable, complete this sentence: “We accept network failure, versioning, deployment, observability, security, and on-call overhead because independent _____ is required by driver _____.” If the blanks contain “cleanliness” or “maybe later,” keep a module seam and postpone the distributed system.
- In two sentences, explain to a new lead why a module seam is cheap and a service boundary is a subscription.
- Take your current system. Name one boundary you would enforce with a test this week, and the mistake that test would have caught in the last three months.
- Predict the failure: if a six-person team splits into five services now, what does their on-call rota look like in a quarter, and which change will require three coordinated deploys?
Your win
You can communicate system scope and runtime behavior, choose evidence-based module boundaries, and resist deployment boundaries whose costs have no driver.
Read and watch deeper
- The C4 model, Simon Brown — official model; especially context, container, dynamic diagrams, and notation checklist.
- Fundamentals of Software Architecture, Richards & Ford — architecture styles, characteristics, and component boundaries.
- Team Topologies, Skelton & Pais — ch. 6–7: aligning ownership boundaries and interaction modes.
- MonolithFirst, Fowler — why teams that start with services usually get the boundaries wrong, and what to do instead.
- Reference: Initiative Playbook · architecture packet, and deep modules.
Show the diagrams to your agent-teacher with the driver set and ask which box or arrow has no justification and which driver has no visible response.
Source page: lessons/0044-system-context-and-boundaries.html
Lesson 0045 · Initiative Lead · Module 2
Design State, Data Ownership, and Contracts
Most integration bugs are unanswered state questions wearing API syntax. Model transitions, ownership, failure, and change before polishing endpoints.
Mission tie-in: data is the durable part of a system. A lead makes its invariants, writer, lifecycle, and compatibility explicit before code makes accidental promises.
Knowledge: state first, transport second
“Return status” sounds like a field. It is actually a state machine with allowed transitions and actors:
| From | Command | To | Rule / owner |
|---|---|---|---|
| — | request return | pending decision | Journey creates one request per intent. |
| pending decision | record eligibility | approved / refused | Eligibility owns decision and reasons. |
| approved | attach label | ready to ship | Journey accepts one carrier label. |
| ready to ship | record receipt | received | Warehouse event must match item. |
| received | record settlement | refunded | Payments owns settled-money truth. |
The state machine answers what is legal. Data ownership answers who may establish each fact. Prefer one authoritative writer for a piece of state; other modules receive a contract, event, or read model. A shared database does not require shared ownership. If both Journey and Payments can set refunded, the invariant lives nowhere.
State comes before transport because the transport is replaceable and the state is not. You can move from REST to gRPC to a queue in a quarter; you cannot unwrite four million rows whose meaning was never agreed. Underneath that, a state machine is a claim about which histories are possible, and every bug in this family is a history the code permitted and the model forbade, or the reverse. Enumerating transitions turns an unbounded space of field combinations into a small set you can test exhaustively—and small enumerable sets are the only things teams reliably reason about under pressure.
Single-writer ownership works for a related reason. An invariant can only be enforced where all the information needed to check it is present and serialized. Two writers means the rule has no home: each one is correct in isolation and the pair is wrong, which is why these defects are so hard to reproduce. Ownership is not a governance preference; it is where you put the check.
The boundaries: do not force a state machine on an entity with one terminal transition, or on what is genuinely a set of independent flags—an artificial machine over orthogonal facts produces a combinatorial mess that models nothing. And single-writer stops being achievable when latency or availability forbids a coordination point. Then you do not pretend; you choose convergence rules, make the conflict visible in the model, and decide who resolves it.
For every command, API, or event contract, answer:
- Meaning: command, fact, or query? What business promise does success make?
- Identity: what identifies the resource and the user's intent? How are retries deduplicated?
- Validation and authorization: which edge rejects malformed data, and which domain rule refuses valid-but-forbidden behavior?
- Failure: timeout, unavailable dependency, conflict, partial success—what may the caller safely do?
- Compatibility: which fields are required, what may be added, how will consumers migrate, and when can old behavior be removed?
- Lifecycle: classification, encryption, retention, deletion, audit, and who may read it.
Design retries with an idempotency key representing one intent. A client retry after timeout must return the original return and label, not create another. Design consumers as tolerant readers, and evolve persisted data with expand/contract. Compatibility is a delivery feature, not cleanup.
Draw data flows across trust boundaries and minimize data early. The carrier needs recipient name and address for a label; it does not need return reason notes or order history. Record retention and deletion in the model, not only a policy document.
How the lead actually reviewed it
Week 6, contract review, ninety minutes, with Asha from the order platform and Sam from privacy in the room. The lead's first move is not to read the API draft. She scans the state table for any row where two components could write the same fact, and finds one: refunded, which both the journey module and the payments context believed they owned. Nobody had disagreed, because nobody had asked.
Her second move is the question that has repaid itself on every initiative she has led. She asks the producer and the consumer separately: what does a timeout on create_label mean? Journey answered “no label was created.” The engineer holding the carrier adapter answered “maybe a label was created.” Both answers are reasonable and they cannot both be built on. The four-hour spike settled it: retrying a timed-out label call without an idempotency key produced a duplicate label in 12% of attempts.
The obvious fix—a unique database constraint on the return—was rejected, and the reason matters. It converts a duplicate into an error at the wrong layer, at the wrong time, and it throws away the thing the caller actually needs: the label that was already bought. The design chosen instead stores the intent before the call and returns the original answer to every retry:
class DuplicateIntent(Exception):
"""Same intent key, different content — a client bug, not a retry."""
def request_label(store, carrier, intent_key, return_id, address):
existing = store.find_label(intent_key)
if existing is not None:
if existing.return_id != return_id:
raise DuplicateIntent(intent_key)
return existing.label_id # a retry gets the first answer
reservation = store.reserve(intent_key, return_id) # committed before the call
label_id = carrier.create_label(reservation.id, address)
store.attach_label(intent_key, label_id)
return label_id
Rui dissented: the carrier offers its own idempotency header, so why hold state on this side? The lead recorded the objection and answered it with a boundary argument rather than a technical one—the carrier's semantics are outside the team's control and can change with a vendor release, while the promise made to the customer cannot. The header is used as well; it is not relied on.
Sam's contribution took four minutes and removed a liability. The carrier needs a recipient name and an address to print a label. It was being sent the return reason and the order history because the payload was built from an existing internal object. Minimizing that payload at design time cost nothing; removing it after two consumers depend on it would have been an expand/contract migration across a vendor boundary.
The consequence appears at the staff-account gate in lesson 0049: fifty cases, zero duplicate labels. Nothing was celebrated, which is what a well-designed contract feels like from the outside.
| Question | Bad default | Designed answer |
|---|---|---|
| Who may move this state? | Anyone with database access. | One command owner enforces transitions. |
| What did timeout mean? | Assume failure and repeat. | Retry one intent idempotently. |
| Can a field disappear? | Coordinate a big-bang deploy. | Expand, migrate readers, contract. |
| Why retain this PII? | Storage is cheap. | Purpose, duration, deletion owner. |
- Idempotency means an operation can be retried safely.
- It means one intent has one result. A retry that generates a fresh key is a new intent, and most duplicate-charge incidents are key-generation bugs on the client, not handler bugs on the server. Decide where the key is minted and write it into the contract.
- One authoritative writer means one service owns the table.
- Ownership is about who may establish the fact. A shared schema with a single writing module is fine; two services writing the same rows from separate deployments is not, whatever the diagram says.
- Backwards compatibility is cleanup for the end.
- It is what allows the two sides of a change to deploy on different days—which is what allows you to deploy at all without a coordination meeting. Treat it as a delivery feature and the release calendar stops being a constraint on design.
- Privacy decisions belong to legal.
- The design decides what is collected and where it crosses a boundary; legal can only approve or forbid what the design already made possible. By the time a review happens, the cheap moment—choosing not to send the field—has passed.
When this is the wrong move
A full state-and-contract review is waste for a read-only internal report, or for an entity that is created and never changes again. Inside a single deployable, where one module calls another and both ship together, a typed function signature is the contract; adding a versioning policy and a compatibility window to it buys nothing and costs a document nobody updates. The apparatus earns its cost exactly where the two sides can be deployed, operated, or changed independently—which is also the moment most teams notice it is missing.
Skill: locate the durable promise
Who should set “refund settled”?
The customer interface module
The return journey module
The payment settlement context
The external carrier adapter
Correct. The payments context owns settlement truth and publishes it for journey and support views.
An idempotency key should identify:
One individual network attempt
One complete business intent
One authenticated system user
One deployed application version
Correct. All retries of one user intention reuse the same key and therefore the same result.
A consumer receives a field it does not recognize. It should:
ignore it and continue
reject the whole message
log and stop processing
store it for later
Correct. A tolerant reader ignores what it does not need, so producers can add fields without a coordinated release.
Practice: review one critical state transition
Draw the state machine for one initiative entity. For every transition, name command, actor, rule, owner, emitted fact, retry behavior, and audit need. Then choose one cross-boundary contract and document its meaning, failure semantics, compatibility policy, PII, and a contract test.
In the lab and the capstone. The lab's phase-4 checkpoint makes the retry semantics executable: a test that replays one intent twice and fails if the system produces two labels. The worked artifact is the state and contract review in the strong capstone packet, which names one writer per fact and states what a timeout means before any endpoint is designed. Compare with the weak version, where “status” is a free-text field, two components update it, and the failure semantics are left to whoever writes the client.
Reveal: the contract review test
Ask the producer and consumer separately what success, timeout, duplicate, missing field, new field, and deleted user mean. Any different answer is already a production defect; the code has merely not executed it yet.
- In two sentences, explain to a new lead why two writers of one fact means the invariant has nowhere to live.
- Take one contract you own. Ask its producer and its consumer, separately, what a timeout means—then write down whether the two answers matched.
- Predict the failure: if nobody names the authoritative writer for a state, what does the reconciliation script look like in a quarter, and who ends up running it by hand each Monday?
Your win
You can design a system's durable promises: legal transitions, authoritative writers, safe retries, evolvable contracts, and purposeful data lifecycles.
Read and watch deeper
- Designing Data-Intensive Applications, Kleppmann — ch. 4 and 8: evolvable data, partial failure, and distributed-system ambiguity.
- Domain-Driven Design, Evans — entities, aggregates, repositories, and context boundaries as consistency tools.
- OWASP Threat Modeling Cheat Sheet — model data flows and trust boundaries early, then ask what can go wrong.
- Idempotent requests, Stripe API reference — a production contract that states key scope, retention, and what a repeated key returns.
- Reference: Initiative Playbook · state and contract review.
Ask your agent-teacher to generate adversarial sequences—duplicate, reorder, timeout, stale read, unauthorized identifier—then decide which your contract prevents or absorbs.
Source page: lessons/0045-data-state-and-contracts.html
Lesson 0046 · Initiative Lead · Module 2
Compare Options and Record Decisions
Senior judgment is not having the first answer faster. It is exposing credible alternatives, buying evidence where uncertainty matters, and leaving a decision future teammates can reconstruct.
Mission tie-in: explicit forces turn architecture from personal taste into organizational reasoning. Decision records make that reasoning durable without freezing it.
Knowledge: design it twice, decide once
For any consequential choice, produce at least two genuinely different designs. “Kafka” versus “managed Kafka” is one mechanism with two suppliers. For carrier failure, credible options are: fail the whole request synchronously; accept and retry from a local outbox; or route work to an agent queue. Compare them against the drivers from lesson 0043.
| Option | Buys | Costs | Evidence |
|---|---|---|---|
| Synchronous failure | least code; immediate truth | outage blocks outcome; poor recovery | fails availability scenario |
| Persist intent + worker | accepted request survives outage | pending state; retries; operations | fits recovery and latency scenarios |
| Manual agent fallback | fastest pilot; human judgment | queue capacity; slower response | viable below 50 cases/day |
Designing it twice works because the first design is a product of availability, not of search. It is the nearest solution in your memory, and it arrives already feeling correct—which is precisely the problem, since the criteria that made it feel correct were never spoken. A second, genuinely different design forces those criteria into words, because you now have to say why one beats the other. Comparison is the only operation that reliably converts tacit judgement into an argument other people can check. This is also why two options from the same mechanism family teach nothing: they share the hidden assumptions, so comparing them confirms rather than tests.
Decision records work on a different mechanism: what is expensive to reconstruct later is not the choice but the rejected alternatives and the facts that were true at the time. Without those, the next lead either re-litigates a settled question or, worse, treats an obsolete decision as sacred because they cannot tell which of its premises expired. An ADR is a cheap way of making a decision falsifiable in the future.
Both practices degrade under their own success. Design-it-twice on a cheap, reversible, local choice is theatre that trains people to perform deliberation. An ADR that becomes a gate—required before merge, approved by a board—converts a twenty-minute memory aid into a queue, and teams respond rationally by making decisions without writing them down. Apparatus should scale with reversibility, and nothing else.
Use a spike only when evidence could change the decision. A spike has one question, a timebox, a result format, and a discard rule: “In four hours, measure carrier label p95 and duplicate behavior under timeout using the sandbox; publish numbers and throw away the harness.” “Explore the carrier API” is an unbounded implementation prelude.
Match decision apparatus to reversibility. A local class shape can be changed in a pull request. A public contract, data ownership boundary, expensive vendor, or privacy model deserves broader advice and a written record. Review with the people who carry the consequences: product for outcome, design for experience, security/privacy for trust, operations for failure, and affected teams for contracts.
Use an Architecture Decision Record (ADR) for one decision:
- Status
- Accepted · 12 September · owner: Returns team
- Context
- Carrier outages must not lose eligible requests; peak 40 rps; pilot team owns one deployable.
- Forces
- Availability scenario, 800 ms decision target, operational simplicity, no duplicate labels.
- Options
- Synchronous failure; local intent + worker; manual-only fallback.
- Decision
- Store intent transactionally, process from worker, make carrier call idempotent, expose pending.
- Consequences
- New worker and retry visibility; customer can continue; on-call owns queue health.
- Revisit when
- Volume exceeds worker capacity, carrier offers durable async API, or queue SLO misses twice.
Keep ADRs immutable as historical facts. If the decision changes, write a new one that supersedes it. A one-page record written near decision time beats a forty-page design document updated after everyone has forgotten the disagreement.
Name the decider before review. Advice should improve a decision, not create consensus veto. Record meaningful dissent and the evidence that would prove it right. Psychological safety and technical rigor meet here: a concern can survive even when its preferred option does not.
How the lead actually decided it
Week 7. The carrier-failure decision has been circling for four days in a chat thread. The lead's first move is not to open the carrier documentation; it is to reread the availability scenario from lesson 0043, because a decision without a driver is a preference contest. Then she asks the question the thread had never asked: what does the customer see between “accepted” and “label ready”? That question, not the technology, is what eliminates one option and creates another.
Synchronous failure is attractive and is rejected on evidence, not taste: it is roughly forty fewer lines, no worker, no queue to operate—but the availability scenario requires accepting eligible requests through a thirty-minute carrier outage at peak, and this design cannot. Manual-only fallback is the second rejection, and the arithmetic does it: the manual queue already runs near 46 of its 50 daily cases from ordinary policy exceptions, so a peak outage would push it straight through the guardrail.
The spike is scoped to one question and four hours: measure carrier label latency and duplicate behavior under timeout in the sandbox, publish the numbers, throw away the harness. It returns p95 1.9 s, p99 6.4 s, and duplicate labels in 12% of naive retries. Note which decision that evidence actually changed. It did not choose the outbox—the availability scenario had already done that. It chose the idempotency key, which nobody had been arguing about.
Tomas dissents, and his objection is the best one in the room: the worker adds an on-call surface for a failure mode nobody has yet seen. The lead does not talk him out of it. She writes it into the ADR and converts it into the revisit trigger—if the queue misses its service objective twice, or the carrier ships a durable async API, the decision reopens. Dissent that survives in writing is what makes it safe to disagree the next time.
Week 12 supplies the consequence. The carrier goes down for twenty-two minutes during scheduled maintenance. Sixty-one requests are accepted, sixty-one labels are created after recovery, and no duplicates appear. The honest cost is on the same page: the pending state generated four support contacts from customers who did not understand what “label pending” meant—a comprehension problem the design created and the copy had to solve.
| Smell | Underlying failure | Move |
|---|---|---|
| “Everyone agreed.” | Dissent or decider disappeared. | Name both explicitly. |
| spike becomes production | No discard rule existed. | Define evidence, timebox, disposal. |
| ADR describes only choice | Future readers cannot reconstruct why. | Record context, forces, consequences. |
| every change needs review board | Apparatus ignores reversibility. | Set decision thresholds. |
- Two options means two vendors.
- Self-hosted Kafka versus managed Kafka is one mechanism with two invoices. Genuinely different options differ in where the state lives and what fails—which is the only axis on which the comparison can teach you something.
- An ADR documents what we built.
- It records what was decided and why, at decision time. Written afterwards it becomes archaeology, and it quietly omits the alternative that nearly won—which is the single most useful thing in the document.
- ADRs need approval.
- The record is the residue of a decision, not a gate on it. Add approval and you have built a queue; teams then decide in chat and write the ADR to match, which is worse than not writing one.
- A spike is a prototype.
- A spike answers one question and is deleted; a prototype is kept and shown. A spike with no discard rule has a strong tendency to become the foundation of production, complete with the shortcuts you took because it was going to be thrown away.
When this is the wrong move
Do not write an ADR for a choice that is cheap, local, and reversible in a pull request; the code and its history already record it, and a log full of trivia trains people to stop reading the log. Do not run a spike when the evidence cannot change the decision—if a hard constraint means you will do X regardless, buying a number is procrastination with a timebox. And when a constraint genuinely leaves one option, record the constraint rather than staging a comparison; a fabricated alternative in a decision table is a tell that the decision was made elsewhere.
Skill: spend evidence where it changes decisions
A useful technical spike must have:
Question, timebox, evidence format
Production quality, reusable abstractions
Detailed backlog, complete estimate
Team consensus, polished code
Correct. A spike exists to answer one decision-changing question inside a fixed cost boundary.
Which decision most deserves an ADR?
Rename one private method
Move ownership across contexts
Pick a retry timeout
Apply the code formatter
Correct. State ownership and a public event become durable promises with broad future consequences.
Why record a revisit trigger?
To make decisions permanent
To prevent future discussion
To reopen with evidence
To satisfy audit paperwork
Correct. The trigger states which changed evidence makes reopening the decision worth its coordination cost.
Practice: write one decision, not one design epic
Pick an initiative choice that would be expensive to reverse. Draft two meaningfully different options, compare them against named drivers, and identify the largest unknown. If evidence can change the choice, write a timeboxed spike. Then write the one-page ADR, including dissent and a measurable revisit trigger.
In the lab and the capstone. The lab's phase-4 checkpoint asks for an ADR covering one decision you actually make in the lab code—with its rejected option and a revisit trigger you could test. The worked artifact is the option comparison and ADR in the strong capstone packet, where the rejected options are the ones a reasonable team would have chosen. Compare with the weak version: one option, a decision stated as a conclusion, no forces, no dissent, and nothing that could ever reopen it.
Reveal: the ADR compression test
A teammate arriving in six months should be able to answer: what was true, what mattered, what alternatives were real, who decided, what was chosen, what price the team accepted, and which changed fact should reopen it. Everything else can be linked rather than copied.
- In two sentences, explain to a new lead why a second option is worth an hour even when everyone already agrees on the first.
- Take a decision your team made this month. Write its revisit trigger as a measurable condition—and check whether that condition is already true.
- Predict the failure: if a team records no decisions for a quarter, what happens the first time a new engineer proposes replacing something that was chosen deliberately?
Your win
You can compare real options, cap uncertainty with targeted spikes, scale review to reversibility, and preserve architectural reasoning in a decision record people will read.
Read and watch deeper
- A Philosophy of Software Design, Ousterhout — ch. 11: design it twice.
- Architecture Decision Record, Fowler — concise decision, context, ramifications, advice, and superseding records.
- Software Architecture: The Hard Parts, Ford et al. — trade-off analysis and architecture decision records.
- Documenting Architecture Decisions, Michael Nygard — the original ADR format: context, decision, status, consequences.
- Reference: Initiative Playbook · option, spike, and ADR templates.
Give the ADR and driver set to your agent-teacher and ask it to steelman the rejected option, find an unstated consequence, and propose a precise revisit trigger.
Source page: lessons/0046-options-spikes-and-decision-records.html
Lesson 0047 · Initiative Lead · Module 3
Plan the Walking Skeleton
A delivery plan should retire uncertainty, not arrange component tasks. Connect the real path early, then grow behavior through thin, demonstrable slices.
Mission tie-in: execution is where architecture meets sequence. The order of work determines how long assumptions can hide and how expensive integration becomes.
Knowledge: prove the path before filling it in
A walking skeleton is the thinnest production-shaped implementation of an end-to-end path: code enters the real build, deploys through the real pipeline, crosses the chosen boundaries, touches representative storage and integration seams, emits telemetry, and can be exercised by the team. Its business behavior may be deliberately tiny; its delivery path is real.
For returns, the skeleton lets an internal test customer submit one hard-coded eligible item, stores a return intent, calls a fake carrier adapter through the real port, shows pending label, and records a trace and outcome event in a production-like environment. It does not yet contain the full policy engine, all designs, or a real customer rollout.
A walking skeleton works because it front-loads the discovery of environmental facts, and environmental facts are the ones estimates are worst at. An estimate is assembled from imagined work: the classes, the screens, the rules. It cannot contain the release train that runs twice a week, the sandbox that only answers an allow-listed IP, the migration that takes a lock, or the credential that requires a nine-day ticket. None of that is hard; all of it is invisible until something real travels the path. The skeleton converts unknown environmental cost into a measured cost in week one, while the plan can still absorb it.
Its second effect is habit. Batch size dominates delivery risk, and a path that has carried one change can carry a hundred; a path that has never carried anything will produce its first surprise at the worst possible moment, which is the day the deadline depends on it.
The boundary is straightforward: when the path already exists, the skeleton is redundant. Adding a feature to a live system with a working pipeline does not need one—the equivalent move is the thinnest slice through the new boundary only. And a skeleton that runs on a fake path proves nothing. If it deploys from a laptop script into an environment nobody else uses, you have measured the laptop.
Sequence the delivery map by four forces:
- Learning: put the highest-impact weak assumption where the team can test it early.
- Technical risk: exercise the unfamiliar integration, migration, load, or security boundary before easy screens fill the calendar.
- Dependency: obtain sandbox access, legal decisions, data contracts, and operational ownership before they block a finished feature.
- Value: keep each increment coherent enough to demonstrate in user language and release to a controlled cohort.
| Milestone | Evidence produced | Exit condition |
|---|---|---|
| Path walks | one request deploys, persists, crosses adapter, and traces | repeatable from clean build; failure visible |
| Policy works | sampled rules pass executable examples | domain owner accepts decisions and reasons |
| Team uses it | internal users complete accessible happy and refusal paths | no severity-one usability or security issue |
| Pilot learns | 50 real users expose outcome and guardrail measures | pre-agreed continue, change, or stop threshold |
| Scale earns | load, recovery, support, and ownership are proven | rollout gates from lesson 0049 pass |
Map external dependencies separately with an owner, needed-by date, evidence of readiness, fallback, and escalation point. “Waiting on platform” is not a plan. “Asha owns sandbox credentials by Tuesday; fake adapter lets skeleton proceed; escalate to platform lead Wednesday” is.
Forecast ranges from throughput and uncertainty; do not convert a feature list into false precision. State what is fixed—outcome, date, scope, or team—and which variable can move. For a date-bound pilot with a fixed team, scope is the control surface. Reforecast when evidence changes, not only when the original date becomes impossible.
Keep technical enabling work attached to the slice it enables. A small schema migration, contract test, alert, and adapter are part of “eligible customer reaches pending label.” A free-floating “architecture runway” becomes infrastructure nobody has yet earned.
How the lead actually sequenced it
Week 2, before the story map is finished. The lead proposes spending the first two engineer-weeks on a skeleton that returns a hard-coded approved and a fake carrier label. The question she used to justify it was not “what is most valuable?” but what in this plan have we never done before? Two answers came back: deploying into the order platform's release train, and getting a carrier sandbox credential. Neither is a feature. Both are on the critical path of every feature.
Marta, the product manager, objects, and her objection is legitimate: two weeks with no user value, against a 30 September pilot date, is not obviously affordable. The lead does not win the argument by asserting engineering judgement. She reframes the spend—the skeleton is the pilot date's insurance—and then buys the objection off with a constraint: eight working days, with a stop rule that if the path is not walking by day eight, the sequencing problem is escalated rather than absorbed.
The alternative she rejects is the comfortable one: design the policy engine while platform access is arranged in the background. It feels efficient and it is exactly wrong, because it lets the unknown wait while the known progresses. Uncertainty does not shrink while you ignore it; it just arrives later, with more work stacked behind it.
Day 3 returned the first fact: the order platform's release train runs twice a week, not on demand. That single number changed the rollout stage durations in lesson 0049—a stage gate that assumes same-day reversal is a fiction when the next train is Thursday—and it is why the team invested in a configuration flag that can be flipped without a deploy. Day 6 returned the second: the carrier sandbox requires an allow-listed egress IP, a nine-day platform ticket. Discovered in week 2, it cost nothing; discovered in week 8, when the eligibility engine was ready and waiting for a real label, it would have cost the pilot date.
| Planning smell | What it delays | Replace with |
|---|---|---|
| backend sprint then frontend sprint | Integration and usability evidence. | One end-to-end skeleton. |
| “carrier integration: 80%” | The unknown last behavior. | Demonstrable exit condition. |
| platform dependency, no owner | Escalation until too late. | Owner, date, fallback, trigger. |
| six-week infrastructure phase | Whether users value the path. | Enabling work inside slices. |
- The walking skeleton is a prototype.
- A prototype is thrown away; a skeleton is grown. Its code is production code with almost nothing in it, which is why it must go through the real build, the real pipeline, and the real boundaries.
- A skeleton is “hello world in production”.
- It must cross the boundaries you actually chose and emit the telemetry you will actually use. A page that returns 200 from a new service proves the deploy button works and nothing else.
- Risk-first means doing the hardest thing first.
- It means doing the most uncertain thing first. Hard-but-known work is schedule and can be planned around; uncertain work is risk and cannot. Teams routinely sequence the difficult, familiar work early and feel productive while the unknown waits.
- A forecast range is hedging.
- A single date is the hedge: it conceals variance the team already knows about and removes the sponsor's chance to trade scope while trading is still possible. A range with a named cause is the more accountable answer, not the softer one.
When this is the wrong move
Skip the skeleton when the path is already proven and unchanged—an existing service with a working pipeline, where the new work crosses no new boundary. Skip it when an identical environment already exists next door: copy the pipeline and spend the time on the boundary that is genuinely new. And notice the case that looks like a reason to skip it but is not: if the skeleton would take six weeks because the platform work does not exist, that is not evidence the skeleton is too expensive. It is evidence that the platform work is the initiative, and the honest move is to say so before the plan is built on the assumption that it is free.
Skill: sequence for evidence
The walking skeleton should first prove:
Every business rule works
The end-to-end path works
Peak production load works
Every design state works
Correct. The skeleton establishes that a real change can travel through build, deployment, boundaries, and observation.
Which milestone reports useful progress?
Integration is eighty percent
Forty tasks are closed
One traced request deploys
Everyone remains fully utilized
Correct. A repeatable deployed workflow is concrete evidence and exposes the remaining failure surface.
For a fixed-date, fixed-team pilot, vary:
The required correctness level
The intended customer outcome
The first release scope
The available team size
Correct. Cohorts, variations, and automation depth can shrink while preserving a coherent learning slice.
Practice: redraw the plan around proof
Take an initiative plan organized by components or epics. Define its walking skeleton in one sentence. Replace percentage milestones with five evidence milestones. For each external dependency, add owner, needed-by, readiness evidence, fallback, and escalation trigger. State which planning variable can move.
In the lab and the capstone. The lab's phase-4 checkpoint has you walk one request through the lab system end to end and prove it with telemetry rather than a screenshot. The worked artifact is the walking skeleton and delivery map in the strong capstone packet, where every milestone names the evidence it produces and every dependency has an owner, a date, and a fallback. Compare with the weak version: phases named after components, percentage-complete milestones, and a dependency listed as “waiting on platform”.
Reveal: a useful skeleton sentence
“From the main branch, deploy one internal-user return request through the production pipeline; persist its intent, cross the carrier port with a fake, render pending status, and find the complete request by correlation id.” It is small, demonstrable, operationally real, and ready to receive behavior.
- In two sentences, explain to a new lead why the first thing to build is the thing with the least business value.
- Take your current plan and answer one question: what is in it that this team has never done before? Then say where that work currently sits in the sequence.
- Predict the failure: if a team defers integration to the final three weeks, what does week nine look like, and which dependency will they discover they needed a month earlier?
Your win
You can build a delivery map that integrates early, retires the largest risks first, makes dependencies actionable, and reports progress as working evidence.
Read and watch deeper
- Growing Object-Oriented Software, Guided by Tests, Freeman & Pryce — ch. 4: the walking skeleton and end-to-end feedback.
- User Story Mapping, Jeff Patton — release slices, learning strategy, and development strategy.
- Accelerate, Forsgren, Humble & Kim — ch. 4: small batches, deployment capability, and delivery performance.
- Crystal Clear, Alistair Cockburn — the origin of the walking skeleton as a delivery strategy rather than a prototype.
- Reference: Initiative Playbook · skeleton, milestone, and dependency map.
Ask your agent-teacher to challenge every milestone with “what can we now demonstrate or decide that we could not before?”
Source page: lessons/0047-walking-skeleton-and-delivery-map.html
Lesson 0048 · Initiative Lead · Module 3
Run a Small Cross-Functional Initiative Team
A 6–8 person team moves quickly when ownership is shared and decisions are explicit—not when eight specialists each maximize their own queue.
Mission tie-in: team structure is part of system design. The communication paths, decision rights, and ownership model become boundaries and coupling in the software.
Knowledge: one team, distinct accountabilities
A useful initiative cell might be a product manager, product designer, tech lead, and four or five engineers. Testing, data, security, operations, content, and domain experts may be embedded or consulted. Titles vary; the accountabilities must not vanish:
| Accountability | Owns the final call on | Must seek advice from |
|---|---|---|
| Product | outcome, priority, cohort, scope trade | design, tech lead, data, domain, commercial |
| Design | experience coherence, interaction, research method | users, product, engineering, accessibility |
| Tech lead | technical integrity, risk, architecture decisions | engineers, product, design, security, operations |
| Engineers | implementation decisions within agreed boundaries | peers and affected owners |
| Whole team | delivery forecast, quality bar, ways of working | stakeholders carrying consequences |
This is not three lanes handing work to one another. Product, design, and engineering discover together; design works close enough ahead to clarify the next slice, not a quarter ahead producing an inventory of stale screens. Engineers join research and mapping. Product and design join failure, scope, and rollout decisions. The tech lead makes technical work legible in outcome and risk language.
Named decision rights cut cycle time by changing the shape of the dependency graph. A decision with no owner defaults to whoever is most persistent or most senior, and the time to decide becomes a function of calendar availability rather than of information. Naming the decider converts a negotiation into a consultation: advice can be gathered in parallel, asynchronously, from people who never have to be in a room together, whereas consensus must be gathered serially and synchronously. That is the entire mechanism—a join becomes a fan-out. It is also why “who decides” is a kindness rather than a power grab: without it, the quiet expert's advice is worth less than the loud one's.
Low WIP works on arithmetic. Cycle time equals work in progress divided by throughput, so with five items in flight everything finishes roughly five times more slowly than with one. Feedback arrives per finished item, so a team running high WIP is buying less learning per week while feeling maximally busy. This is counterintuitive on purpose: idle people look expensive and idle work is invisible, which is why utilization wins arguments it should lose.
Both fail in identifiable conditions. Decision rights fail when the named decider lacks either the information or the authority to absorb the consequence—then you have named a scapegoat, and people learn to route around the table. WIP limits fail when the items are genuinely independent and blocked externally: six things each waiting on a different vendor are not a flow problem, and capping the board will not make the vendors answer. Fix the dependency map instead.
Optimize for flow, not utilization. Keep one or two slices in progress, swarm when one is blocked, and finish the end-to-end path before starting another. Assign a directly responsible individual (DRI) to move a workstream or decision—not to do all its work or own the code forever. Rotate module work with pairing and review so ownership stays collective and knowledge has redundancy.
| Cadence | Purpose | Artifact changed |
|---|---|---|
| daily, 10 min | flow, blockers, swarming—not status recitation | work board and dependency map |
| twice weekly, 30 min | shape next slice with examples and design | story map and examples |
| weekly, 30 min | review outcome evidence, risks, decisions, forecast | initiative review |
| weekly, 45 min | demo integrated behavior to users or stakeholders | feedback and decision log |
| as needed, timeboxed | resolve one design decision with required advisers | ADR or explicit no-decision |
| fortnightly, 45 min | improve team system and technical health | one owned experiment |
Use one visible source for each fact: initiative frame for why, story map for scope, delivery board for current flow, ADR log for decisions, risk/dependency map for uncertainty, dashboards for evidence. Do not reproduce the same status in slides, tickets, chat, and documents; duplication creates conflicting truth.
Escalate by trigger, not emotion. Examples: external dependency misses its needed-by date; a driver cannot be met inside the agreed scope; a decision crosses another team's ownership; forecast confidence falls below the agreed range. Escalation asks for a decision or resource, states options and consequences, and names the date. “Leadership awareness” is not an outcome.
How the lead actually intervened
Week 8. Five items in flight across four engineers, and every one of them is “nearly done”. The lead does not look at the count—counts always look reasonable. She looks at the age of the oldest item, which is where a board hides its real state. The accessibility fix for the refusal screen is nine days old. It is not blocked on engineering; it is waiting on a decision about how much of the policy reason to show, and nobody owns that decision.
The obvious intervention is a daily blocker-escalation meeting. She rejects it, and the reasoning is worth stealing: the problem is caused by a serial synchronization point, so adding another serial synchronization point to fix it makes the system worse in the same direction. Instead: publish the decision-rights table, cap work in progress at two slices, and add one default—any decision without an owner after twenty-four hours goes to the tech lead, who may hand it back with a name on it.
Ines, the designer, dissents immediately and correctly. A default of “the tech lead decides” will, over a quarter, erode experience coherence—the decisions that fall through are disproportionately the ones about how something feels. The lead does not dismiss it; she changes the table. Experience coherence is Ines's call, with the tech lead as a required adviser rather than a decider, and the twenty-four-hour default routes to whoever owns the category rather than always to engineering. The dissent improved the design of the team, which is what dissent is for.
Over the next four weeks, median slice age fell from nine days to four. What did not change is instructive: the team did not make more decisions per week. It made the same number, with far fewer of them waiting. That is the shape of most flow problems—not capacity, but queueing—and it is why adding a person in week eight would have made it worse.
| Signal | System problem | Intervention |
|---|---|---|
| eight items, each “90%” | Utilization outranks finishing. | Cap WIP; swarm one slice. |
| design handoff every Monday | Discovery and delivery are detached. | Shape examples together continuously. |
| tech lead approves every PR | Authority is a throughput bottleneck. | Publish guardrails; distribute review. |
| same blocker for three days | No owner or escalation trigger. | Name DRI, fallback, deadline. |
- Decision rights mean the tech lead decides technical things alone.
- The table says who closes a decision, not who thinks about it. Advice is mandatory, and a decider whose mind is never changed by advice has a consultation problem that the table will not fix.
- Low WIP means people will sit idle.
- Sometimes, briefly—and that is the cheap resource. Idle work is the expensive one: it ages, it goes stale, it hides risk, and it delays every piece of feedback behind it. Optimizing for busy people guarantees queues.
- Swarming is inefficient because of the coordination overhead.
- The overhead is real and it is being bought deliberately: you pay coordination cost to shorten the feedback loop on the one item that is blocking evidence. It is a bad default and an excellent intervention.
- A DRI owns the code.
- A DRI moves one outcome to a close. If that becomes permanent ownership of a module, you have manufactured a bus factor and removed the reason to rotate and pair.
When this is the wrong move
A formal decision-rights table is bureaucracy for three people who share a desk and can settle anything in a sentence; write it when the team grows, when someone joins, or the first time a decision goes unowned for a week—not before. The full cadence is waste for an initiative with one or two people, where the daily and the shaping session are the same conversation. Objective escalation triggers add nothing when the sponsor attends the standup. And the deepest counter-case: if the team's real problem is that the outcome is unclear, none of this machinery helps. It will produce a beautifully governed team delivering the wrong thing on time.
Skill: choose the team intervention
Four engineers each start a separate item. The lead should:
Add more ready items
Limit work and swarm
Extend the daily meeting
Assign individual delivery dates
Correct. A WIP limit and swarming move integrated evidence to done instead of keeping everyone locally busy.
A DRI is responsible for:
Completing all work alone
Moving one outcome forward
Owning that code forever
Assigning everyone else's tasks
Correct. The DRI ensures the decision or workstream advances, gathers advice, and closes its loop.
Product and design should work:
One quarter ahead separately
Together, slightly ahead continuously
Only after engineering finishes
Together only during planning
Correct. They continuously discover with engineering while clarifying the next small slice just in time.
Practice: design the team operating system
For a 6–8 person initiative, write decision rights for product, experience, architecture, implementation, quality, and rollout. Set a WIP limit. Name the six canonical artifacts and their owners. Define a one-week cadence and three objective escalation triggers. Delete any ceremony with no decision or artifact to change.
In the lab and the capstone. The lab's phase-4 checkpoint is deliberately larger than one sitting, so it is where a WIP limit and a named decider stop being theory: decide up front which decisions you will record and which you will simply make. The worked artifact is the team charter in the strong capstone packet, where each accountability names both the call it owns and the advice it must seek. Compare with the weak version: a list of roles, a full meeting calendar, and no statement anywhere of who decides when two of those roles disagree.
Reveal: the meeting test
Every recurring meeting should have an input people can inspect, a decision or learning purpose, a changed source of truth, and a shorter asynchronous alternative for weeks when no decision is needed. If its output is “everyone is informed,” a concise written update may be the deeper module.
- In two sentences, explain to a new lead why naming one decider makes a team more collaborative rather than less.
- Look at your team's board today. Find the oldest item, name what it is waiting for, and name who owns that decision—if nobody does, you have found this week's intervention.
- Predict the failure: if a team runs at maximum utilization for a quarter, what happens to the age of its oldest work item, and what will the retrospective blame instead?
Your win
You can establish clear decision rights, collaborative product-design-engineering work, low WIP, useful cadence, resilient ownership, and escalation before pressure improvises them.
Read and watch deeper
- Team Topologies, Skelton & Pais — stream-aligned teams, cognitive load, team APIs, and deliberate interaction modes.
- Accelerate, Forsgren, Humble & Kim — ch. 4, 7, and 8: continuous delivery practices, lean management, and lean product development.
- The Principles of Product Development Flow, Reinertsen — WIP, queues, batch size, and economic decision-making.
- Reference: Initiative Playbook · team charter and cadence.
Ask your agent-teacher to audit the team's calendar and artifact list for duplicated status, missing decision rights, and meetings without a changed output.
Source page: lessons/0048-running-the-cross-functional-team.html
Lesson 0049 · Initiative Lead · Module 3
Test, Observe, and Roll Out Safely
Done is not “merged,” and launch is not one event. A responsible design includes the evidence, controls, and ownership needed to learn in production without betting every user.
Mission tie-in: testability and operability are architecture qualities. If a team cannot see, limit, or reverse behavior, the system is not finished enough to release.
Knowledge: build a release as a controlled experiment
Test according to risk, not a universal pyramid quota. For returns:
- Domain examples prove policy rules, boundaries, refusal reasons, and state transitions quickly.
- Integration and contract tests prove persistence, order and payment contracts, carrier translation, timeout, and idempotency.
- A few end-to-end journeys prove wiring, accessibility, authorization, analytics, and deployment.
- Quality tests exercise the named drivers: p95 latency, carrier recovery, access isolation, data deletion, and alert response.
- Exploratory testing looks for surprising behavior the examples did not imagine.
Map telemetry to both outcome and system health. A green error dashboard does not prove returns resolve faster. Track resolution time and support-contact rate by cohort alongside request rate, decision/refusal distribution, carrier latency, label retries, stuck state age, queue depth, and manual workload. Segment enough to expose who is harmed, while protecting privacy and sample size.
| Stage | Entry | Watch / duration | Advance or reverse |
|---|---|---|---|
| team only | tests pass; support path exists | all flows; two days | no critical security or accessibility defect |
| staff accounts | runbook exercised; alerts route | errors, retries, comprehension; 50 cases | zero duplicates; p95 within target |
| 1% eligible | support briefed; rollback tested | outcome and guardrails; three days | improvement visible; fraud not worse |
| 10% then 50% | prior gate signed off | capacity, segments, manual queue | all thresholds hold at each stage |
| 100% | operational owner accepts service | SLO and outcome continuously | flag removal has owner and date |
Staged rollout is usually explained as a smaller blast radius, and that is the least interesting half. The real mechanism is that exposure makes the population a variable you control while everything else is held constant, so a difference in the measure can be attributed rather than argued about. Every release is a hypothesis test with an unknown effect size: the exposure percentage buys statistical power, and the duration buys the slow failure modes—memory growth, queue drift, nightly batches, weekly cycles—that an instant jump to 100% cannot reveal at any percentage. That is why a gate needs a minimum observation window as well as a threshold. A stage that advances in ten minutes because “it looks fine” has measured nothing but the deploy.
Separating deployment from release works on a second mechanism: it decouples code risk from behavior risk. The code goes out cold on the release train; the behavior is enabled by configuration. Reversal then costs seconds instead of a deployment, which matters concretely here—the order platform's train runs twice a week, so a code rollback was never a real recovery plan.
Canaries stop earning their cost when the population is too small to move the measure. At the pilot's 160 requests a day, a 1% stage is under two requests a day. That is why the staff stage above is sized in cases rather than percent, and why the 1% stage carries a three-day floor and gates on things a handful of cases can still falsify—a duplicate label, a fraud signal, a refusal nobody understands—rather than on a median that needs volume to mean anything. They also stop applying when the change has no user-visible surface and no runtime cost, and when partial exposure is itself the hazard—a data migration, a price change that must be simultaneous, a legal cutover with a fixed date.
Separate deployment from release. Deploy dormant behavior behind a short-lived release flag, validate it internally, then expose cohorts gradually. The flag needs an owner, removal date, safe off-state, and tests for the live and fallback configurations. Flags reduce release risk while adding state-space and cleanup cost.
Define rollback before rollout: who decides, what signal triggers it, how long it takes, what happens to data already written, and how customers in flight recover. A code rollback cannot undo an email sent, refund issued, or schema contracted. Some effects need roll-forward or compensation.
Operational readiness is part of “done”: dashboards, actionable alerts, structured events with correlation ids, runbook, support script, ownership/on-call, capacity assumption, data recovery, known failure states, and an exercised disable path. Run a 30-minute failure drill before the pilot: carrier returns timeouts, worker backlog grows, one request is duplicated. Can the team detect, diagnose, limit, recover, and explain?
How the lead actually ran the drill
Week 11, thirty minutes, whole team, one scenario: at 14:05 the carrier starts returning timeouts. The lead's opening question is not about dashboards. It is who turns this off, and how long is it between noticing and off? Everything else in the drill is downstream of that sentence.
The alert fires at 14:11—label failures above 2% for five minutes, as designed. Dana finds it. The disable is a configuration flag, ninety seconds, no deploy. So far the plan holds. Then the drill produces its actual finding, which nobody had anticipated on paper: sixty-one requests have already been accepted with labels pending, and turning off label creation does not un-accept them. The customers who are mid-journey are now in a state the “off” switch does not address.
Karl proposes the simplest fix and is nearly right: a boolean kill switch, off means off. The lead rejects it because the boolean answers the wrong question—it protects the system and abandons the sixty-one people. The flag gets three positions instead of two: on, accept but hold (keep taking eligible requests, queue the label work, tell the customer honestly), and refuse new (stop accepting, drain what exists). Karl's objection to that is legitimate and is answered rather than overruled: three states do not triple the test matrix if you test each state's contract rather than every combination of state and path.
The consequence lands in week 12, during the real twenty-two-minute carrier outage. The team used accept but hold. No requests were lost, the outbox drained after recovery, and the honest cost was four support contacts from customers who did not understand the pending state. A boolean switch would have refused sixty-one eligible customers to protect a metric—and the dashboard would have looked better for it, which is exactly the trap a drill exists to find.
| Claim | What is absent | Required evidence |
|---|---|---|
| “Tests are green.” | Production behavior and outcome. | Telemetry plus staged exposure. |
| “We can roll back.” | Data and external side effects. | Rehearsed reversal or compensation. |
| “There is a dashboard.” | An owner and decision threshold. | Actionable alert and runbook. |
| “The flag makes it safe.” | Fallback tests and deletion. | Owner, off-state, removal date. |
- A canary means one percent.
- A canary is a population small enough to limit harm and large enough to measure. At low volume that is a case count and a duration; a percentage chosen because it is conventional will either expose nobody or teach nothing.
- Rollback undoes the release.
- It undoes the code. Emails sent, refunds issued, rows written, schemas contracted and vendor calls made are not undone. The reversal plan has to name each irreversible effect and say whether it is compensated, rolled forward, or accepted.
- Observability means dashboards.
- A dashboard nobody is required to look at is decoration. The unit that changes behavior is an alert with a threshold, an owner, and a documented response—plus the ability to answer a question you had not thought of before the incident.
- More tests make a release safer.
- Coverage of the wrong risk buys expensive confidence. The only useful question about a test is which failure it would have caught; a suite that cannot answer that for its top three risks is long, not strong.
When this is the wrong move
A staged rollout is overkill for an internal tool with twelve users: tell the twelve, ship to all of them, watch, and revert—the coordination cost of stages exceeds the harm they would prevent. It is impossible, not merely expensive, for a simultaneous cutover where two populations on different behavior would be the incident. A failure drill for a change with no external dependency and no persistent side effect is theatre; run one when there is a dependency that can fail, a state that survives the failure, or an authority question nobody has answered. And a feature flag on a change that will be fully rolled out within a day is usually a net cost—you pay for the state space, the tests, and the removal, and buy back very little.
Skill: design the evidence and control
A healthy error rate proves:
The customer outcome improved
One system guardrail holds
The rollout is complete
The deployment finished cleanly
Correct. The measure supports one claim about observed system health, within its coverage and segmentation.
A rollout stage needs which four things?
Entry, observation, exit, reversal
Scope, estimate, owner, deadline
Demo, retro, ticket, approval
Flag, dashboard, test, deploy
Correct. A controlled stage defines readiness, what is watched, the decision gate, and a safe response.
Why rehearse rollback before release?
To guarantee zero failure
To complete release paperwork
To expose recovery gaps
To avoid staged rollout
Correct. Practice exposes missing access, unsafe data effects, unclear authority, and unrealistic recovery time while stakes are low.
Practice: write the rollout contract
Choose one upcoming release. Map tests to its top risks and architecture drivers. Define outcome, guardrail, and health telemetry with owners. Write exposure stages with entry, minimum observation, advance threshold, stop threshold, and reversal. Rehearse one dependency failure and one data-side-effect rollback.
In the lab and the capstone. The lab's phase-4 checkpoint asks you to add outcome telemetry to the lab system and then express one rollout gate as an automated test, so “are we allowed to advance?” has an answer that does not depend on who is in the room. The worked artifact is the rollout contract in the strong capstone packet—stages in case counts, a stop threshold beside every advance threshold, and a named reversal for each irreversible effect. Compare with the weak version: a launch date, a percentage ramp with no observation window, and “roll back if there are problems”.
Reveal: the “done” test
The slice is done when it can be deployed, observed, supported, limited, and recovered—not only used on the happy path. The team can name who watches it, which threshold changes exposure, and what happens to every in-flight state if behavior is disabled.
- In two sentences, explain to a new lead why a rollout stage needs a minimum duration and not only a threshold.
- Take your next release. Name its irreversible effects, and say for each one whether the plan compensates, rolls forward, or simply accepts it.
- Predict the failure: if a team ships to 100% behind green dashboards, what is the first thing a customer notices that no alert covers, and how long until anyone connects the two?
Your win
You can build risk-shaped tests, pair product outcomes with operational telemetry, and release through explicit gates with a rehearsed response to failure.
Read and watch deeper
- Canarying Releases, Google SRE Workbook — partial, time-limited exposure evaluated against control.
- Feature Toggles, Pete Hodgson — release versus deploy, flag categories, testing, and carrying cost.
- Release It!, Nygard — stability, production readiness, and designing for operational reality.
- Service Level Objectives, Google SRE Book — choosing indicators and targets that a gate can actually be written against.
- Reference: Initiative Playbook · readiness and rollout contract.
Ask your agent-teacher to run a tabletop incident against the plan; make it track customer impact, data side effects, authority, and recovery rather than only server errors.
Source page: lessons/0049-testing-observability-and-safe-rollout.html
Lesson 0050 · Initiative Lead · Module 3
Steer, Replan, and Close the Initiative
Leadership is keeping the truth cheap to see. Review evidence while options remain, change the plan without disguising the change, and finish the operational and organizational work after launch.
Mission tie-in: a system is successful when it changes the intended outcome and remains responsibly owned. Shipping is a transition in that lifecycle, not the finish line.
Knowledge: run one evidence loop
Use a weekly initiative review built from live sources, not a status performance. Product, design, and engineering answer seven questions:
- Outcome: what does the latest measure say, by relevant cohort?
- Learning: which assumption became stronger, weaker, or false?
- Delivered evidence: what integrated behavior can now be demonstrated?
- Flow: what is aging or blocked, and where should the team swarm?
- Risk and quality: which driver, guardrail, dependency, or operational signal changed?
- Decisions: what was decided, what is waiting, and who owns the next evidence?
- Forecast: what range is credible now, and what scope trade preserves the outcome?
- Outcome
- Median 3.2 hours for eligible cohort; support contacts down 18% versus 25% target.
- Guardrails
- Fraud unchanged; manual queue 34/day under limit; mobile completion is 11 points lower.
- Learned
- Status visibility drives fewer contacts; label speed is not the limiting factor.
- Changed
- Move mobile comprehension work ahead of exchange automation; keep carrier design.
- Risk
- Guest orders are 29% of demand and remain outside pilot; product decision due Friday.
- Forecast
- Core pilot outcome likely in two weeks; exchange variation removed from this initiative.
- Ask
- Approve two design sessions with customer-support participants; no staffing change.
A weekly evidence loop is not about discipline; it is about preserving options. The cost of changing a plan grows with the work already committed to the old one, while the value of information is highest when the most options remain. A week is not sacred—it is chosen because it is short enough that at most one week of work has been committed against a belief that turns out to be false, and long enough that at least one slice's evidence has actually arrived. Pick the interval from those two quantities and you will sometimes get three days and sometimes get a fortnight.
Pre-agreed continue/change/stop criteria work on a different mechanism: they remove the identity cost of stopping. A decision to stop made in advance is a policy; the same decision made in the moment is a verdict on the people who proposed the work. That asymmetry, not incompetence, is why teams that never pre-commit almost never stop—and why the criteria have to be written while everyone still expects to succeed.
The loop degrades into theatre in two conditions. When the outcome measure cannot move weekly—a quarterly conversion figure, a retention curve—steering on it produces noise interpreted as signal; name a leading indicator, and label it explicitly as a proxy so nobody mistakes it for the outcome. And when the review has no authority to change scope, it is a status meeting wearing a review's clothes. If nothing on the page can be changed by the people reading it, delete the page and send an email.
Make a change explicit across all affected artifacts. If guest checkout enters scope, update the frame, story map, threats, contracts, telemetry, rollout, and forecast. “We are still on plan” after the plan changed is how teams exchange trust for temporary comfort.
Pre-agree continue, change, and stop criteria. Continue when outcome movement and guardrails support the hypothesis. Change the solution or slice when the opportunity remains but evidence rejects the mechanism. Stop when the opportunity is too small, the intervention cannot move it economically, a guardrail cannot be protected, or strategy has changed. Stopping after cheap evidence is capital allocation, not team failure.
After release, close the initiative deliberately:
- Transfer service ownership, on-call, runbooks, access, dashboards, support knowledge, and vendor responsibility to a named steady-state owner.
- Remove release flags, temporary adapters, manual pilot paths, obsolete metrics, elevated access, and test data—or record each as debt with a trigger from lesson 0038.
- Supersede ADRs whose context changed; update context/container views, contract docs, and architecture fitness functions.
- Report the outcome against baseline and guardrails, including segments that did not benefit and costs that moved elsewhere.
- Hold a learning review: expected, observed, surprised, decision for next time. Turn at most one or two insights into owned system changes.
A retrospective about feelings can improve teamwork; an initiative review must also improve the organization's memory. Preserve decisions and evidence, not a victory narrative. Future leads need to know which assumptions failed and which shortcuts still charge interest.
How the lead actually read week four
The numbers above are good enough to be dangerous. Median 3.2 hours beats the under-four-hour target. Support contacts are down 18% against a 25% target, which invites the most comfortable reading available: nearly there, keep going, it will close.
The lead does not look at the headline first; she looks at the guardrails, because that is where a working system hides its casualties. Mobile completion is eleven points below desktop. So she asks the question that separates a lead from a reporter: is the 18% a smaller version of the same effect, or a different effect? Splitting the contact reduction by device answers it—desktop is down 27%, mobile down 6%. The mechanism works. The surface does not. Averaged together, those two facts look like partial success; separated, they are one success and one defect, and they need opposite responses.
The obvious move is to add a comprehension workstream alongside the exchange automation already in flight. The lead rejects it on WIP grounds: two parallel bets on a seven-person team means neither produces clean evidence, and the team has just demonstrated how easily two effects hide inside one number. Exchange automation is dropped from the initiative and recorded as a non-goal with a revisit trigger, not quietly deprioritized where it can drift back.
Marta dissents, for a real reason rather than a bad one: the exchange slice was promised to a stakeholder. The lead does not overrule the relationship; she makes the trade explicit in the “Ask” line of the review, so the sponsor chooses between the promise and the outcome with the same information the team has. That is the honest version of scope negotiation—the alternative is deciding on the sponsor's behalf and calling it protecting them.
Two weeks later, support contacts are down 24% with no new mechanism at all, only mobile comprehension work: better refusal copy, a status page that survives a small screen, and one fewer step before the label. The exchange slice never came back, and nobody asked for it—which is itself evidence about the original scope, and belongs in the learning review rather than in a quiet sense of relief.
| Pressure response | Damage | Leadership move |
|---|---|---|
| hide forecast until certain | Options disappear while confidence grows. | Share range, cause, options early. |
| add people late | Coordination load rises immediately. | Trade scope; remove blockers. |
| declare launch a success | Outcome and cleanup become orphaned. | Keep review through stabilization. |
| skip the closeout | Temporary machinery becomes permanent. | Assign removal and ownership dates. |
- Stopping means the initiative failed.
- The failure is spending the remaining budget after the evidence has arrived. Stopping cheaply is the return on discovery—and a team that has stopped one thing well is trusted with larger bets, not smaller ones.
- The weekly review is for stakeholders.
- It is the team's steering instrument. If the only thing it ever changes is a slide, it has become reporting, and reporting is cheaper to do asynchronously.
- Closeout is documentation.
- Closeout is the transfer of ownership and the removal of machinery that only made sense during the pilot: the flag, the manual queue tooling, the fake adapter, the elevated support access. Documentation is the residue of that work, not the work.
- A green outcome metric closes the initiative.
- Not until someone else can operate the service at three in the morning, and every temporary path is either gone or on a dated ledger with an owner. Otherwise the initiative has not ended; it has just stopped being anyone's job.
When this is the wrong move
A weekly review is overhead on a two-week effort—one mid-point check is the whole loop. A formal closeout is waste when nothing temporary was built and the team that built it is the team that will run it; in that case the only real item is the debt ledger. Pre-agreed stop criteria are meaningless when the initiative is a legal or regulatory mandate: the variable there is scope and sequence, not existence, so write change criteria and skip the stop column rather than pretending the option exists. And a learning review held while the outcome is still moving will mostly capture relief; wait for the number to settle, then hold it while people still remember what they believed.
Skill: steer with evidence
The solution fails but the opportunity remains. Choose:
Continue the original solution
Change the tested intervention
Immediately double team size
Stop all related discovery
Correct. Preserve the validated opportunity and change the mechanism or slice that evidence rejected.
When should forecast risk be shared?
When new evidence changes confidence
After the miss becomes certain
Only when key stakeholders request
During the final team retrospective
Correct. Early ranges and causes preserve scope, dependency, and sequencing options for the team and sponsors.
An initiative is operationally closed when:
The final change merges
Marketing announces the launch
Ownership and cleanup transfer
The team starts another
Correct. Closure requires a steady-state owner, completed or recorded temporary work, and outcome evidence.
Capstone: lead a simulated initiative
Choose a real or plausible product brief. In a 90-minute working session, produce the complete leadership packet: initiative frame, assumption map, current workflow, story-map slice, five architecture drivers, three-view architecture packet, one state/contract review, one ADR, walking skeleton, delivery/dependency map, team charter, and rollout contract. Then write the week-four review after inventing one adverse result. The templates live in the Initiative Playbook.
In the lab and the capstone. The lab's phase-4 checkpoint ends the way an initiative does: the telemetry you added has to say something, the rollout gate has to pass or fail on its own, and the temporary scaffolding has to be removed or written down. The worked artifacts are the week-four review and the closeout in the strong capstone packet—note that the review changes the plan on its own page rather than announcing that everything is on track. Compare with the weak version's review and closeout: a status summary with no segment, no ask, no dissent, and a closeout that transfers nothing.
Reveal: how to review the capstone
Trace one line through every artifact: outcome → assumption → user slice → architecture driver → boundary/contract → decision → delivery milestone → test/telemetry → rollout gate → continue/change/stop criterion. Any broken link is either unnecessary work or an unmanaged risk. A strong packet is coherent, not large.
- In two sentences, explain to a new lead why stop criteria have to be written before anyone believes they will be needed.
- Take the last initiative you were on. Name the week the evidence was already sufficient to change the plan, and name what was actually done that week instead.
- Predict the failure: if a team never closes out, what is still running in a quarter, who is quietly maintaining it, and what does the next initiative inherit?
Your win
You can lead a new initiative from ambiguous brief through discovery, system design, team execution, controlled release, evidence-based steering, and responsible operational closure.
Read and watch deeper
- Thinking in Systems, Donella Meadows — feedback, delays, and why local delivery measures can misrepresent system outcomes.
- Accelerate, Forsgren, Humble & Kim — delivery performance and continuous improvement grounded in evidence.
- The DevOps Handbook, Kim et al. — feedback, telemetry, learning, and operational ownership across the value stream.
- Escaping the Build Trap, Melissa Perri — ch. 1–4 and 13: outcome over output, and why organizations keep shipping after the evidence has arrived.
- Reference: Initiative Playbook · review and closeout, and the debt ledger.
Use your agent-teacher as a hostile reviewer of artifact coherence: it should ask what evidence connects each decision to the outcome, never manufacture the evidence itself.
Source page: lessons/0050-steering-and-closing-the-initiative.html
Lesson 0051 · System Architect · Module 1
Model the Problem Before the System
Architecture is downstream of a business question: what actually matters here, and where does the valuable complexity live? Classify the domain before you draw a box.
Mission tie-in: the discipline of the senior designer starts before code — knowing which part of the business deserves bespoke modeling and which deserves a vendor. This lesson gives you the map.
Knowledge: understand the problem, then invest
Before asking what to build, assemble the problem from four ingredients. Goals — what must improve, stated as outcomes (lesson 0039). Stakeholders — who cares, whose judgment matters. Requirements — what the system must do, expressed as behavior. Constraints — what binds the solution regardless of preference: a regulated market, a fixed deadline, a legacy order platform, an existing payment rail.
Constraints deserve special attention because they rule out options. The returns system cannot replace the order platform, so the design must consume its data as-is. Constraints are not obstacles to design around; they are the first evidence the design is built on.
Requirements become concrete as use cases: for each one, an actor, a goal, and the sequence the system must support (Larman, ch. 6). The business scenarios you will gather in lesson 0052 are their raw material.
Once the problem is assembled, one lens is particularly useful for deciding where the effort goes: classify the business into subdomains — areas identified by the work they perform, not by teams or tables:
| Subdomain | Type | Why |
|---|---|---|
| Eligibility policy | Core | Decides which returns are accepted — the company's differentiator and its legal risk. |
| Return journey | Supporting | Needed for the business to work, but not what makes it special. |
| Label printing | Generic | Carriers sell this off the shelf; no competitive edge in writing it. |
| Payments settlement | Generic | Refunds are a solved problem; vendors and payment rails do it better. |
The classification is not an opinion poll, and it is not a build-versus-buy decision by itself. Core subdomains are where the business differentiates itself — often worth the most modeling attention and hardest thinking. A business may have more than one. Supporting subdomains are necessary but do not create the differentiation. Generic subdomains have widely available solutions. For supporting and generic work, prefer commodity treatment, but still compare vendor fit, regulation, integration risk, total cost, and strategic control before choosing to buy or build (lesson 0027).
Classification is not taxonomy for its own sake. It is a cheap proxy for one quantity: how much business value moves when the model gets better. In a generic subdomain the market has already driven that quantity to near zero — a more thoughtful label-printing model prints the same label. In a core subdomain the model is the product: a slightly better eligibility model accepts different returns, and that changes fraud loss, contact rate, and how many cases land on a human. Design effort should follow that derivative, and three labels are the cheapest way to estimate it before you have any measurements at all.
Seeing the mechanism also tells you where the lens stops working. It assumes a functioning vendor market, a durable advantage, and decisions you actually own. If no vendor can meet a binding constraint, the generic label does not make the work go away. If every competitor automates return adjudication within two years, today's core decays into tomorrow's supporting work. And if the business has already bought the whole capability, your design question is the anti-corruption layer around someone else's model, not how to split your own investment. Where an assumption fails, keep the labels but re-derive them — and write down the fact that would change them.
This is where you also find the domain experts. Every subdomain has people who carry its rules; core subdomains often depend on a few people whose judgment shapes the business. Identify them early — they are the people you will learn from in lesson 0052.
Then ask the question the whole sequence depends on: where does the valuable complexity live? A returns system whose policy is "always accept, no questions" has almost no domain to model — the complexity is in logistics integration. A company whose edge is adjudicating edge-case eligibility has complexity in the policy model. Complexity location decides where your architecture, data, and testing attention go. Spending equally everywhere is the beginner's move; spending on the core domain is the professional's.
One distinction keeps the lens honest: a business capability is what the business can do (returns management), while a subdomain is the problem space that capability serves. Conflating them produces a list of features rather than a map of complexity — useful, but not the same thing.
# A cheap way to record the classification before anyone draws a service.
# The point is the labels, not the dataclass: they drive where effort goes.
from dataclasses import dataclass
from enum import Enum
class Kind(Enum):
CORE = "core"
SUPPORTING = "supporting"
GENERIC = "generic"
@dataclass(frozen=True)
class Subdomain:
name: str
kind: Kind
why: str
expert: str | None = None
RETURNS = {
"eligibility_policy": Subdomain("Eligibility policy", Kind.CORE,
"Adjudicating edge cases is the company's edge.", "Anya, policy owner"),
"return_journey": Subdomain("Return journey", Kind.SUPPORTING,
"Needed to run the business; not a differentiator."),
"label_printing": Subdomain("Label printing", Kind.GENERIC,
"Buy from the carrier; do not rebuild."),
"payments": Subdomain("Payments settlement", Kind.GENERIC,
"Use the existing payment rail."),
}
Worked: proving that eligibility is the core
An architect handed the returns brief does not open with a list of subdomains. They open with the numbers in the initiative frame (lesson 0039) and ask one question of each: which of these moves if the model gets better?
The frame gives four. Median resolution 72 hours, to be brought under 4. A 41% support-contact rate, to be cut by a quarter. Fraud loss no worse than the current baseline. A manual review queue of at most 50 cases a day. The pilot cohort runs roughly 3,000 return requests a day; the 40 requests/second peak in the driver set is portal traffic — status checks and page loads — not decisions. Three of those four numbers turn out to be downstream of one thing: how many requests the system can decide without a human, and how confidently it can defend each decision afterwards.
Then arithmetic assigns the label for you.
# The arithmetic that turned a label into a decision.
DAILY_REQUESTS = 3_000 # pilot cohort: eligible domestic returns per day
MANUAL_QUEUE_CAP = 50 # guardrail from the initiative frame (lesson 0039)
def manual_queue(auto_decision_rate: float) -> float:
"""Requests a day that fall out to a human reviewer."""
return DAILY_REQUESTS * (1 - auto_decision_rate)
def within_guardrail(auto_decision_rate: float) -> bool:
return manual_queue(auto_decision_rate) <= MANUAL_QUEUE_CAP
assert round(manual_queue(0.92)) == 240 # a competent generic rules engine
assert not within_guardrail(0.92) # five times the guardrail
assert within_guardrail(0.984) # 48/day — what the core model must reach
A configurable rules engine plausibly reaches 92%: purchase window, receipt present, value under a threshold. That leaves 240 cases a day for humans — five times a guardrail that is not a preference but the support team's real capacity. Reaching 48 means modeling what a generic rule language cannot express: partial receipts, a VIP override carrying a named authority, evidence that arrives after a refusal, and the appeal path. Those are exactly the cases where the company's judgment differs from its competitors', and exactly where its legal exposure sits. That is what core means here — not that eligibility is the biggest or the hardest part, but that the last six percentage points of automation are worth more than everything else on the board.
The alternative the team rejected was not "build something worse". It was: buy the rules engine and staff the residual queue with two more agents. They rejected it on two grounds, and only one is technical. First, the queue grows with volume, so its cost is permanent and linear, while the model's cost is mostly one-off. Second, policy changes weekly, and lesson 0043's modifiability driver says one engineer must ship a new rule in a day. Routed through a vendor's rule console, each change becomes a ticket, a release window, and an operator who does not know why the rule exists. Eighteen months later that is the difference between a policy change landing on Tuesday and one landing next quarter.
Notice what the same arithmetic does to label printing. A better label model saves nothing — the carrier prints the same label — and the driver that does matter (99% of labels created within 15 minutes of carrier recovery) is about persistence and retry, not modeling. Generic, and the design attention it earns is one adapter, one idempotency key, and a pending state the customer can see.
| Type | Modeling effort | Technology stance | Investment default |
|---|---|---|---|
| Core | Deep, expert-driven where needed | Choose what protects the advantage | Usually highest |
| Supporting | Enough to preserve its rules | Prefer simple, conventional choices | Proportionate |
| Generic | Enough to integrate safely | Evaluate available solutions first | Minimize bespoke effort |
- “Core means the hardest or biggest part.”
- Core means the highest business value per unit of model quality. The carrier adapter may be more code, more error handling, and more on-call pain than the policy engine and still be generic — because doing it better buys nothing a competitor cannot also buy.
- “Core means build, generic means buy.”
- The classification sets a default investment, not a procurement decision. A generic subdomain with no vendor that satisfies a binding privacy or regulatory constraint still gets built — as cheaply as the constraint allows. Run the comparison in lesson 0027 anyway.
- “Classify once, at kickoff.”
- Advantage decays. Once every competitor auto-adjudicates returns, eligibility slides toward supporting and the investment should follow. Give each core label a revisit trigger — a competitor's launch, a regulation, a vendor that finally fits — the same way an ADR carries one.
- “A business has one core domain.”
- It may have several, and the system you are building may contain none of them. A checkout that is pure integration around three vendors is a coordination problem: the design work is in contracts and failure handling, not in domain modeling.
When this is the wrong move. Skip the classification when the system owns no business decisions — an internal report exporter, a data pipeline that moves someone else's facts, a two-week experiment you plan to delete. Skip it when the capability has already been bought whole and your job is to integrate it. And do not perform it from an armchair: labels invented without a single conversation with the people who carry the rules are guesses with a nice table around them. If you cannot name the expert for a subdomain, you are not ready to classify it — go get scenarios first (lesson 0052).
Skill: sort the domain before the structure
A subdomain is identified by…
The business work it performs
The framework that it uses
The team that maintains it
The database tables it owns
Correct. A subdomain is part of the problem space: the business work it performs, not the software around it.
A constraint shapes design when it…
rules out credible design options
appears in the slide deck
the constraint is purely technical
matches a favorite library choice
Correct. A constraint's design value is that it eliminates whole classes of options early.
Label printing is generic, but no vendor meets a binding privacy constraint. The team should…
compare compliant build and buy options
buy because generic always means vendor
ignore privacy because labels are commodity
make label printing the core domain
Correct. Classification guides investment; it does not override a binding constraint. Compare the compliant options and their costs.
Practice: map your own problem space
Use a real business domain you know, or use the returns case described here and in lesson 0041. Write its goal, stakeholders, three requirements as behaviors, and binding constraints. List its subdomains from the work performed, classify each with one sentence of evidence, and name the experts you would consult. Finish by locating the valuable complexity and naming one fact that could cause you to reclassify a subdomain later.
In the lab and the capstone: the companion lab's phase-5 checkpoint works on a deliberately flawed returns-resolution service. Before changing any of its code, classify the parts it contains and ask where its design effort actually went — the mismatch between the classification and the care in the code is the finding. Then compare your map against the worked capstone's workflow and drivers artifacts, which carry this same reasoning through to a decision.
Reveal: the misinvestment signal
If your team's deepest design effort is in supporting or generic work, ask what evidence justifies it. Regulation, integration risk, or a poor vendor market may provide a good answer. If no such force exists, keep the part replaceable so a simpler implementation or vendor remains available without a rewrite.
- Explain to a senior engineer, in two sentences, why core is an investment decision rather than a description of difficulty.
- Take one system you own. Classify its three largest components this week, and name the one where your team's effort and the classification disagree — then say what you would stop doing.
- Predict the damage: if a team spends its deepest modeling effort on a generic subdomain for a year, name the specific change that becomes expensive, and who pays for it.
Your win
You can classify a business into subdomains, say which one deserves bespoke modeling and which deserves a vendor, and point at where the valuable complexity actually lives.
Read and watch deeper
- Applying UML and Patterns, Craig Larman — ch. 5–7: from requirements and use cases to a problem understood well enough to design.
- Design It!, Michael Keeling — ch. 2–3: deciding how much design to do, understanding stakeholders, and discovering goals and constraints on a blank canvas.
- Learning Domain-Driven Design, Vlad Khononov — ch. 1: subdomain classification as a prioritization lens (optional deep-dive).
- Domain-Driven Design, Eric Evans — ch. 15 (“Distillation”): the original argument for separating the core domain from generic subdomains so the scarce modeling effort lands where the advantage is.
- Reference: glossary subdomain, core domain, and domain.
Ask your agent-teacher to classify a description of your business into subdomains and then defend each label against a hostile counter-classification — the argument is where the thinking happens.
Source page: lessons/0051-analyzing-the-business-domain.html
Lesson 0052 · System Architect · Module 1
Build the Domain Model and Its Language
The model is a shared simplification of the domain — and its words are a contract. Discover both from real scenarios before you write a class.
Mission tie-in: the glossary of this course works only because every term has one agreed meaning. Your system's vocabulary deserves the same discipline — a shared vocabulary for the domain, precise enough to encode in code.
Knowledge: the model is a tool, not a transcript
In lesson 0051 you found the core subdomain and its experts. Now extract the knowledge: the rules, exceptions, and vocabulary those experts carry. Work from business scenarios — real, concrete cases, including the exceptional ones. A scenario is a story with a beginning, a decision, and an outcome; it is the raw material from which the model is built.
As the stories accumulate, a vocabulary emerges. Capture it as a glossary with one meaning per term, and then encode those words in code: classes, methods, and tests named by the language, not by implementation. When a support agent says "the return was refused," the domain object should have a refusal with reason — not a status == 4.
| Expert's words | Glossary entry | Encoded as |
|---|---|---|
| “We refuse when the policy window passed.” | Refusal: a decision with a reason code and the actor who made it. | EligibilityDecision.refusal(reason) |
| “Except when the customer is VIP, we still accept.” | Exception: a policy override with its own authority. | PolicyOverride value object |
| “The label must match the approved return.” | Label intent: a request for shipment tied to one approved return. | label_for(approved_return) |
Naming is not decoration; it is where rules are stored. Every step between what an expert says and what the code does is a translation, and each translation is a place a rule can quietly fall out. refuse(reason) cannot be called without a reason, so the rule that every refusal has a defensible cause survives in the type system. status = 4 stores the conclusion and discards the cause, so the rule now lives only in whoever wrote that line. Multiply by twenty call sites and two years, and the difference is not stylistic: one system can answer “why was this refused?” and the other cannot.
The second mechanism is fidelity. A model is a decision-support tool, so its required accuracy is set by the decisions the software makes — not by how rich the business is. Detail the code never consults is carrying cost plus a lie surface: it drifts, nobody notices, and someone eventually trusts it. That is also the boundary condition. Where the software makes no domain decisions — a report exporter, an ETL job, a form over a table someone else owns — there is no second model to keep aligned, and inventing vocabulary for it is pure overhead. Adopt the source system's words instead. Likewise, where the vocabulary is fixed by law or by a standard, adopt it verbatim and translate at the edge; a "better" name for a legally defined term is a defect.
The model is a simplification, not a transcript of the business. You deliberately leave out what the software does not need — the physical shelf where parcels wait, the phone calls between desks. A model that mirrors every detail is as useless as one that mirrors none; both fail to support thought. What you keep is what matters for the decisions the software must make.
Watch for the words that mean different things to different people. "Approved" may mean policy eligibility to support, inspection passed to the warehouse, and money settled to finance. Each divergence is evidence of a separate model — a future boundary (lesson 0053), not a synonym problem to vote away. The glossary you build here is per-model, not per-company.
Keep the language alive: name classes after it, name tests after it, and write a scenario the way the expert said it. When code and expert disagree, investigate the discrepancy with concrete cases: the code may be stale, the rule may have changed, or two contexts may be using the same word differently.
# Encoded language: the refusal is a decision, not a status number.
from dataclasses import dataclass
from enum import Enum
from datetime import date, timedelta
class Reason(Enum):
POLICY_WINDOW_PASSED = "policy window passed"
EVIDENCE_MISSING = "evidence missing"
@dataclass(frozen=True)
class Refusal:
reason: Reason
decided_on: date
decided_by: str
@dataclass(frozen=True)
class EligibilityDecision:
accepted: bool
refusal: Refusal | None = None
def __post_init__(self) -> None:
if self.accepted == (self.refusal is not None):
raise ValueError("a decision must be accepted or refused, exclusively")
def decide(scenario: dict) -> EligibilityDecision:
"""One domain rule, in the language of the policy owner."""
if scenario["requested_on"] - scenario["purchased_on"] > timedelta(days=30):
return EligibilityDecision(False, Refusal(
Reason.POLICY_WINDOW_PASSED, date.today(), "policy engine"))
return EligibilityDecision(True)
Worked: reading the exceptions, not the nouns
Given a week with the returns experts, a beginner collects nouns: return, customer, order, label. An architect collects refusals, because the exceptions are where a domain hides its rules. The concrete move on this initiative was to pull a sample of 200 refusals from the support tool's free-text notes — one week of manual decisions — and sort them by what the agent actually wrote.
| What the agent wrote | Count | What it turned out to be |
|---|---|---|
| “outside window” / “too late” | 61 | One rule, already codified — a reason code. |
| “no receipt”, “no photo” | 44 | One rule with a list — evidence requirements per category. |
| “not as described — judgement call” | 31 | Genuine judgement; needs a human and an audit trail. |
| “VIP — accepted anyway” | 24 | Not a refusal at all: an override with an authority. |
| everything else, once or twice | 40 | Unclassified; the queue that must shrink over time. |
The question the architect asked of every row was the same: is this a rule or a judgement? The 24 VIP entries answered it loudest. They were filed as refusals and then reversed by hand, which means the vocabulary had no word for what actually happened. The expert's own sentence — “except when the customer is VIP, we still accept” — names a policy override, and an override has something a reason code does not: an authority who may issue it and a record of why. Encoding it as a refusal that someone edits afterwards loses both.
The alternative the team rejected was the obvious one: keep reason as free text, because the agents like writing sentences and an enum will always be incomplete. It was rejected for a reason that has nothing to do with tidiness. The initiative's guardrail is a manual review queue of at most 50 cases a day, and you cannot manage what you cannot count: with free text, “how many of last week's manual decisions were already codifiable?” is a data-cleaning project, not a query. The compromise they shipped keeps both — a required reason code and an optional note — with the unclassified bucket as the backlog the policy owner works down.
# The vocabulary, encoded: a refusal carries its cause, an override its authority.
from dataclasses import dataclass
from enum import Enum
class Reason(Enum):
POLICY_WINDOW_PASSED = "policy window passed"
EVIDENCE_MISSING = "evidence missing"
CONDITION_DISPUTED = "condition disputed" # the judgement bucket
UNCLASSIFIED = "unclassified" # the backlog, counted honestly
@dataclass(frozen=True)
class PolicyOverride:
"""“Except when the customer is VIP, we still accept.” — the policy owner."""
granted_by: str # an authority, not a checkbox
justification: str
def codifiable_share(refusals: list[Reason]) -> float:
"""The number the policy owner asks for every month."""
codified = sum(1 for r in refusals
if r not in (Reason.CONDITION_DISPUTED, Reason.UNCLASSIFIED))
return codified / len(refusals)
SAMPLE = ([Reason.POLICY_WINDOW_PASSED] * 61 + [Reason.EVIDENCE_MISSING] * 44
+ [Reason.CONDITION_DISPUTED] * 31 + [Reason.UNCLASSIFIED] * 40)
assert round(codifiable_share(SAMPLE), 2) == 0.6 # 105 of the 176 real refusals
A year later that enum is what makes a policy question answerable in an afternoon: “how many refusals would flip if we moved the window from 30 days to 45?” is a filter over one column. The same question against free text is a week of somebody reading. The vocabulary you choose is the query surface you will have.
| You hear | It reveals | The move |
|---|---|---|
| “Approved means different things here.” | Two models are present. | Record both meanings; keep them apart. |
| “It's basically like…” | A concept is still fuzzy. | Ask for the last concrete case. |
| “The system calls it X, we call it Y.” | The code drifted from the language. | Rename toward the expert's word. |
| “Usually, except when…” | A rule and its exception. | Model the rule and the override separately. |
- “A shared language means the whole company uses the same words.”
- It means one model has one meaning per term. Forcing finance, the warehouse, and support to agree on a single definition of “approved” destroys the most useful signal you have: that three models exist. Unify the words and you will spend the next two years debugging the places where they were never the same thing.
- “The model should be as faithful to the business as possible.”
- Fidelity is a cost, not a virtue. Model what the software decides; leave out the shelf the parcel sits on. Unused detail rots silently and then gets trusted by someone who does not know it is stale.
- “Domain naming is a style preference — the logic is the same either way.”
- The logic is the same on the day it ships. The difference appears when the rule changes: a name that carries the reason gives the next reader somewhere to look, and a name that carries only the conclusion sends them to read every call site.
- “The glossary is a kickoff artifact.”
- It is a living contract with the code. When an expert's word and the code's word disagree, one of them has changed; find out which before renaming anything. Ask for the last real case — arguments about definitions are resolved by examples, not by seniority.
When this is the wrong move. Do not build a model and a vocabulary for work that owns no decisions. In an integration or reporting job, the source system's vocabulary is the domain language; inventing your own adds a translation layer with nothing on the far side of it. In a genuinely generic subdomain, speak the vendor's language inside the adapter — a carrier calls it a “consignment”, and renaming it to please your model just makes their support tickets harder to read. And in a throwaway experiment, the scenarios are worth gathering but the glossary is not worth maintaining; write down what you learned and delete the code.
Skill: hear the model in the stories
The shared vocabulary is owned by…
the product manager's presentation deck
the engineering style guide document
the domain experts and developers
the external vendor's own glossary
Correct. Experts and developers build it together; both must be able to enforce it in their work.
The domain model's main purpose is…
a complete system specification document
a database schema in advance
a shared simplified mental map
a record of every requirement
Correct. A simplification both sides share supports decisions; fidelity to detail is not the goal.
Two experts use “approved” differently. The move is…
just rename the ambiguous term
record a model boundary line
choose the single best meaning
ignore the less powerful expert
Correct. Divergent meanings are evidence of separate models; keep them apart rather than unifying them.
Practice: extract the language from three stories
Take a core subdomain you mapped in lesson 0051. Use three real scenarios from an expert, or use the three returns statements in the worked table above as your supplied case. For each: preserve the speaker's words, extract the nouns and verbs, and build a small glossary with one precise meaning per term. Mark words that could mean different things in another context. Then encode two important rules as named domain functions and write one counterexample for each.
In the lab and the capstone: the companion lab's phase-5 checkpoint gives you a returns-resolution service whose names came from the database rather than from anyone who does the work. Before restructuring it, write the glossary the code implies, then the glossary the scenarios imply, and mark every place they disagree — those marks are your refactoring list. The worked capstone carries the same vocabulary through its workflow and drivers artifacts, so you can see which words survived all the way to a decision.
Reveal: the glossary test
Read a term to two people and ask each to describe it in their own words. If they differ, the entry is too vague — sharpen it or split it. A glossary entry that cannot be paraphrased consistently is not yet domain knowledge; it is a word.
- Explain to a senior engineer, in two sentences, why a domain vocabulary is a defect-prevention mechanism rather than a naming convention.
- Pick one system you own. Find a field whose values are numbers or booleans standing in for a business decision, and write what the expert would call each value — then say what that rename would let you query.
- Predict the damage: if two teams keep using one word for two meanings for a year, name the specific bug that ships, and who reports it.
Your win
You can extract a working vocabulary from real scenarios, encode it in code and tests, and notice when divergent meanings reveal that more than one model exists.
Read and watch deeper
- Applying UML and Patterns, Craig Larman — ch. 9: domain models — concepts, relationships, and terminology before database or classes exist.
- Learning Domain-Driven Design, Vlad Khononov — ch. 2: discovering domain knowledge and building the vocabulary (optional deep-dive).
- Domain-Driven Design, Eric Evans — ch. 2: a shared language as a living, enforced contract; ch. 3 on binding the model to the implementation so the words cannot drift.
- Specification by Example, Gojko Adzic — ch. 6–8: deriving shared vocabulary from concrete business examples and keeping it executable.
- Related lesson: 0041 shows the workflow-mapping side of the same discovery; glossary · domain modeling.
Give your agent-teacher the anonymized scenario transcript and ask it to draft the glossary — then treat every term it invents as unverified until the expert confirms or corrects it.
Source page: lessons/0052-building-the-domain-model.html
Lesson 0053 · System Architect · Module 1
Cut Boundaries by Responsibility and Change
Before modules, databases, or services exist, the system has boundaries to find: who owns each decision, what changes together, and what must stay hidden. Responsibility answers it, not naming conventions.
Mission tie-in: every later boundary decision — modules, data ownership, deployables — inherits this map. Finding boundaries from responsibility and change keeps them stable when the technology moves.
Knowledge: responsibility first, structure second
Lesson 0052 produced the concepts and vocabulary. Now assign each concept a responsibility: the knowledge it holds and the decisions it owns. Responsibility-driven design asks three questions of every candidate component:
- Who knows? Which part holds the facts another part needs?
- Who does? Which part may change a fact, and under what rule?
- Who decides? Which part has final say when facts conflict?
For the returns system, one defensible first assignment is:
| Responsibility | Owner | Why there |
|---|---|---|
| Decide eligibility, with reasons | Eligibility | It holds the policy knowledge and the decision rights. |
| Show the customer progress | Return journey | It knows the customer-visible steps and wording. |
| Establish settled money | Payments (external) | Financial truth belongs to the money system, not to us. |
| Produce a shipping label | Carrier (external) | A solved problem we consume, not own. |
Responsibilities cluster into boundaries along two tests, both about change:
- The change test: if the policy rules change weekly, and carrier integration changes on the vendor's cadence, those two responsibilities will diverge. Each earns its own seam.
- The ownership test: a boundary becomes operationally credible when its decision rights and authoritative writers are explicit, even if one small team currently owns several boundaries.
A boundary is a bet about which changes arrive together. Its value is the change it localizes; its price is the coordination it adds to every change that has to cross it. So the boundary you want is the one that minimizes the number of future changes touching both sides — which makes this an empirical question, not a taxonomic one. Nouns are the wrong input because nouns are the stable part of a domain; decisions are what change, and that is why responsibility (who knows, who does, who decides) predicts the seam better than any diagram of entities.
The boundary conditions follow directly. On a greenfield system you have no change history, so you are substituting the experts' estimate of cadence for evidence — expect to be wrong about at least one seam and pick the cheapest kind of boundary to move (a module, not a service). In a system small enough that nearly every change touches everything, boundaries cost more than they save; one cohesive module is the honest answer. And Conway's law outranks your table: if the organization will hand you four teams whatever you draw, a boundary map that ignores those four teams will be eroded by the people who need to ship without asking each other.
The cohesion side comes from lesson 0001: what belongs together is what changes together and is used together. The coupling side sets the rule: dependencies point from the variable world toward the stable one — the policy engine depends on nothing; the carrier adapter depends on the engine's vocabulary, never the reverse.
Two warnings keep this honest. First, a shared database does not establish a boundary. Multiple components may share a store while one authoritative writer owns each fact, but unrestricted writes erase that ownership. Name the writer of every table. Second, don't cut boundaries you don't need yet: a six-person team does not need eight deployables or elaborate contracts. Preserve meaningful model seams, then add structural cost only where change or ownership demands it.
# A boundary as data: the responsibility, its owner, and what changes it.
# This is the spec you will enforce in module organization (lesson 0022).
from dataclasses import dataclass
@dataclass(frozen=True)
class Responsibility:
what: str
owner: str
changes_with: str
BOUNDARIES = [
Responsibility("Eligibility decisions with reasons",
"eligibility", "policy rules"),
Responsibility("Customer-visible progress",
"return_journey", "communication wording"),
Responsibility("Settled money truth",
"payments_external", "payment rail"),
Responsibility("Shipping labels",
"carrier_external", "carrier API"),
]
Worked: letting the change log draw the line
The returns work is not greenfield — returns are handled today inside the order platform's support tooling. So the first thing the architect opened was not a whiteboard but git log, restricted to the returns-handling code, for the previous twelve months: 63 commits. The question was not “what are the entities?” but “what changed, and what changed with it?”
| Area | Commits | Also touched policy rules | Reading |
|---|---|---|---|
| Policy rules | 38 | — | The fast-moving part; weekly cadence. |
| Carrier integration | 11 | 2 | Vendor cadence; almost never co-changes. |
| Customer notifications | 9 | 3 | Wording changes on its own beat. |
| Return journey / status | 17 | 17 | Never changes without the policy changing. |
| Payment call | 5 | 0 | Owned elsewhere; we only call it. |
# Co-change, computed rather than argued about.
COMMITS = [ # one entry per commit: the areas it touched
{"policy", "journey"}, {"policy"}, {"carrier"}, {"policy", "journey"},
{"notifications"}, {"policy", "carrier"}, {"payments"}, {"policy"},
# ... 55 more, extracted from twelve months of history
]
def co_change(commits: list[set[str]], a: str, b: str) -> int:
"""How often a change to a forced a change to b in the same commit."""
return sum(1 for touched in commits if a in touched and b in touched)
def seam_is_cheap(commits: list[set[str]], a: str, b: str,
tolerance: float = 0.1) -> bool:
"""A seam is cheap when few changes have to cross it."""
involving_a = sum(1 for touched in commits if a in touched)
return co_change(commits, a, b) <= tolerance * involving_a
Two of the rows decide the design. Carrier integration co-changed with policy twice across 38 policy changes and 11 carrier changes — under 5%. That seam is nearly free: put a contract between them and almost nothing has to cross it. The return journey co-changed with policy 17 times out of 17 — it never moved on its own. Splitting those two would have meant that every one of the 38 policy changes now needs a coordinated change on both sides of a boundary, which is the exact cost a boundary is supposed to remove.
The alternative the architect rejected was the one the team wanted: extract eligibility as its own service now, “so it is clean from day one”. The change log says the seam between eligibility and the journey is the least justified one in the system, and lesson 0059 prices what a process boundary costs. The rejection was recorded with its reversal condition: if a second team takes ownership of policy, or if the journey stops co-changing with it for two quarters, the seam has become real and the extraction becomes a three-week job instead of a rewrite — precisely because the module boundary was kept enforced in the meantime.
The consequence over time is the part worth internalizing. The seams you place well are invisible: nobody notices the coordination that did not happen. The seams you place badly announce themselves as a standing tax — every release note that says “deploy A before B”, every pull request that spans two repositories for a one-line rule change.
| You see | What it hides | The move |
|---|---|---|
| One giant "everything" module | Ownership is unassigned. | Ask who knows, who does, who decides. |
| Two things change on different beats | A candidate seam. | Test whether separation reduces change cost. |
| A shared table with no writer | Ownership is fictional. | Name the writer or split it. |
| A seam nobody needs | Fashion over evidence. | Merge; cut it when change demands. |
Teams that adopt bounded context vocabulary will recognize this as boundary-finding too — they call the seams contexts and label relationships between them on a context map. That vocabulary is a useful refinement when two teams must coordinate; it is not required to find good boundaries. The responsibility questions above work with or without it.
- “Boundaries follow the entities.”
- Entities are the stable nouns; boundaries are about the volatile decisions. Two components can both read
Returnand still belong apart, because one decides eligibility weekly and the other formats a carrier payload on the vendor's schedule. - “A boundary is only real if it is a separate service or repository.”
- A boundary is real when crossing it is checked. An enforced import rule in one codebase is a stronger boundary than two services that share a database, and it costs nothing at runtime. Choose the weakest enforcement that actually fails a build.
- “Sharing a database means sharing a boundary.”
- Ownership is about the writer, not the connection string. A shared store can preserve boundaries when each table has one authoritative writer — but only for as long as that rule is enforced by something other than good intentions, because the first cross-module
UPDATEunder deadline pressure is silent. - “Get the boundaries right up front.”
- You will get one or two wrong. The useful goal is not correctness but cheap correction: keep the mistakes inside a codebase where moving a seam is a refactor, not a data migration and a contract negotiation.
When this is the wrong move. Do not cut boundaries into a system whose change history says everything moves together — that is a cohesive core, and splitting it converts every change into a coordination problem. Do not cut them in a codebase small enough to hold in your head, or in one owned by a single person for the foreseeable future; the ceremony has no beneficiary. And do not cut a boundary you cannot enforce: an unenforced seam decays into a naming convention within two quarters, and then the map lies, which is worse than having no map. Merge instead, and wait for change to give you evidence.
Skill: find the seam from the change
A boundary becomes operationally credible when…
two modules share one database
the folder names look distinct
its decision rights are explicit
the code exceeds many lines
Correct. Explicit decision rights and writers make responsibility enforceable even when one team owns several boundaries.
Two responsibilities belong together when…
their change cadences clearly diverge
their names sound broadly similar
they were written together once
their changes and uses coincide
Correct. The change test — they are used together and change together — decides cohesion.
A shared database between two parts means…
both parts become one boundary
write ownership must remain explicit
the database decides all ownership
shared access proves better performance
Correct. Shared storage can preserve boundaries only when every fact still has an authoritative writer.
Practice: assign responsibility before structure
Take the model from lesson 0052, using either your domain or the supplied returns case. For every concept, answer who knows, who does, and who decides. Cluster the answers by the change and ownership tests, then record candidate boundaries as a table — name, responsibilities, decision rights, authoritative facts, and reasons to separate. Identify one candidate you would keep together for now and state what future evidence would justify splitting it.
In the lab and the capstone: the companion lab's phase-5 checkpoint starts from a returns-resolution service with no enforced seams at all. Run the same analysis there — read what the code's own history and its callers say changes together, then propose boundaries and back each one with a rule something can check. The worked capstone shows where this reasoning ends up in its workflow and drivers artifacts.
Reveal: the two-writer test
Pick any fact both of your parts could plausibly write — a status, a count, a decision. If both can write it, you have not assigned responsibility. Name one writer, make the other a consumer of a contract, and the boundary appears by itself.
- Explain to a senior engineer, in two sentences, why co-change evidence beats a diagram of entities when placing a boundary.
- Take one system you own this week: name the two parts that most often appear in the same pull request, and say whether your current boundary agrees with that fact.
- Predict the damage: if a team splits two responsibilities that always change together, name what shows up in their release process a year later.
Your win
You can find system boundaries from responsibility and change — who knows, who does, who decides — and catch unowned state before it becomes a shared table or a service with no owner.
Read and watch deeper
- Applying UML and Patterns, Craig Larman — ch. 17–18: GRASP — assigning responsibilities by knowing, doing, and deciding.
- Object Design: Roles, Responsibilities, and Collaborations, Wirfs-Brock & McKean — responsibility-driven design, collaborations, and control style.
- A Philosophy of Software Design, Ousterhout — ch. 7–9: layers, pulling complexity downward, better together or apart.
- Team Topologies, Matthew Skelton & Manuel Pais — ch. 2–4: Conway's law, cognitive load, and why an ownership boundary the organization cannot staff will not survive contact with delivery.
- Related lessons: 0036 (the trade-off), 0044 (the diagram side); glossary consistency boundary.
Give your agent-teacher your responsibility table and ask it to argue that two rows belong to the same component — then rebut with the change and ownership evidence.
Source page: lessons/0053-responsibility-and-change-boundaries.html
Lesson 0054 · System Architect · Module 2
Design State as a Machine, Not a Column
"Status" is not a field — it is a state machine with legal transitions, actors, and rules. Design the machine before you name the column.
Mission tie-in: state is the most permanent thing you design. A lead makes transitions legal, owned, and recoverable where possible before a schema makes them accidental.
Knowledge: the transition table is the contract
Beginners ask "what columns do I need?" Designers ask what is the state machine? Take the return journey's decision from lesson 0052. As a column it invites return.status = "approved" from anywhere. As a machine it demands an answer for every arrow:
| From | Command | To | Actor | Rule |
|---|---|---|---|---|
| — | assess | accepted / refused | Policy engine | Window and evidence rules; one decision per return. |
| refused | appeal | under review | Customer, via support | One appeal per refusal; new evidence required. |
| under review | uphold | refused | Policy owner | Decision must record who upheld and why. |
| under review | overturn | accepted | Policy owner | Override recorded with authority and reason. |
Transitions are where invariants live. Every rule your business has about behaviour is a statement about a change — who may make it, what must already be true, what becomes true afterwards. A status column has nowhere to put such a rule, so the rules migrate to whichever call site happens to perform the assignment. There is no compiler check that they all agree, and they do not stay agreed: each new caller re-derives the rules from whatever the previous caller did.
There is a counting argument underneath it. With n states, an assignable column admits n × (n − 1) transitions; a real workflow usually has six or eight legal ones. Modelling the machine is how you go from “everything is possible, please be careful” to “these eight are possible, everything else raises”. That is the same compression as making invalid states unrepresentable (lesson 0011), applied to change rather than to shape.
It stops being worth it in two places. When an entity has two states, one transition, and one actor, the machine is a boolean and the table is ceremony. And when the “status” is derived rather than decided — computable at any moment from other facts — it is not state at all, and storing it creates a second truth that will disagree with the first. Compute it instead.
Four questions turn this table into design:
- Who may cause each transition? The actor column. "Anyone with database access" is not an actor.
- What must be true? The rule column — the invariants that guard each arrow (lesson 0055).
- How do we recover? A true reversal restores the prior business state as though the transition had not happened. An appeal is instead a new forward transition; compensation creates another fact that offsets an irreversible effect. If a transition has no safe recovery path, make that consequence explicit before permitting it.
- Do we need the history? If auditors, customers, or debugging need the path taken, the current state alone is insufficient — you need a log of transitions.
The fourth question is the fork between current state and historical facts. Most systems keep the current state and overwrite it; the past is gone. If the business needs the past — "why did this end refused?" — start cheap: record an audit trail of transitions beside the current state, or keep the full transition history as part of the model. If the need grows to full replay and time travel, a dedicated event store — event sourcing — is a later, heavier option with real costs: replay, read models, event-schema evolution, tooling. It is earned by a genuine need, not by fashion, and it is never the first choice for "we should keep history."
# A state machine as guards, not as assignments. "status" never appears
# as a settable field; transitions are the only way to change it.
from dataclasses import dataclass, field
from enum import Enum
class Decision(Enum):
ACCEPTED = "accepted"
REFUSED = "refused"
UNDER_REVIEW = "under review"
@dataclass
class Eligibility:
decision: Decision | None = field(default=None) # the machine's current state
def assess(self) -> None:
if self.decision is not None:
raise ValueError("already decided")
self.decision = Decision.ACCEPTED # policy rule elided
def appeal(self) -> None:
if self.decision is not Decision.REFUSED:
raise ValueError("only a refusal can be appealed")
self.decision = Decision.UNDER_REVIEW
def uphold(self) -> None:
if self.decision is not Decision.UNDER_REVIEW:
raise ValueError("nothing under review")
self.decision = Decision.REFUSED
def overturn(self) -> None:
if self.decision is not Decision.UNDER_REVIEW:
raise ValueError("nothing under review")
self.decision = Decision.ACCEPTED
Worked: the double refund, and what the table would have prevented
In the pilot's third week, four customers were refunded twice. The total was small — €312 — and the fix took an afternoon. The design lesson is in what the architect looked at first, which was not the settlement job that issued the second payment.
They looked at the write sites. grep for assignments to the return's status field returned seven, spread across three modules: the customer portal, the support tool's “reopen” button, the carrier webhook handler, the settlement job, an admin script, and two test fixtures. The support tool's reopen set the status back to pending — reasonable in isolation, since an agent wanted to re-examine a case. It did so on returns that were already refunded, which put them back into the settlement job's query the same night.
The tempting fix is a guard inside the settlement job: skip anything already refunded. The architect rejected it for two reasons. It repairs one of seven paths, and the eighth path, added next quarter by someone who never reads this incident report, will be wrong again. And it places the rule in the module that reads the fact rather than the one that owns it — the placement error lesson 0055 is about. The fix that holds is structural: one guarded entry point, and a transition table that says in one place what an agent's reopen actually means.
# The return journey (lesson 0045) as an explicit table, not an assignment.
from dataclasses import dataclass, field
from enum import Enum
class State(Enum):
REQUESTED = "requested"
APPROVED = "approved"
REFUSED = "refused"
UNDER_REVIEW = "under review"
READY_TO_SHIP = "ready to ship"
RECEIVED = "received"
REFUNDED = "refunded"
# (current state, command) -> (next state, the one actor allowed to cause it)
TRANSITIONS: dict[tuple[State, str], tuple[State, str]] = {
(State.REQUESTED, "approve"): (State.APPROVED, "eligibility"),
(State.REQUESTED, "refuse"): (State.REFUSED, "eligibility"),
(State.REFUSED, "appeal"): (State.UNDER_REVIEW, "support"),
(State.UNDER_REVIEW, "uphold"): (State.REFUSED, "policy_owner"),
(State.UNDER_REVIEW, "overturn"): (State.APPROVED, "policy_owner"),
(State.APPROVED, "attach_label"): (State.READY_TO_SHIP, "journey"),
(State.READY_TO_SHIP, "record_receipt"): (State.RECEIVED, "warehouse"),
(State.RECEIVED, "settle"): (State.REFUNDED, "payments"),
}
class IllegalTransition(Exception):
"""The refusal a status column cannot make."""
@dataclass
class ReturnJourney:
return_id: str
state: State = State.REQUESTED
history: list[tuple[State, str, State, str]] = field(default_factory=list)
def apply(self, command: str, actor: str) -> State:
try:
to_state, permitted = TRANSITIONS[(self.state, command)]
except KeyError:
raise IllegalTransition(
f"{command} is not legal from {self.state.value}") from None
if actor != permitted:
raise IllegalTransition(f"{actor} may not {command}")
self.history.append((self.state, command, to_state, actor))
self.state = to_state
return self.state
# The incident, expressed as a test that now fails at the boundary.
journey = ReturnJourney("r-4711", state=State.REFUNDED)
try:
journey.apply("appeal", "support") # the support tool's "reopen"
except IllegalTransition:
pass # refused here, not paid twice at 02:00
else: # pragma: no cover
raise AssertionError("a refunded return must not re-enter the queue")
Writing the table forced the real question into the open, which is what the reopen button was for. It was not a way back to pending; it was two different business intents wearing one label — “the customer has new evidence” (an appeal, legal only from refused) and “we refunded the wrong amount” (a correction against a settled payment, which is compensation, not reversal). Neither is a state assignment. Both got their own command, their own actor, and their own row.
Twelve months on, the value is not the four avoided refunds. It is that the transition history — four rows for a clean return, more when it is appealed — answers the questions that arrive later without a project: which refusals were overturned and by whom, how long the appeal path takes at the 95th percentile, and how many returns reached ready to ship and never came back. None of that was a requirement when the table was written. It came for free with recording the arrows instead of the destinations.
| Question | Bad default | Designed answer |
|---|---|---|
| Who changes state? | Any code with the object. | One actor per transition. |
| How do we recover? | Call every later action rollback. | Name reversal, compensation, or forward recovery. |
| Do we keep the past? | Overwrite silently. | Log or event stream, by need. |
| What is the source of truth? | Whichever write came last. | One owner per state machine. |
- “A state machine means adopting a workflow engine.”
- It means a dictionary of legal transitions and one method that consults it. Reach for an engine when non-engineers must author the workflow or when transitions are long-running and need durable timers — not to get guards, which cost thirty lines.
- “Keeping history means event sourcing.”
- An append-only table of transitions gives you the audit trail, the timings, and the “who did this” answer at a fraction of the cost. Event sourcing is a different commitment: the events become the source of truth, and you take on read models, event versioning, and replay tooling with it.
- “The UI already prevents illegal transitions.”
- The UI is one writer. Admin scripts, background jobs, webhooks, migrations, and next year's integration are the others, and none of them will open your React code first. Guards belong where the state lives.
- “Every transition should be reversible.”
- Most interesting ones are not: money moved, a label was printed, an email arrived. Naming the irreversible arrows is a design output, not a failure — it tells you which transitions need confirmation, an idempotency key, or a delay before their side effect fires.
When this is the wrong move. A two-state flag with one actor does not need a table — is_archived is a fine design, and wrapping it in a machine is noise. Neither does a status you can derive: if “overdue” is just due_date < today, computing it is correct and storing it creates a second truth to keep in sync. Nor should you build a guarded machine around state you do not own: the carrier's tracking status is mirrored from their system, and your job is to translate and store what they tell you, not to adjudicate whether their transition was legal. Guard the states whose rules are yours.
Skill: read the machine from the business
A status column becomes a state machine when…
you name commands and transitions
you add more string values
you rename it to status_code
you index it for queries
Correct. Naming the commands, actors, and rules per transition is exactly what makes it a machine.
A compensating transition means…
the original fact never happened
any actor may trigger it
another action offsets its effect
the stored record becomes immutable
Correct. Compensation acknowledges the original effect and creates a new effect that offsets it.
The actor column in the transition table answers…
who may cause each change
what the new value is
how fast the change happens
where the data is stored
Correct. The actor column decides who may cause each change — the authorization of the machine.
Practice: draw one entity's machine
Take a core entity from your model, or continue the supplied eligibility-decision case. Draw its state machine as a table: from, command, to, actor, rule. For every transition mark its side effects, audit need, and recovery as reversal, compensation, forward recovery, or deliberately none. Then choose between current state, an audit trail, and event sourcing, with one sentence of evidence for the choice.
In the lab and the capstone: the companion lab's phase-5 checkpoint hands you a returns-resolution service where the status is a plain field several call sites write. Find every write site before you change anything, then introduce the transition table and watch which existing tests start failing — the failures are the illegal transitions the code was quietly performing. The worked capstone records the finished machine and its rules in its state and contracts artifact.
Reveal: the audit test
Ask: "Three months from now, a customer asks why their request ended refused. Is the current state enough to answer?" If the answer needs the path — who, when, on what evidence — you need history. If the answer is "because it is refused," current state suffices.
- Explain to a senior engineer, in two sentences, why a transition table is a stronger guarantee than a validation check in every caller.
- Take one entity you own this week: count the places that assign its status, and say which of them would be illegal under the table you would write.
- Predict the damage: if a status column stays assignable for a year, name the class of production incident it produces and how you would discover it.
Your win
You can design state as a guarded machine with named actors and rules, distinguish reversal from compensation and forward recovery, and keep history as cheaply as the real need allows.
Read and watch deeper
- Applying UML and Patterns, Craig Larman — ch. 29: state machine diagrams — transitions, guards, events, and state-dependent behavior.
- Learning Domain-Driven Design, Khononov — ch. 6–7 (optional deep-dive: complex business logic and modeling the dimension of time).
- Designing Data-Intensive Applications, Kleppmann — ch. 3: facts over state and event streams as data (advanced).
- Domain Modeling Made Functional, Scott Wlaschin — ch. 4–5 and 9: making illegal states unrepresentable by giving each state its own type, so a transition is the only way between them.
- Related lesson: 0045 for the contract side of state; glossary state machine, event sourcing.
Ask your agent-teacher to generate every illegal transition sequence for your machine — reorder, duplicate, skip-a-state — and check your guards against each one.
Source page: lessons/0054-designing-state-machines.html
Lesson 0055 · System Architect · Module 2
Protect Invariants at the Consistency Boundary
An invariant is a rule the system cannot break — an order without line items, a completed task edited, a refund beyond the payment. Where it lives and what changes with it decide your components and your transactions.
Mission tie-in: code that makes invalid states impossible is this course's promise. Here you decide where that impossibility is enforced — one method, one component, or one transaction.
Knowledge: name the rule, then place it
Lesson 0011 established the code-level rule: make invalid states unrepresentable. A hard invariant is a state condition that must hold after every committed change. Keep it distinct from an authorization check (who may act), a precondition (what must be true before one operation), and a cross-boundary reconciliation target that may be temporarily false:
| Rule | Kind | What it forbids or controls |
|---|---|---|
| A submitted order has at least one line item. | Hard invariant | An empty submitted order. |
| A completed task cannot be edited. | Hard invariant | A persisted late edit. |
| A refund cannot exceed the captured payment. | Hard invariant when owned together | An over-refund. |
| Only the assignee or manager may complete a task. | Authorization precondition | An unauthorized command. |
Stating a rule is the easy part. The design work is placing it, and placement runs on four questions:
- Where should this rule live? In the component that owns the facts the rule reads. The refund rule lives with the payment truth; the empty-order rule lives with the order.
- Who has enough information to enforce it? The rule belongs where all its inputs are present. If the inputs are split, the rule is a symptom of a boundary drawn wrong — or a rule that must be checked across the seam.
- Can callers bypass it? A rule enforced by the UI or by caller courtesy is not enforced. The only path to a fact should pass through the guard.
- What happens when the rule fails? Refuse loudly (lesson 0014), or detect-and-repair where the rule spans a seam? The answer is part of the design, not an afterthought.
Larman's operation contracts are the formal version of this table: each operation names its actor, its preconditions (what must hold), and its postconditions (what changed). The rule column of your state machine from lesson 0054 is a precondition; the resulting state is a postcondition.
Then comes the system question: how many facts must change together? The answer is the consistency boundary. Consider the return with required evidence:
| Invariant | Boundary | Why |
|---|---|---|
| “A return cannot be refused while evidence is pending.” | Return + evidence, one component | The refusal rule reads the evidence; they change together. |
| “Every accepted return eventually receives the matching refund.” | Return and payments, two components | This is a reconciliation target with a time bound, not an always-true invariant. |
| “The customer's address must be current.” | Customer owns it | Not a return rule at all — consume the fact. |
The consistency boundary is the transaction, not the entity. An invariant is a predicate over a set of facts; it can only be guaranteed if every fact it reads and writes moves inside one atomic, isolated unit. So the direction of reasoning runs: the rule names its facts → the facts determine the transaction → the transaction determines the component. Designers who start from the object model and then ask “what should be in a transaction?” have the arrow backwards, and end up with rules that are true in the tests and false under concurrency.
The corollary is worth saying plainly: any rule whose facts span two transactions is not an invariant but a target with a repair loop. Calling it an invariant anyway is how a system acquires bugs that appear only under load — two requests that each satisfy the rule commit interleaved and jointly break it.
Where does the principle bend? Two places. Advice to keep consistency boundaries small is a contention heuristic, not a correctness one: inside a single relational database, a boundary spanning four tables costs you nothing until measured lock waits say otherwise, so let the rule set the size and let contention shrink it. And the reverse — a boundary that has become a bottleneck — is not fixed by quietly widening or dropping the transaction. It is fixed by renegotiating the rule with the business, because the rule is what you are about to weaken.
Two heuristics find the boundary. The invariant test: is there a rule that reads two facts and must never observe them inconsistent? Those facts belong in one consistency boundary — typically one component and one transaction. The change test: do these facts always change together under one operation? If every write touches both, they belong together.
Beyond one component — especially across a network — exact atomicity may be unavailable or too costly, and the rule must be restated honestly. It is no longer an always-true invariant; it becomes a reconciliation condition with detection time, retry behavior, compensation, and an owner. For example: “within five minutes of acceptance, either the matching refund exists or an alert is owned.” This preserves precision instead of calling temporary inconsistency an invariant (lesson 0032).
The mistake to avoid is declaring everything one consistency boundary "so it stays consistent." That trades a fast boundary for a global one, and the global boundary becomes a bottleneck that eventually gets broken under load — without redesigning the rules. Invariants are the map; transactions are the territory, not the other way around.
# A guard as behavior: the only way into the state passes the rule.
from dataclasses import dataclass, field
@dataclass
class ReturnRequest:
return_id: str
required_evidence: frozenset[str]
_decision: str | None = field(default=None, init=False, repr=False)
_evidence: set[str] = field(default_factory=set, init=False, repr=False)
@property
def decision(self) -> str | None:
return self._decision
@property
def evidence(self) -> frozenset[str]:
return frozenset(self._evidence)
def attach_evidence(self, item: str) -> None:
if self._decision is not None:
raise ValueError("decision already made; evidence closed")
self._evidence.add(item)
def refuse(self, reason: str) -> None:
if self._decision is not None:
raise ValueError("already decided")
if not self.required_evidence <= self._evidence:
raise ValueError("cannot refuse while evidence is pending")
self._decision = f"refused: {reason}"
# The public API exposes read-only views; supported changes pass
# through guarded methods. Python's underscore is a convention,
# so tests and review must also protect this module boundary.
Worked: an invariant that cannot fit in one transaction
“A refund must never exceed the captured payment” is the returns system's most expensive rule to get wrong, and it is not enforceable the way the empty-order rule is. The captured amount lives in the payment platform. The returns service cannot open a transaction there.
The architect's first move was not to draw components. It was to write the rule's fact list — captured amount, refunds already issued, the refund being requested — and then ask, for each fact, who owns it and when is our copy true? The captured amount is owned by payments and changes without telling us (a partial capture, a chargeback). Refunds already issued are owned by payments too. Only the request is ours. That is the whole problem in one line: we own none of the facts the rule reads.
Three designs were on the table.
| Design | What it buys | Why it was rejected or accepted |
|---|---|---|
| Distributed transaction across the payment vendor | A real atomic guarantee. | Rejected: the vendor's API has no prepare phase, and a two-phase round trip alone would consume most of the 800 ms p95 decision budget. |
| Copy the captured amount into the return at acceptance | A local check, one query, no coupling. | Rejected: the copy is stale by construction. Two concurrent refunds each read “captured 80, refunded 0” and both commit 60. |
| Payments issues a reservation; returns holds it | The fact we check becomes a fact we own. | Accepted: the reservation is ours for its lifetime, so the invariant is local and atomic again. |
The chosen design is worth understanding as a general move rather than a payments trick. The rule was unenforceable because we owned none of its facts, so the design changed which fact the rule reads: payments authorises an amount against the capture and hands back a reservation with an identity and an expiry; the returns service's rule becomes “the sum of refunds issued against this reservation must not exceed it”. Every fact in that sentence is now inside one consistency boundary, and the boundary is atomic because a single row's version guards it.
# The boundary is the transaction: load, decide, and commit as one unit.
from dataclasses import dataclass, field
class InvariantViolated(Exception):
pass
@dataclass
class RefundReservation:
"""One consistency boundary: the reservation and the refunds against it."""
return_id: str
reserved_cents: int # authorised by payments; ours until it expires
version: int = 0 # optimistic lock — the atomicity you can afford
issued: list[int] = field(default_factory=list)
def issue(self, amount_cents: int) -> None:
if amount_cents <= 0:
raise InvariantViolated("a refund must be positive")
if sum(self.issued) + amount_cents > self.reserved_cents:
raise InvariantViolated("refund would exceed the reservation")
self.issued.append(amount_cents)
def refund(store, return_id: str, amount_cents: int) -> None:
"""The rule is checked and committed inside one transaction, or not at all."""
with store.transaction():
reservation = store.load_for_update(return_id) # SELECT ... FOR UPDATE
reservation.issue(amount_cents) # the guard, on fresh state
store.save(reservation) # fails if version moved
# Two concurrent refunds of 60 against a reservation of 80: the second one
# reloads, sees 60 already issued, and is refused rather than silently paid.
What remains across the seam is honest and small: the reservation might expire, or payments might reverse a capture after we refunded. That is a reconciliation condition, and it is written the way lesson 0049 writes them — within five minutes of a settlement, either the refund matches a live reservation or an alert is owned by the on-call engineer. At 3,000 returns a day the reconciliation job finds a handful of mismatches a week, each with a name attached, instead of an over-refund class of bug nobody discovers until finance closes the quarter.
That is the consequence worth internalizing: a rule enforced inside a boundary costs one guard and one test, forever. A rule “enforced” by everyone remembering costs a little more with every new caller, every new integration, and every engineer who joins after the incident report was archived.
| Question | If yes | If no |
|---|---|---|
| One rule reads both facts? | Same consistency boundary. | Separate; converge later. |
| One operation always writes both? | Candidate for one boundary. | Separation may be cheaper. |
| Is a moment of inconsistency observable? | Think twice. | Eventual is fine. |
| Does the rule span a seam? | Define a reconciliation condition and SLO. | A local guard may suffice. |
Terms like aggregate name one way to package a consistency boundary inside an object model. It is a useful technique when you have it; it is not required to design well. The questions above — where the rule lives, who can enforce it, what changes with it — produce the same boundaries without the vocabulary.
- “Consistency boundaries should always be small.”
- Small is a contention heuristic, not a correctness rule. Inside one relational database, a boundary spanning four tables costs nothing measurable until lock waits say otherwise. Let the invariant set the size; let evidence of contention shrink it.
- “Eventual consistency means the rule still holds, just a bit later.”
- It means the rule does not hold, and something must notice and repair. If you cannot name the detection window, the repair, and the owner, you have not chosen eventual consistency — you have chosen an undetected violation.
- “A database constraint is enough.”
- Constraints are excellent backstops and poor spokespeople: they express set-level shapes, not conditional business rules, and they fail with a driver error rather than a domain refusal your caller can act on. Use both — the guard for meaning, the constraint for the write that bypassed it.
- “One big transaction is the safe choice.”
- It converts a correctness question into a throughput failure. At 40 requests a second the deadlocks and lock waits arrive instead of the bug, and the usual response under pressure is to remove the transaction entirely — which is how a system ends up with neither guarantee nor performance.
When this is the wrong move. Not every rule deserves an atomic boundary. Where a violation is cheap to detect and cheap to repair — a counter that drifts, a search index that lags, a denormalized display total — a nightly reconciliation costs far less than the coordination an atomic guarantee would need, and buys the same business outcome. Read-mostly reference data and reporting stores need neither. The test is a price comparison, not a principle: multiply the violation's cost by its expected frequency and compare it against the latency, coupling, and operational burden of enforcing it atomically. If the comparison is not close, you have your answer; if it is close, prefer the one you can change later.
Skill: place the rule
An invariant is best enforced by…
a carefully written code comment
the database user permission rules
a single guarded behavior method
the frontend form validation code
Correct. A guarded method on the owner checks the rule on every path into the state.
The consistency boundary should contain…
every entity in the database
all the related domain concepts
the entire business domain model
the facts changing atomically together
Correct. Facts whose invariants require atomic change belong together; nothing else does.
A condition spans two services with no shared transaction. The design should…
force one global transaction immediately
accept silent inconsistency without ownership
define detection, recovery, and deadline
assume shared storage guarantees correctness
Correct. Restate the guarantee with a time bound, detection, recovery, and ownership.
Practice: name and place your rules
Take one entity from your state machine, or use the supplied return request. Classify each rule as a hard invariant, authorization/precondition, or reconciliation condition. For hard invariants, answer where the rule lives, who can enforce it, how callers could bypass it, and what failure means. Group facts that require atomic change. For one cross-boundary condition, write its detection deadline, retry or compensation, escalation, and owner.
In the lab and the capstone: the companion lab's phase-5 checkpoint contains at least one rule that is checked in a caller rather than at the boundary that owns the facts. Find it by asking which state can be reached without passing a guard, then move the rule and add the test that fails on the old path. The worked capstone states each rule, its boundary, and its repair loop in the state and contracts artifact.
Reveal: the repair test
For every cross-boundary condition, write: “When X observes [gap], [system] detects it within [time], and [recovery] restores [required condition] or escalates to [owner].” If you cannot fill the blanks, you have not designed the guarantee — you have only asserted it.
- Explain to a senior engineer, in two sentences, why the transaction rather than the entity determines the consistency boundary.
- Take one rule you rely on this week. List the facts it reads, name their owners, and say whether all of them can be read and written in one atomic unit — and what you would change if not.
- Predict the damage: if a cross-service rule is documented as an invariant with no detection or repair, name what goes wrong, when someone notices, and who notices first.
Your win
You can state rules plainly, place each where it can be enforced, keep the guarantees that must be atomic inside one consistency boundary, and design explicit repair for the rules that must tolerate a moment of inconsistency.
Read and watch deeper
- Applying UML and Patterns, Craig Larman — ch. 11: operation contracts — preconditions and postconditions as the systematic way to state rules.
- Domain-Driven Design, Evans, ch. 5–6: entities and aggregates — the object-model packaging of consistency boundaries (optional refinement).
- Designing Data-Intensive Applications, Kleppmann — ch. 7: the isolation levels behind “atomic”, and ch. 8–9 on what distribution takes away.
- Effective Aggregate Design, Vaughn Vernon — the three-part essay arguing consistency-boundary size from transaction contention rather than from taste.
- Related lessons: 0011, 0054; glossary consistency boundary, domain invariant.
Ask your agent-teacher to attack your guards with a generated sequence of legal-looking calls that violate a rule — then make it justify why each attempt is or is not a real risk.
Source page: lessons/0055-invariants-and-consistency-boundaries.html
Lesson 0056 · System Architect · Module 2
Carve Components as Deep Modules
The boundary map says where components live. Depth says how good each component is: a small interface hiding a powerful implementation — not a thin layer over someone else's work.
Mission tie-in: components are where every earlier decision — responsibilities, boundaries, state machines, invariants — becomes code. Depth is the quality bar for that translation.
Knowledge: interface small, implementation powerful
A deep module has a small interface and a powerful implementation: callers see a handful of meaningful operations, and the module hides substantial complexity behind them. A shallow module is the opposite — an interface nearly as complex as its implementation, or worse, one that merely forwards: it adds a name but no leverage (glossary: information hiding).
Compare two shapes for the eligibility context:
| Shallow | Deep |
|---|---|
get_policy(), get_evidence(), check_window(), check_evidence(), check_vip(), decide(), save_decision()… — the caller orchestrates every rule. | assess(return_) → EligibilityDecision — one call; the module knows the rules, the evidence, the window, the overrides. |
Depth is a ratio: capability delivered divided by interface surface. The interface is the part you cannot change without coordinating with someone else; the implementation is the part you can change alone. So depth is not an aesthetic score — it literally measures how much of your own system you are still free to change. A module with seven public methods and one caller has seven change agreements outstanding; the same behaviour behind assess() has one.
The second mechanism is multiplicative. Every element of an interface is a channel through which a caller can couple to you, so the coupling in a system grows roughly as interface elements × callers. Halving an interface used by ten call sites removes far more complexity than halving one used by two — which is why depth work should follow call-site counts rather than intuition about which module “feels messy”.
The boundary is real, though. Depth stops paying when callers legitimately need variation you have hidden: the symptom is a growing tail of boolean flags and pass-through parameters, each one a caller asking for a door you closed. And some modules are properly wide by nature — a collections library, a numeric toolkit, a set of primitives. Those are mechanisms, not policies; a small interface over them would be a lie that forces every user to work around it.
Four Ousterhout moves make modules deep:
- Hide what can change. The policy's internal rule structure, the evidence store's layout, the vendor's quirks — all behind one interface. A caller that touches any of them is coupled to a change you want to make freely.
- Pull complexity downward. When a caller needs a helper to use your module correctly, the module is too shallow: move the orchestration in. The module should make the common case simple and the complex cases contained.
- Make the interface general enough, the implementation specific. Avoid mirroring one caller's incidental steps, but do not invent extension points for hypothetical uses. An operation like
assess(return_)expresses the durable domain capability without exposing today's orchestration. - Different layers, different abstractions. A layer that only forwards calls to the layer below is decoration. Each layer must add meaning — translation, policy, or state — or it should not exist.
Then comes the assembly question: better together or better apart? Ousterhout's test is about change, not resemblance. Two things belong together when they change together and are used together; they belong apart when one can change without the other. The eligibility policy and its assess() guard live together; the eligibility module and the label printer change on different beats — apart, behind contracts.
Depth also pays for itself at the system level: a deep module has fewer seams to enforce, fewer contracts to version (lesson 0057), and more room to change internally without a ripple. When lesson 0059 weighs extracting a service, the extraction is only viable if the seam is already this deep.
# Deep: one call, powerful implementation. Callers never see the rules.
from dataclasses import dataclass
from datetime import timedelta
@dataclass(frozen=True)
class EligibilityDecision:
accepted: bool
refusal: str | None = None
class EligibilityService:
"""Hides the policy window, the evidence rules, and the override table."""
def __init__(self, policy_repo, override_table, clock):
self._policy = policy_repo
self._overrides = override_table
self._clock = clock
def assess(self, return_) -> EligibilityDecision:
if self._overrides.is_vip(return_.customer):
return EligibilityDecision(True)
if self._clock.today() - return_.purchased_on > timedelta(
days=self._policy.window_days()
):
return EligibilityDecision(False, "policy window passed")
if not self._policy.evidence_satisfied(return_.evidence):
return EligibilityDecision(False, "evidence incomplete")
return EligibilityDecision(True)
Worked: the interface that leaked a rule
The way to judge a module is to read its callers, not its code. On the existing returns tooling, the eligibility module had eleven call sites. Six of them replicated the same five-step sequence: load the policy, check the window, check the evidence, check the VIP override table, then persist the decision. That duplication is not a style problem — it is the module telling you it never absorbed the orchestration.
Two of the six had dropped the VIP check. Not maliciously: they were written a year apart from the other four, by people copying whichever call site was nearest. Both sat on the support tool's path, which is exactly the path a VIP customer's escalated case travels. Twenty-four VIP returns were refused in the first month of the pilot before anyone connected the complaints to the code.
# Shallow: the module exposes steps, so every caller re-implements the policy.
def handle_return_request_shallow(eligibility, return_):
policy = eligibility.get_policy(return_.category)
if not eligibility.check_window(return_, policy):
return eligibility.save_decision(return_, accepted=False,
reason="policy window passed")
if not eligibility.check_evidence(return_, policy):
return eligibility.save_decision(return_, accepted=False,
reason="evidence incomplete")
# The line two of six call sites never had:
# if eligibility.check_vip(return_.customer): accept anyway
return eligibility.save_decision(return_, accepted=True, reason=None)
# Deep: one call. The rule cannot be forgotten because it is not exposed.
def handle_return_request_deep(eligibility, return_):
return eligibility.assess(return_)
The question the architect asked at the review was the diagnostic one: what does every caller have to know that it should not? The answer named the interface exactly — the order of the checks, the existence of an override table, and the fact that a refusal must carry a reason. All three are policy, and policy belongs inside the module that owns the policy.
The alternative the team proposed first was an EligibilityFacade wrapping the existing seven methods, leaving both available so nothing had to be migrated at once. It was rejected on a specific ground rather than a purist one: two doors into the same state means the rules can still be bypassed, so the incident stays possible while the code grows a layer that only forwards. What shipped instead was assess() as the single public entry, the seven methods made private, and an import rule in the build (lesson 0059) that fails if anything outside the module reaches past it. Migration took two days because there were only eleven call sites; deferring it would have meant migrating whatever the number had grown to.
The payoff arrived fourteen months later, when the policy moved out of a YAML file into a table with an editing UI for the policy owner. That change touched one module and zero callers. Under the shallow shape it would have touched eleven call sites in three modules, each needing its own review, and at least one would have been missed — the same way the VIP check was.
| You see | The defect | The move |
|---|---|---|
| Callers need five calls per operation | Orchestration leaked out. | Add one method that does the sequence. |
| Every method forwards one level | The layer adds no abstraction. | Delete the layer or make it mean something. |
| Callers must know the order of calls | Temporal coupling at the interface. | Fold the sequence into one operation. |
| The interface mirrors vendor types | Foreign vocabulary leaked in. | Translate at the boundary. |
- “Deep means few methods.”
- It means little for callers to know. Three methods that each take a twelve-key options dictionary expose more surface than eight methods with meaningful names — the dictionary is an interface with no type and no documentation.
- “A thin wrapper around a vendor SDK is an adapter, so it is fine.”
- An adapter that mirrors the vendor's shape has relocated the coupling, not removed it. When the vendor's model changes, every caller changes with it. The adapter earns its place only by speaking your domain's language on its own side.
- “Deep modules are harder to test.”
- Usually the reverse. A deep interface is a domain capability, so its tests are business cases you can read. A shallow one pushes the interesting logic into callers, where you end up testing orchestration in five places and the rule in none.
- “Hide everything, including failures.”
- Hide the mechanism; do not hide a decision the caller must make. Defining an error out of existence is deepening; swallowing a failure the caller needed to know about is a leak in the opposite direction, and it usually surfaces as a support ticket rather than a stack trace.
When this is the wrong move. A general-purpose mechanism library is meant to be wide: hiding a numeric routine behind one “do the right thing” call makes it useless for the caller who needed the other thing. When a single caller genuinely needs three orchestration variants and folding them in produces assess(return_, strict=False, skip_evidence=True, audit=False), the depth is fake — the complexity moved into flags. Split into two honest modules, or expose a small explicit sequence and document why. And do not deepen a module whose shape you do not yet know: an interface invented for hypothetical callers is speculative generality (lesson 0028), and it will be wrong in a way that is expensive to unwind.
Skill: judge a component's depth
A deep module has…
many methods exposing every detail
a small interface, powerful implementation
one method forwarding to dependencies
a long configuration knob list
Correct. Small interface, powerful implementation — the caller gets leverage, not chores.
Information hiding protects…
the caller from all validation
the database from all writes
the team from all requirements
the implementation's changeable internal details
Correct. The module's internals are free to change because no caller depends on them.
Two classes belong together when…
they share a single file
they change for common reasons
they share the utility functions
they were written on Tuesday
Correct. Change is the test: if one changes, the other must too, they belong together.
Practice: deepen one component
Take the most important component in your boundary map, or use the supplied eligibility component. Write its current interface. Mark every caller that must know an internal detail — a call order, policy rule, vendor type, or config knob. Redesign toward a small set of capability-level operations, hiding those details; “five or fewer” is a prompt, not a rule. Finally, list what changes together inside it and what changes separately, and decide whether anything should move across the seam.
In the lab and the capstone: the companion lab's phase-5 checkpoint gives you components whose callers do most of the work. Count the call sites of the riskiest one, list what each of them has to know, and deepen the interface until that list is empty — then check whether any caller behaves differently from its neighbours, because that difference is usually a bug rather than a requirement. The worked capstone shows the resulting component shape in its architecture views.
Reveal: the one-caller test
A method used by one caller is a signal to inspect, not an automatic defect. If it exposes an orchestration step or incidental detail, fold it in. If it represents a coherent capability with one legitimate consumer, keep it. Interface size is a budget, but meaning matters more than a method count.
- Explain to a senior engineer, in two sentences, why interface size is a measure of how much of the system you are free to change.
- Take the module you change most often this week: count its callers, and name one thing they all have to know that they should not.
- Predict the damage: if an orchestration sequence stays outside a module for a year, name the defect that shows up and which call site produces it.
Your win
You can judge a component's depth, hide what changes, pull complexity inward, and decide what belongs together — the interface discipline that makes later contracts cheap.
Read and watch deeper
- A Philosophy of Software Design, John Ousterhout — ch. 4–9: deep modules, information hiding, general-purpose modules, layers, pulling complexity downward, better together or apart. This phase's component source.
- “On the Criteria To Be Used in Decomposing Systems into Modules”, David Parnas, Communications of the ACM 15(12), December 1972 — the original argument that modules should hide decisions likely to change, not steps in a process.
- Related lessons: 0006 for the change test, 0022 for enforcing the seams; glossary deep module, design principles · deep modules.
Give your agent-teacher one of your component interfaces and ask it to call the module the way a naïve consumer would — every question it asks reveals a hidden complexity that belongs inside.
Source page: lessons/0056-components-as-deep-modules.html
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…
the HTTP routing and payloads
the vendor's wire format exactly
the database table's column types
the domain vocabulary in code
Correct. The internal contract is the domain vocabulary in code — meaning, not transport.
A versioning policy must say…
which version number is highest
what counts as breaking change
when the backups are scheduled
who deploys the new software
Correct. The policy defines what consumers may assume, what may be added, and what breaks the contract.
A producer adds a new enum value. A robust tolerant reader should…
handle unknown values by policy
reject every response containing it
assume additions are always harmless
copy producer schema without tests
Correct. Tolerance needs an explicit unknown-value policy; ignoring unknown fields does not solve exhaustive enums.
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.
Source page: lessons/0057-designing-contracts.html
Lesson 0058 · System Architect · Module 3
Map the Domain Model to a Data Model
The domain model decided what facts exist and which must change together. The data model decides how those facts are stored — and the choice is driven by access patterns, not fashion.
Mission tie-in: storage is where invariants get their final guard and queries get their shape. Choosing it after the domain, not before, is the sequence this phase teaches.
Knowledge: the model first, storage second
Lesson 0055 found which facts must change atomically. Those clusters are natural units for repository operations and transactions, but they do not require one row, one document, or one physical record: a relational implementation may normalize a cluster across several tables while committing it in one transaction. A return's decision and required evidence may belong to one boundary; the customer's tier does not. References between boundaries use explicit identities rather than object graphs that callers traverse freely.
Separate two decisions that are often mixed together: the data model used for query and update patterns, and the persistence strategy used to retain current state or history:
| Choice | Strength | Cost to investigate | Fits |
|---|---|---|---|
| Relational model | Constraints, joins, ad-hoc queries, mature transactions. | Mapping object-shaped updates; deliberate schema evolution. | Related facts, reporting, and varied queries. |
| Document model | One tree reads and writes naturally as a unit. | Cross-document constraints, joins, and product-specific transaction limits. | Tree-shaped access with strong locality. |
| Event sourcing | History is the source of truth; state can be replayed. | Read models, event evolution, tooling, and operational learning. | Only when audit, temporal queries, or replay justify it; events can be stored in several technologies. |
Storage is the one design decision with a population. Code has a single copy: change it, deploy, done. A schema has as many copies as you have rows, and every row is a commitment already made. So the cost of changing a data decision scales with rows × writers × external readers, and — unlike a code decision — it grows on its own while you are not looking. A choice that is trivially reversible on the day you make it becomes a project eighteen months later without anyone doing anything wrong.
That is precisely what expand/contract buys, and it is worth naming the mechanism rather than the steps: it converts one irreversible change into a sequence of individually reversible ones by keeping the old and the new forms simultaneously true. At every point in the sequence you can stop and go back with a deploy instead of a restore. The dual-write window is not overhead; it is the reversibility, and paying for it is the point.
The boundary condition falls out of the same arithmetic. Ceremony has to be paid for by the population. A table with ten thousand rows, one writer, and no external reader can be migrated in a maintenance window at a cost measured in minutes; wrapping that in a four-stage choreography spends a week to protect against a risk you could have absorbed. Count the rows, the writers, and the readers before deciding how careful to be.
Use access patterns and required guarantees to choose, not category slogans. A return's decision and evidence have tree-shaped locality, so a document model is credible; reporting across returns and customer tiers favors relational queries. Start with the simplest store that satisfies the important writes, reads, constraints, scale, and team-operability needs. Add a separate read model only when measured query or scale pressure justifies duplicated data and convergence work. Polyglot persistence is an architectural cost, not the default conclusion of having several boundaries.
Whatever the store, the repository (repository, lesson 0013) keeps storage details behind a domain-facing port. The schema is a contract with its own evolution: an expand/contract migration adds the new form, writes both, backfills and verifies, switches reads, then stops old writes. Dropping the old form is a later, deliberately destructive step taken only after a compatibility and retention window; recovery may require backup, restore, or forward repair.
# One cluster persists as one unit; the repository hides the store.
from dataclasses import dataclass
@dataclass(frozen=True)
class Refusal:
reason: str
decided_on: str
decided_by: str
@dataclass(frozen=True)
class ReturnRecord:
return_id: str
accepted: bool
refusal: Refusal | None = None
class ReturnStore:
"""Port: load and save one whole cluster. Storage is behind this."""
def load(self, return_id: str) -> ReturnRecord: ...
def save(self, record: ReturnRecord) -> None: ...
Worked: splitting one column into a state machine
The transition table from lesson 0054 has to land in storage. Today the legacy table has one status column holding strings; the model needs a state plus a refusal reason plus the transition history. The architect's opening questions were the three that price the change: how many rows, how many writers, and who reads this besides us?
| Fact | Number | What it implies |
|---|---|---|
| Rows | 1.9 M after the legacy import | A backfill cannot hold a lock; it must be batched. |
| Writers | 1 (the returns module, after lesson 0054) | Dual-write is feasible: there is one place to change. |
| External readers | 2 (support tool, nightly analytics extract) | The status string is a published contract, not an internal detail. |
| Write rate at peak | ≈ 40 requests/second on the portal | No maintenance window is available inside the pilot's guardrails. |
The naive plan — ALTER TABLE, backfill, deploy the new code, drop the old column — fails on two of those four rows, and each failure is instructive. Backfilling 1.9 M rows in one statement holds locks long enough to stall the write path. And the analytics extract, owned by another team on a monthly release, contains SQL that reads status; dropping it breaks a report the finance team sees before anyone on the engineering side notices.
# The mapping is a design decision, written down, not an ORM accident.
from dataclasses import dataclass
from enum import Enum
class State(Enum):
REQUESTED = "requested"
APPROVED = "approved"
REFUSED = "refused"
REFUNDED = "refunded"
@dataclass(frozen=True)
class ReturnRecord:
return_id: str
state: State
refusal_reason: str | None
# Expand/contract: each step is reversible with a deploy, not a restore.
MIGRATION = [
("expand", "add nullable state and refusal_reason; no default, no rewrite"),
("dual", "write both forms; keep reading the old status column"),
("backfill", "5,000 rows per batch, ~380 batches, verified between runs"),
("verify", "comparison query returns zero mismatches on three consecutive runs"),
("switch", "read the new columns behind a flag; old column still written"),
("compat", "publish a status view for the analytics extract, one quarter"),
("contract", "stop writing status; drop it after the retention window"),
]
def write_both(row: dict, record: ReturnRecord) -> dict:
"""The dual-write window: one writer, two truths, deliberately."""
row["state"] = record.state.value
row["refusal_reason"] = record.refusal_reason
row["status"] = _legacy_status(record) # the old contract, still honoured
return row
def _legacy_status(record: ReturnRecord) -> str:
return {State.REQUESTED: "pending", State.APPROVED: "approved",
State.REFUSED: "refused", State.REFUNDED: "refunded"}[record.state]
The batches matter more than they look. At 5,000 rows a batch the backfill is about 380 batches; run with a short pause between them it finishes in roughly half an hour without competing with the portal's traffic, and it can be stopped after any batch. The comparison query is what turns “we think it worked” into evidence: it counts rows where _legacy_status(state) disagrees with status, and the switch does not happen until three consecutive runs return zero.
The one step that is genuinely irreversible is the last. Dropping status cannot be undone with a deploy — only with a restore, and a restore loses everything written since. So it was scheduled eleven weeks after the switch, once the analytics extract had migrated off the compatibility view and the retention window had passed. The alternative — dropping it the same week to “finish the migration” — buys a tidy schema and sells the ability to roll back the whole change.
| You see | It hides | The move |
|---|---|---|
| A table per value object | Possibly incidental mapping complexity. | Check whether one repository and transaction hide it. |
| One critical query is too costly | The current read shape may not fit. | First tune it; justify a read model if needed. |
| A status column plus a "history" table | The domain needs the past. | Start with an audit trail (lesson 0054). |
| One schema shared by contexts | Ownership is fiction (lesson 0053). | Name the writer per table or split. |
- “The ORM's schema is the domain model.”
- The mapping is a design act with its own trade-offs. A table per class is a coincidence when it happens and a constraint on the model when it is enforced — value objects, state machines, and history usually map to something other than one row per object.
- “Normalize until it hurts, denormalize until it works.”
- Both directions are driven by access patterns and rules, not by a rule of thumb. Normalization exists to protect an invariant by having one place to write a fact; denormalization buys a read at the price of a convergence problem. Say which one you are doing and what it costs.
- “Expand/contract is only for zero-downtime shops.”
- Its real payoff is that each step is reversible with a deploy. Even with a maintenance window available, that is the difference between rolling back an artifact in two minutes and restoring a backup while the business waits.
- “Event sourcing gives you history for free.”
- History yes; everything else no. You take on read models, event versioning, replay tooling, and the fact that a badly designed event is permanent. Start with an audit trail of transitions and let a real need for replay or temporal queries earn the upgrade.
When this is the wrong move. A new table with zero rows should just be changed — there is nothing to migrate and no reader to protect. A table under ten thousand rows with one writer, no external consumer, and an available window is cheaper to migrate in a single transaction than to choreograph. Purely additive changes with no backfill (a nullable column nobody reads yet) need no dual-write window at all. The sequence in this lesson is priced for a population; when there is no population, skip it and spend the time on something with risk in it.
Skill: choose the shape from the pattern
Choose a document model when…
the relations demand many joins
the team wants SQL constraints
the cluster is one tree
the queries cross cluster roots
Correct. Cluster-shaped locality — read and write as one tree — is the document model's strength.
A read model exists to serve…
important queries needing separate optimization
the primary write path itself
the backup and restore duties
the source of truth data
Correct. A separate read model earns its duplication when important queries cannot be served adequately by the primary model.
Expand/contract migrates data by…
one risky rename over weekend
staged backward-compatible data migration steps
copying the entire database over
deleting the old columns first
Correct. Compatibility overlaps old and new forms; contraction is delayed until evidence makes the destructive step acceptable.
Practice: map your clusters to storage
Use your consistency boundaries or the supplied returns case. List the important writes, reads, constraints, scale assumptions, and team-operability needs. Compare a relational and document model for the whole system; treat event sourcing as a separate history strategy. Choose the simplest adequate starting point and name the evidence that would justify a second store or read model later. Finally, write an expand/contract sequence including verification, retention window, the destructive step, and recovery if contraction proves premature.
In the lab and the capstone: the companion lab's phase-5 checkpoint stores its state in a shape the domain model has outgrown. Write the mapping you want, then plan the change as an expand/contract sequence and name which step is the irreversible one — that identification is the skill. The worked capstone carries the storage decision in its state and contracts artifact and the reasoning that rejected the alternatives in options and decision records, while the brownfield case walks a larger version of the same migration in expand and contract.
Reveal: the read-model test
If a query needs fields from three boundaries, first ask whether an indexed join, view, cached response, or batch report is already adequate. A separate read model earns its duplication only when freshness, latency, scale, or ownership evidence rules out the simpler choices. If it may lag, state how much and how users will understand the delay.
- Explain to a senior engineer, in two sentences, why expand/contract is about reversibility rather than about uptime.
- Take one table you own this week: count its rows, its writers, and its readers outside your team, and say how careful its next schema change has to be.
- Predict the damage: if a team ships a schema change as one irreversible step, name what they will be doing at 02:00 and what they will lose.
Your win
You can choose a data model from access patterns and guarantees, preserve atomic change without dictating one physical shape, and evolve schemas through compatible stages with an explicit recovery decision.
Read and watch deeper
- Designing Data-Intensive Applications, Kleppmann — ch. 3: data models and query languages; ch. 4: encoding and evolution; ch. 5 (2nd ed. ch. 4): schema evolution and compatibility.
- Applying UML and Patterns, Larman — ch. 9–10: mapping the domain model to persistent design (optional; ch. 16: mappings to relational schemas).
- Learning Domain-Driven Design, Khononov — ch. 7 (optional deep-dive: event sourcing as a persistence choice).
- Refactoring Databases: Evolutionary Database Design, Scott Ambler & Pramod Sadalage — the catalogue of schema refactorings with their transition periods, which is expand/contract worked out in detail.
- Related lessons: 0013, 0023; glossary expand/contract, repository.
Ask your agent-teacher to design the schema for your core cluster in all three shapes, then challenge each with your real query list — the weakest shape usually shows itself immediately.
Source page: lessons/0058-mapping-domain-to-data.html
Lesson 0059 · System Architect · Module 3
Choose the Architecture Style from Drivers
The architecture style is a consequence of what the system must be — its drivers and quality characteristics — not a taste to declare. Order matters: characteristics first, style second.
Mission tie-in: this is where the whole sequence converges — domain, state, invariants, components, contracts, and data all get a home. The style must serve them, not reshape them.
Knowledge: characteristics first, style second
Lesson 0043 turned vague goals into measurable architecture drivers. Now the payoff: the driver set constrains and informs the style. It rarely produces one mechanical answer, so compare credible alternatives and their costs. Asking "should we use microservices?" first hands the framing to fashion; asking "what must this system be?" first hands it to evidence.
| Driver | What it demands | Style response |
|---|---|---|
| One small team, one deployable | Low coordination cost, simple ops. | Modular monolith — modules, one process. |
| Policy changes weekly | Fast, isolated change in one module. | Module seam with enforced dependency rules. |
| Carrier and payments are external | Translate at the edge; replace vendors. | Ports and adapters at the boundary. |
| Compliance audit of decisions | Auditable facts, no accidental writes. | Event or history stream inside the module. |
A style is a bundle of default answers to coordination questions, so choosing one is choosing what you want to be cheap and accepting what becomes expensive. Distribution has a single, precise effect underneath all the diagrams: it converts errors that were deterministic and in-process into partial failures that are not. In exchange you get independent failure, independent scaling, and independent release. Everything else people say about microservices is downstream of that trade.
The reason the modular monolith is the default for a small team is not modesty; it is option value. A module seam can become a service later, mechanically, if it was enforced. A service fleet cannot become modules later without a data-ownership merge project. Under uncertainty — and at the start you are uncertain about exactly the things that would justify distribution — you take the choice that is cheaper to reverse, and you keep the seam that preserves the option.
That argument inverts when the uncertainty is gone. If a regulator requires an isolated processing environment, if one component's load is two orders of magnitude larger than the rest, if two teams already exist and cannot coordinate a release, then delaying the boundary costs more than drawing it now — you are not preserving an option, you are deferring known work. And Conway's law sets an outer limit: if the organization will hand you four teams regardless of your diagram, one deployable that four teams edit is not a monolith, it is a queue.
For a small team, the modular monolith is the responsible default: one deployable, explicit modules, one composition root, and enforced dependency direction (lesson 0022). It keeps every seam from your boundary map (lesson 0053) while paying none of the distributed-system rent. A different style must earn its cost against a driver:
- Independent scaling of one component under measured load.
- Isolation — a security, regulatory, or availability boundary that one deployable cannot honor.
- Independent release cadence — teams that must deploy on their own beat.
- Technology freedom — a component that genuinely needs another runtime.
- Ownership — a team that must own its slice end to end (lesson 0036).
Every extraction pays permanent rent at the new process boundary: calls across it become network interactions (lesson 0031), while consistency boundaries should usually remain inside one service. Cross-service workflows now need explicit coordination, and versioning, observability, security, and operational ownership grow. Event-driven styles pay in another currency: temporal decoupling in exchange for asynchronous failure handling. The deciding question is never "which style is modern" but "which driver pays for the added rent."
Architecture styles are also measured after they are chosen. The dependency rules from lesson 0022 become fitness functions: automated checks that fail the build when a module imports across its seam, or a latency budget is breached. A style without fitness functions is a diagram; one with them is a contract.
# A fitness function: the boundary map as an enforced import rule.
# Fails the build when a module reaches outside its allowed set.
ALLOWED = {
"return_journey": {"eligibility", "payments", "orders"},
"eligibility": set(), # policy is internal to this boundary
"payments": set(),
}
def check_imports(graph: dict[str, set[str]]) -> list[str]:
violations = []
for module, imports in graph.items():
for target in imports:
if target not in ALLOWED[module]:
violations.append(f"{module} must not import {target}")
return violations
Worked: scoring the styles against the driver set
The architect did not begin with a style. They began with the five prioritized drivers from lesson 0043 and a rule for reading the table: a style earns a deployment boundary only where a driver pays for it. Anything else is rent with no tenant.
| Driver | Modular monolith | Monolith + outbox worker | Eligibility as a service |
|---|---|---|---|
| Availability: carrier down 30 min, 99% of labels within 15 min of recovery | Fails — a synchronous carrier call ties the request to the vendor | Meets it: intent persisted, worker retries | Meets it, but only because it also has a worker |
| Performance: 40 rps, eligibility decision p95 < 800 ms | Meets it with room | Meets it with room | Meets it; one network hop spends ~10–30 ms of the budget for nothing |
| Modifiability: one engineer ships a policy rule in a day | Meets it | Meets it | Worse: a rule that touches the contract needs two deploys, ordered |
| Operability: label failures > 2% for 5 min, disable within 10 min | One process to watch, one flag | Two processes, one flag; queue depth is a new signal | Three processes, two on-call surfaces, six-person team |
| Security: object-level authorization on every read | One place to enforce it | One place to enforce it | Enforced twice, or trusted across the wire |
The table decides two things at once, and the second one is the interesting one. The monolith alone fails the availability driver, so the pilot does need a second deployable — the outbox worker recorded in ADR 0003 (lesson 0046). That is a real distributed system with a real cost: a queue to watch, retries to make idempotent, a pending state the customer can see. It was accepted because a named driver pays for it. Being “a modular monolith” was never the goal; not paying unjustified rent was.
Eligibility-as-a-service is the option worth rejecting carefully, because it is the one a team asks for. It is not slow — 800 ms is a generous budget for one hop. It fails on the other three rows: it makes the most frequent change in the system (a weekly policy rule) more expensive, adds an on-call surface to a team of six with a rotation of three, and splits a consistency boundary that lesson 0055 put together. No driver asks for independent scaling of eligibility at 40 rps, and no second team exists to own it.
# The rule the table encodes: every extra deployable needs a paying driver.
STYLE_DEPLOYABLES = {
"modular_monolith": ["web"],
"monolith_plus_worker": ["web", "outbox_worker"],
"eligibility_service": ["web", "outbox_worker", "eligibility"],
}
# Which driver pays for each boundary beyond the first?
PAYING_DRIVER = {
"outbox_worker": "availability: carrier outage must not lose eligible requests",
}
def unpaid_boundaries(style: str) -> list[str]:
"""Deployment boundaries no driver has agreed to pay for."""
extra = STYLE_DEPLOYABLES[style][1:]
return [name for name in extra if name not in PAYING_DRIVER]
assert unpaid_boundaries("monolith_plus_worker") == []
assert unpaid_boundaries("eligibility_service") == ["eligibility"]
Twenty months later a second team took over policy, and the driver that had been missing finally arrived. The extraction took about three weeks rather than a quarter — not because anyone predicted it, but because the import rule had been enforced the whole time, so the seam was still where the boundary map said it was. That is the return on keeping a seam you are not yet using: the option stays exercisable.
| You hear | The defect | The move |
|---|---|---|
| “We should use microservices.” | Style before drivers. | Write the drivers first. |
| “It's cleaner as a service.” | Aesthetic without a driver. | Price the network rent. |
| “The database is shared anyway.” | Ownership is fiction. | Name the writer or split. |
| “The rules are in a README.” | No enforcement. | Turn them into fitness functions. |
- “A modular monolith is just a monolith with folders.”
- The difference is enforcement. Without a check that fails the build when a module reaches across a seam, the modules are a naming convention and will be gone in two quarters. The fitness function is not a nice extra; it is what makes the word “modular” true.
- “Microservices scale better.”
- They scale independently. A single process usually has more headroom per unit of complexity at this size; what justifies distribution is scale asymmetry — one component needing far more capacity than the rest — not scale itself.
- “Start with services; you can always simplify later.”
- Merging services is a data-ownership project with a migration attached. Splitting a well-enforced module is a refactor. The cheapness runs one way, which is why the default runs the other way.
- “The style is decided once, at the start.”
- Drivers change: a second team, a regulator, a component whose load grows tenfold. The ADR should carry its revisit trigger, and someone should actually check it — a decision with no trigger becomes a tradition.
When this is the wrong move. The modular-monolith default is wrong when a driver is already known and binding: a regulated workload that must run in an isolated environment; a component that needs a different runtime, such as a model server; two teams that already exist with different release cadences and a shared repository as their actual bottleneck; or one component whose load is a hundred times the rest, where scaling the whole process is genuinely wasteful. In those cases the boundary is not an option you are preserving — it is work you would be deferring at a cost. Draw it, price it in the ADR, and say out loud which drivers pay for it.
Skill: derive the style
Choose the architecture style after…
the team's favorite new framework
the number of developers hired
the drivers and quality characteristics
the marketing roadmap is final
Correct. The driver set — functional needs, qualities, constraints, risks — decides which style fits.
Extracting eligibility into a service changes journey-to-eligibility calls by…
adding network failure at boundary
moving them into shared memory
removing them from critical paths
eliminating all database interactions entirely
Correct. Only calls crossing the new process boundary gain network latency, timeout, and partial-failure semantics.
For a small team the default is…
a microservice per domain noun
a fully event-driven serverless platform
a shared database of services
a modular monolith single deployable
Correct. One deployable with enforced module seams minimizes coordination and ops while keeping boundaries.
Practice: write the driver-to-style argument
Use your top five measurable drivers, or start with the supplied returns driver table. Compare at least two credible styles against every driver and include operational simplicity as a cost. Choose the smallest adequate style, record one accepted weakness, and name the evidence that would trigger reconsideration. For every distributed boundary complete: “We accept network, versioning, observability, and ownership overhead because independent ____ is required by driver ____.”
In the lab and the capstone: the companion lab's phase-5 checkpoint ends with the structure the earlier phases produced. Write the import rule that would make its boundaries real, run it, and count the violations — that number is your honest starting position. The worked capstone shows the comparison and its record in options and decision records, traced back to the measurable drivers that paid for each boundary.
Reveal: the empty-blank test
If the second blank reads "cleanliness," "modernity," or "maybe later," you have not found a driver — keep the module seam and postpone the distributed system. The seam preserves the option; the service pays rent for nothing.
- Explain to a senior engineer, in two sentences, what distribution actually buys and what it converts in exchange.
- Take one system you own: name each deployable and the driver that pays for it. If a boundary has no payer, say what it would take to remove it.
- Predict the damage: if a six-person team runs eight services for a year, name what shows up in their incident reviews and in their lead time.
Your win
You can derive an architecture style from measurable drivers, justify every deployment boundary with a named cost and driver, and enforce the result with fitness functions.
Read and watch deeper
- Fundamentals of Software Architecture, Richards & Ford — ch. 2–8: architectural thinking, modularity, characteristics and component-based thinking; then ch. 10–19: the styles and how to choose.
- Monolith to Microservices, Sam Newman — ch. 1–3: what a service boundary must buy to be worth its cost, and how to extract one from a monolith when a driver finally arrives.
- Learning Domain-Driven Design, Khononov — ch. 8–10: tactical patterns and the architectural styles that preserve model boundaries.
- Related lessons: 0044, 0025; glossary fitness function, architecture driver.
Ask your agent-teacher to defend the style you did not choose as if it were the obvious answer — then rebut it with your driver table. The rebuttal is the ADR.
Source page: lessons/0059-choosing-architecture-style.html
Lesson 0060 · System Architect · Module 3
Design the Deployment and Release Path
A design is only real when it ships repeatedly. The deployment pipeline, data migration, and release strategy are the last stages of the sequence — and the ones that make failures observable and recoverable.
Mission tie-in: lessons 0047–0049 planned the initiative's delivery and rollout. Here you design the durable machinery — the pipeline and release path the system keeps for its whole life.
Knowledge: one artifact, many environments, designed recovery
The deployment pipeline is the automated path from committed code to production: build, test, package once, then promote that same artifact through environments. The rule is one build, many deploys — if each environment rebuilds from source, you are testing different things and shipping unproven code.
| Stage | What runs | Gate |
|---|---|---|
| Build | Tests, fitness functions, package once. | One immutable artifact. |
| Staging | Same artifact against staging data. | Contract and rollout tests pass. |
| Canary | Small measured cohort, full telemetry. | Error budget and latency thresholds. |
| Rolling | Staged exposure to the rest. | Disable, rollback, or recovery path ready. |
Every release is a bet on an artifact nobody has run in production yet. There are only two levers on that bet, and both work by shortening the distance between “changed” and “known”: batch size — how much is riding on one deploy — and blast radius — how much of the population the bet is exposed to before evidence arrives. Canaries, flags, and staged rollout are all the second lever; small, frequent deploys are the first. Neither makes changes safer in themselves; they make the consequences of being wrong smaller and the discovery faster.
That is also why the same artifact must be promoted rather than rebuilt. An artifact is a hypothesis, and every test you ran was evidence about that artifact. Rebuild it for the next environment and the evidence no longer refers to the thing you are shipping; staging stops being a measurement and becomes a ritual.
Where the principle stops: reversibility is not universal and it is not free. Code rolls back. Data does not roll back, it rolls forward with repair. Side effects — emails, refunds, printed carrier labels — do not roll back at all; the only lever on those is not having sent them yet. So for anything with an external effect, the design question is never “can we roll back?” but “how late can we commit, and what makes a repeat harmless?”
Three design moves keep deployments boring. First, separate deploy from release: a feature flag can let code arrive before behavior is exposed. Turning it off limits further exposure, but it does not undo emitted messages, money movement, or incompatible data writes. Second, treat data migrations as controlled release steps: automate and rehearse the schema sequence from lesson 0058, with an explicitly approved runbook where full automation is unsafe. Third, release in observable stages: canary, then rolling, each with entry evidence, an observation window, and a stop threshold (rollout gate, lesson 0049).
Environments are a liability as much as a luxury. Each one is software to run, data to refresh, and drift to chase. You need enough to prove the artifact — typically build, staging, production — and every additional environment must justify itself. Staging earns its cost only if it runs the same artifact, same migration, and a representative shape of data.
Finally, design for the day the release is wrong. The recovery may be code rollback, disabling exposure, forward-fix, compensation, or restoring data. A down-migration is safe only when it preserves writes made under the new schema; often it does not. Decide per release which recovery paths are valid for code, data, and external side effects, and rehearse the chosen path before the deploy rather than inventing it during an incident.
# A rollout gate as a fitness function: the deploy is not "done"
# until the evidence says so. This runs in the pipeline after canary.
def gate_passed(metrics: dict[str, float], budget: dict[str, float]) -> bool:
for metric, threshold in budget.items():
if metrics[metric] > threshold:
return False
return True
assert gate_passed(
{"error_rate": 0.002, "p95_latency_ms": 180},
{"error_rate": 0.01, "p95_latency_ms": 250},
)
Worked: designing the gate around the irreversible parts
The first thing the architect wrote for the returns release was not a pipeline diagram. It was a list of the things this system does that cannot be undone: it moves money, it buys carrier labels at about €0.40 each, and it sends customers email. Everything else in the design follows from that list, because those three are what a rollback cannot reach.
| Stage | Runs | Takes | Stop rule |
|---|---|---|---|
| Build | Unit and contract tests, import fitness function, package once | ≈ 4 min | Any failure; the artifact is never rebuilt downstream. |
| Staging | The same artifact against a restore shaped like production, migration rehearsed | ≈ 12 min | Migration or contract test fails; the sequence is wrong, not the code. |
| Canary | 5% of traffic — about 150 returns a day — with full telemetry, 30-minute window | 30 min | Error rate > 1%, eligibility p95 > 800 ms, or any duplicate label. |
| Rolling | 25%, 50%, 100%, with the same thresholds at each step | ≈ 90 min | Same thresholds; exposure stops where it is, it does not reverse. |
The third stop rule is the one that comes from the irreversibility list rather than from a dashboard template. Latency and error rate are the usual canary signals, and they would not have caught the failure this team was actually afraid of: a botched idempotency key, which produces two labels for one return without raising a single error. At 5% of 3,000 returns a day, thirty minutes of that is only a handful of duplicate labels — trivial money, and exactly the point: the cheapest place to notice is where the exposure is small. So “duplicate label rate must be zero” became an entry in the gate, measured directly, alongside the metrics everyone already had.
# The gate as data: thresholds, and the irreversible effects they protect.
GATE = {
"error_rate": 0.01, # fraction of requests
"p95_latency_ms": 800.0, # the eligibility driver from lesson 0043
"duplicate_label_rate": 0.0, # the effect a rollback cannot undo
}
def stop_reasons(observed: dict[str, float]) -> list[str]:
"""Return every breached threshold, not just the first — the whole picture."""
return [f"{metric}={observed[metric]} exceeds {limit}"
for metric, limit in GATE.items()
if observed.get(metric, 0.0) > limit]
assert stop_reasons({"error_rate": 0.002, "p95_latency_ms": 640.0,
"duplicate_label_rate": 0.0}) == []
assert stop_reasons({"error_rate": 0.002, "p95_latency_ms": 640.0,
"duplicate_label_rate": 0.004}) != [] # stop: labels bought twice
The alternative the team considered was blue/green with an instant switch — two full environments, flip the router, flip it back if anything smells wrong. It was rejected for a reason specific to this system: both colours write the same database, so the switch does not make the schema change reversible, and the schema change was the largest risk in the release. Blue/green solves for code and buys nothing against data. The expand/contract sequence from lesson 0058 is what makes the data reversible, and it does so per step rather than per deploy.
Across the pilot the shape held: 34 deploys, two canary stops (one latency, one duplicate label), and a mean time from a bad deploy to stopped exposure of about eleven minutes. That last number is the one to design for. It is not a measure of how good the code is; it is a measure of how quickly the system can admit it was wrong.
| You see | The defect | The move |
|---|---|---|
| Each environment rebuilds | Artifacts drift. | Build once, promote the same. |
| Unrehearsed manual migration | The result depends on memory. | Automate or use an approved, tested runbook. |
| Deploy equals release | Exposure cannot be limited independently. | Use staged exposure where it reduces risk. |
| No data recovery plan | Code rollback cannot restore truth. | Choose forward repair, restore, or safe reversal. |
- “A feature flag makes a change reversible.”
- It stops further exposure. It does not unsend an email, unbuy a carrier label, or unmove money, and it does not undo rows written in the new shape while the flag was on. Flags control the future, not the past.
- “Rollback is the recovery plan.”
- It is one of five: roll back the code, disable the exposure, fix forward, compensate the effect, restore the data. Each layer may need a different one, and the release plan should say which, per layer, before the deploy rather than during the incident.
- “Staging is useless unless it is identical to production.”
- Staging's job is to falsify specific claims — the migration runs in this order, the contract still holds, the artifact starts. Demanding total parity is how teams end up with an expensive environment they still do not trust. Name what staging proves; accept that it does not prove the rest.
- “Canary releases need a service mesh.”
- A canary is a measured cohort plus a stop rule. A percentage check inside one process, with the same telemetry the rest of the system has, is a canary. The infrastructure is an implementation detail of the idea, and buying it does not supply the stop rule.
When this is the wrong move. A canary of 5% is nonsense for an internal tool with twelve users — use a named beta group, or ship and watch. A pure code change behind a flag that is already off does not need staged exposure at all; the exposure is the flag flip, and that is where the gate belongs. And a system with no data migration and no external side effects can run a much shorter pipeline honestly: the ceremony in this lesson is priced for money, labels, and email. Match the machinery to what is actually irreversible, and be willing to say out loud that a change has nothing irreversible in it.
Skill: make the path recoverable
A deployment pipeline builds and tests…
one artifact promoted through stages
fresh artifacts in every environment
the database alongside each deploy
manual steps on release day
Correct. Build once and promote the same artifact; rebuilding per environment tests different code.
Data migrations belong inside…
the midnight manual maintenance window
the application's startup code path
the pipeline's ordered release sequence
the developer's local database setup
Correct. The release sequence makes migrations ordered and repeatable, with recovery chosen according to their data effects.
A canary release exposes changes…
to the entire user population
only in the staging environment
after the launch party begins
to a small measured cohort
Correct. A small measured cohort with full telemetry turns release into an experiment with a stop threshold.
Practice: draw your pipeline and migration sequence
Use your next change or the supplied returns release. Write pipeline stages with entry gates and exit evidence. Choose staged exposure or a flag only where it controls the identified risk, and write stop thresholds. Place the lesson 0058 migration into the sequence, then name separate recovery for code, data, and external side effects; do not assume one rollback handles all three.
In the lab and the capstone: the companion lab's phase-5 checkpoint is where the service finally has to ship. List its irreversible effects first, then design the gate around them rather than around whatever metrics are easiest to collect. The worked capstone carries the release plan in its rollout artifact and the thin production-shaped slice it starts from in the walking skeleton.
Reveal: the rehearsal test
Dry-run the release and chosen recovery on staging twice. If the second run depends on somebody remembering an undocumented step, automate it or put it in an owned, tested runbook. If production data effects cannot be represented safely in staging, record that limitation and rehearse the closest safe recovery evidence available.
- Explain to a senior engineer, in two sentences, why batch size and blast radius are the only two levers on release risk.
- Take your next release: list what it does that cannot be undone, and say which stop rule protects each item.
- Predict the damage: if a team treats rollback as the whole recovery plan for a year, name the incident where it fails them and what they lose.
Your win
You can design a pipeline that builds once and promotes safely, place data migrations in a controlled sequence with an honest recovery plan, and release through observable stages with named gates.
Read and watch deeper
- Continuous Delivery, Humble & Farley — ch. 5–6 and 10–13: the deployment pipeline, environments, data management, components, and release strategies.
- Accelerate, Forsgren, Humble & Kim — the delivery-performance evidence behind small batches and fast, reversible releases.
- Site Reliability Engineering, Beyer et al. — ch. 8, Release Engineering: hermetic builds, one artifact promoted, and release as an engineering discipline rather than an event.
- Related lessons: 0047, 0049, 0035; glossary deployment pipeline, feature flag.
That is the last lesson. Sixty of them have moved from reading coupling in a single function to designing a system's deployment path, and the way to find out whether the judgment transferred is to use it on something whole. The capstone asks you to produce the full artifact set for a returns-resolution initiative and then compare it against a worked strong version and a deliberately weak one. The capstone assessment has you review your own packet against the rubric before you look at either. And the exit diagnostic puts scenarios comparable to the entry diagnostic in front of you, so the change in how you answer is visible rather than assumed. Do them in that order; the self-review before the reveal is where most of the learning is.
Ask your agent-teacher to simulate the release: it should inject a staging failure, a canary regression, and a mid-migration incident, and you decide the next move each time.
Source page: lessons/0060-deployment-and-release-design.html
Reference document · canonical vocabulary
Glossary
The canonical language of this course. Every lesson uses these terms in these senses; when a term is used loosely elsewhere, the resolution chosen here wins. Each entry carries the lesson that teaches it, and an Avoid line naming the words that blur it.
Structure and complexity
The vocabulary for talking about shape: what makes code hard to change, and how you measure it.
Coupling
How much one module depends on the internals or behavior of another. Low coupling means a module can change without forcing changes in its neighbors.
Avoid: tight coupling, entanglement, "spaghetti"
Taught in: lesson 0001
Cohesion
How strongly the elements inside one module belong together — whether they serve one purpose. High cohesion means a module has one reason to exist.
Avoid: god class, grab-bag module, "utils" folder
Taught in: lesson 0001
SRP — Single Responsibility Principle
A module should have one reason to change. The reason, not the responsibility, is the unit of design.
Avoid: "does one thing" (too vague), "one method" (too literal)
Reason to change
The concrete force (a new requirement, a new technology, a new policy) that would make you edit a module. Design units of code around single reasons to change.
Avoid: "responsibility" (fuzzier), "feature" (too coarse)
Taught in: lesson 0006
Change amplification
The number of places you must edit to make one logical change. It is the most direct measurement of design debt.
Avoid: "shotgun surgery" (the smell it produces)
DRY — Don't Repeat Yourself
Every piece of knowledge should have a single, unambiguous representation in the system. DRY targets knowledge, not text: two identical lines can encode different knowledge.
Avoid: copy-paste, WET ("write everything twice")
False DRY
Deduplication of text that encodes two different pieces of knowledge. The two copies look identical today because the facts happen to coincide; merging them couples parties that will diverge, and the first divergence has to be bent into the shared shape.
Avoid: “removing duplication” (when the duplication was never knowledge)
Rule of three
Wait for the third occurrence before extracting an abstraction. At one occurrence there is no pattern; at two the shape is a guess; by three the variation is visible and the change-rent of the copies is real.
Avoid: “don't repeat yourself” read as “never type it twice”
KISS — Keep It Simple
Prefer the solution with the least complexity that meets the requirement. Simplicity is a design target, not a default.
Avoid: cleverness, over-engineering, speculative generality
Taught in: lesson 0002
YAGNI — You Ain't Gonna Need It
Do not build capability you have no current evidence of needing. Unused generality is paid for forever and used never.
Avoid: speculative features, "just in case" hooks
Speculative generality
Parameters, hooks and layers built for cases that do not exist. It is paid for on every read of the current code and used by nobody. The counter-test: does the generality make today's case simpler? (Ousterhout)
Avoid: “future-proofing”, “extensible by design”
Taught in: lesson 0030
Abstraction
A simplified interface that hides the complexity of what lies behind it. A good abstraction is deeper than the sum of its parts.
Avoid: leaky abstraction, façade without substance
Deep module
A module with a small, simple interface hiding substantial implementation. The opposite of a shallow module, which exposes most of its work through the interface. (Ousterhout)
Avoid: "thin layer" (a shallow module by another name)
Information hiding
Keeping design decisions that are likely to change inside a module, invisible to its users.
Avoid: encapsulation (usually used for the same idea, but includes runtime state protection)
Boundary
The edge of a module where one set of concerns hands off to another. Boundaries are where you enforce invariants, translate representations, and choose failure behavior.
Avoid: "layer" (a boundary is a seam, a layer is a convention)
Seam
A place where you can alter behavior without editing the code in question — a hook, an interface, a dependency-injection point. Seams make change and testing possible.
Avoid: "extension point" (a seam with a designed-for purpose)
Cost of divergence
The worst plausible outcome if two copies of a rule or value drift apart before anyone notices. It is the number that should decide whether “just extract it” is safe: low cost of divergence tolerates a deduplication mistake, high cost demands an explicit shared owner instead.
Avoid: “just DRY it up” (skips the question), duplication treated as always bad
Taught in: lesson 0028
Published boundary
An interface that others already depend on. Narrowing or changing it is no longer a local decision — every consumer pays for the change — so the bar for altering a published boundary is higher than for an internal one with no callers yet.
Avoid: “just tighten the interface” (ignores who already calls it), treating an internal refactor and a public one the same way
Taught in: lesson 0030
Amdahl's law
The speedup from optimizing one part of a system is capped by how much of the total runtime that part accounts for: if a component is only p of the runtime, no amount of work on it can improve the whole by more than p. It is the arithmetic that stops effort going to the wrong bottleneck.
Avoid: optimizing whatever is easiest to optimize, skipping the profiler in favor of instinct
Taught in: lesson 0034
Reversibility
How expensive a decision is to undo once made. Cheap-to-reverse decisions can be made fast on thin evidence; expensive-to-reverse ones earn the extra analysis YAGNI would otherwise forbid spending.
Avoid: “move fast” applied uniformly, treating every decision as equally risky
Taught in: lesson 0002
Co-change
How often two parts of the code appear together in the same commit. High co-change between parts that live in different modules is evidence they share a reason to change and belong on the same side of a boundary, whatever the folder structure says today.
Avoid: “change coupling” (the same idea; prefer this term for consistency), physical proximity mistaken for the signal
Taught in: lesson 0053
Objects and the domain
The words for the things the software is actually about, and the shapes that protect them.
Domain
The problem space the software exists to serve: the rules, entities, and workflows that are true regardless of technology.
Avoid: "business logic" (if you must, mean the same thing)
Taught in: lesson 0011
Subdomain
An area of the business identified by the work it performs — part of the problem space, with its own vocabulary and rules. Classifying subdomains as core, supporting, or generic says where the valuable complexity lives.
Avoid: "module", "service" (solution-space words), "business unit"
Taught in: lesson 0051
Core domain
The subdomain that differentiates the business — the part worth bespoke modeling and the most design attention, because off-the-shelf solutions cannot beat it. Everything else earns commodity treatment.
Avoid: "the main feature", "everything important"
Taught in: lesson 0051
Use case
A description of what a user or system needs to accomplish, told as an actor, a goal, and the sequence the system must support. It names the interactions and outcomes before any component exists. (Larman)
Avoid: "feature", a user story masquerading as a scenario
Taught in: lesson 0051
Operation contract
A specification of one system operation: its name, its actors, preconditions that must hold, and postconditions stating what changed — facts created, modified, or deleted. It makes rules precise without prescribing implementation. (Larman)
Avoid: a method signature alone, an implementation sketch
Responsibility
The knowledge and decisions a component owns: what it knows, what it may change, and what it decides. Assigning responsibilities — who knows, who does, who decides — is how boundaries are found before structure exists. (GRASP, Wirfs-Brock)
Avoid: "role" (softer), a task list
Taught in: lesson 0053
Domain invariant
A rule the domain cannot violate, enforced by the domain itself — not by callers' good behavior. Example: an order can never be confirmed twice.
Avoid: business rule (weaker: often means "policy")
Taught in: lesson 0011
Ubiquitous language
The vocabulary shared by domain experts and developers, precise enough to encode in code and tests. When a word means different things to different people, that is a boundary, not a synonym problem.
Avoid: "business speak", one global glossary across contexts
Taught in: lesson 0052
Value object
An immutable object whose identity is its value: two instances with equal fields are interchangeable. Safe to share, trivial to test.
Avoid: mutable DTOs, "entity-lite"
Taught in: lesson 0012
Entity
An object with a continuous identity across changes, tracked over time.
Avoid: "model" (overloaded), "record"
Taught in: lesson 0012
Aggregate
A cluster of domain objects treated as one unit for changes, with a single root that guards the invariants of the whole cluster.
Avoid: aggregate root (the root is one part; the aggregate is the cluster)
Taught in: lesson 0012
Consistency boundary
The set of facts that must change atomically for a hard invariant to hold. A rule spanning boundaries must instead be stated as a reconciliation condition with a time bound, detection, recovery, and ownership.
Avoid: "transaction scope" (its implementation), "everything in one database"
Repository
A domain-facing collection-like interface for retrieving and persisting aggregates, hiding the storage technology behind it.
Avoid: "DAO" (data-oriented, leaks storage thinking), repository leaking query APIs
Taught in: lesson 0013
Unit of work
The object that owns a transaction's boundary: what is written together, committed together, and rolled back together. Repositories operate inside it; the domain never sees it.
Avoid: save() scattered through the domain, autocommit-per-call
Taught in: lesson 0013
Law of Demeter
A method should only talk to its immediate neighbors: itself, its own fields, its arguments, objects it creates, and the direct members of its own collection. No chain-walking into strangers.
Avoid: "train wrecks" like a.b().c().d(), the principle of least knowledge
Taught in: lesson 0003
Tell, Don't Ask
Ask an object to do something for you instead of interrogating its state and doing it yourself. The natural companion of the Law of Demeter.
Avoid: getter chains, "ask, then act" sequences
Taught in: lesson 0003
Train wreck
A chain of calls that walks through objects the caller does not own — order.customer.address.city. The caller now depends on the internal geography of three objects, and any restructuring of them breaks it.
Avoid: “fluent interface” (a builder returning self is not a wreck)
Taught in: lesson 0003
Composition
Building behavior by holding objects and delegating to them, rather than inheriting from them.
Avoid: "has-a" (shorthand only); composition does not mean "put everything in one class"
Taught in: lesson 0007
Strategy pattern
Extracting a varying behavior behind an interface so the behavior can be swapped at runtime without touching its caller.
Avoid: "policy" (same shape, different name)
Higher-order function
A function that takes or returns a function. In Python it is the cheapest strategy interface there is: no protocol, no class, no registration.
Avoid: a one-method class used once
Taught in: lesson 0016
Protocol
A structural interface in Python: an object conforms to it by having the right methods, no inheritance required.
Avoid: "duck typing" (untyped protocols), abstract base class (nominal, heavier)
Taught in: lesson 0017
Read model
A representation of data shaped for a specific query, kept separate from the write model that enforces invariants on the way in. It can denormalize, precompute, and lag reality slightly, because its job is answering questions fast, not guarding rules.
Avoid: “the database” (conflates read and write shape), one model exposed for both directions
Taught in: lesson 0013
Structural typing
An object conforms to a type by having the right methods and attributes, not by declaring inheritance from it. Python's typing.Protocol makes this checkable statically without forcing a class hierarchy on unrelated implementations.
Avoid: “duck typing” (the untyped, unchecked version of the same idea)
Taught in: lesson 0017
Parameter object
A small class with no behavior, whose only job is to hold values that always travel together as one argument instead of three or four positional parameters. It stops a signature from growing every time a new related value shows up.
Avoid: a bag of unrelated booleans, “just add another parameter”
Taught in: lesson 0015
Optimistic locking
Concurrency control that reads a version marker with the data and rejects the write if the marker has moved, instead of holding a lock for the duration of the read-modify-write. It fails loudly on conflict rather than silently overwriting.
Avoid: “just retry the write” without checking the version, treating SELECT FOR UPDATE as the only option
Taught in: lesson 0055
Reconciliation condition
A rule that is allowed to be false for a bounded, understood window because enforcing it everywhere at once is too expensive — paired with a detector that notices the gap, a repair that closes it, and an owner accountable for both. The gap is designed, not tolerated by accident.
Avoid: “eventual consistency” used as if it needed no detector or owner, an unmonitored discrepancy
Taught in: lesson 0055
Fragile base class problem
A base class changes one of its own methods, and that change silently breaks subclasses that overrode a different method but relied on the old internal call pattern. Inheritance exposes internals that composition would have kept hidden.
Avoid: “it's just a bug in the subclass” (the base's internal change is the actual cause)
Taught in: lesson 0007
Liskov substitution principle
Anything that was true of the base type — its preconditions, postconditions, and invariants — must stay true of every subtype. A subclass that narrows what it accepts or weakens what it guarantees is not a subtype, whatever the class hierarchy claims.
Avoid: “IS-A” as a sufficient test (syntax, not behavior), overriding a method to throw “not supported”
Taught in: lesson 0007
Middle man
A class whose methods mostly just forward the call to another object with no added logic. It is the failure mode you get from over-applying the Law of Demeter — hiding a train wreck by inserting a layer that repeats it, instead of moving the behavior to where the data lives.
Avoid: “just add a wrapper” as an automatic fix for chained calls
Taught in: lesson 0003
Dependencies and boundaries
Who is allowed to know about whom, and where that rule is written down.
Dependency direction
Which way arrows point between modules. Well-directed dependencies point inward: domain code depends on nothing; infrastructure depends on domain.
Avoid: upward dependencies, dependency cycles
Taught in: lesson 0009
Port
An interface on the edge of a module that defines what the module needs from the world (driven port) or offers to it (driving port). The domain defines the ports; the world provides the adapters.
Avoid: interface owned by the infrastructure
Adapter
Code that translates between a port (our interface) and a concrete external thing — a database, an API, a file format — so the external thing never touches the domain.
Avoid: wrapper (when the translation is cosmetic), leaky adapter
Composition root
The single place — usually one function near the program's entry point — where every concrete adapter is constructed and injected. Nothing else in the system names a technology.
Avoid: service locator, ambient container, “wiring everywhere”
Anti-corruption layer
An adapter at the edge of a bounded context that translates another context's vocabulary into this one's, so a foreign model never leaks inward. (Evans)
Avoid: “just import their types”, a shared DTO package
Taught in: lesson 0036
Bounded context
The boundary within which one model and one vocabulary are consistent. The same word may mean different things in two contexts — and that is the signal to keep them apart.
Avoid: “the one true model”, a global schema
Taught in: lesson 0036
Context map
The diagram of bounded contexts and the relationships between them — partnership, shared kernel, customer/supplier, conformist, anti-corruption layer. It is the boundary evidence for modules and services.
Avoid: "architecture diagram" (too broad), unlabelled boxes and arrows
Taught in: lesson 0053 (optional refinement)
Shared kernel
The small, stable set of concepts two contexts genuinely share and agree to change together. Integration chosen on purpose, not integration by accident.
Avoid: a “core” or “common” package that only grows
Taught in: lesson 0036
Control coupling
One module steering another's internal path with a flag or mode argument, so the caller must understand the callee's branches. The usual fix is to inject the behaviour instead.
Avoid: boolean parameters, mode="..." strings
Taught in: lesson 0016
Global coupling
Dependence on shared, reachable-from-anywhere state or services (singletons, module globals, ambient context). Changes to one consumer affect all consumers — silently.
Avoid: "ambient state", hidden global dependencies
Taught in: lesson 0019
Shared state
State that more than one component reads or writes. The owner is whoever is responsible for its correctness; unowned shared state is the source of most concurrency bugs.
Avoid: "global state" (a species, not the genus)
Taught in: lesson 0020
Fitness function
An automated check that an architectural property still holds — a test that fails the build when a dependency arrow, a layer rule, or a latency budget is violated. (Ford, Parsons & Kua)
Avoid: a README rule, a convention nobody enforces
Taught in: lesson 0022
Conway's law
Systems tend to take the shape of the communication structures of the organizations that build them. Treated as a design input it makes team boundaries an architecture decision; ignored, the architecture is decided by the org chart anyway.
Avoid: “reorg and it will fix itself”, inverse Conway as a slogan
Taught in: the multi-team architecture supplement
Team API
The surface one team offers another: its code and service interfaces, but also how work is requested, who decides what, and how quickly it answers. Teams integrate through this the way modules integrate through contracts — and an undocumented one costs the same as an undocumented module.
Avoid: org chart, “just ping them”, a shared backlog
Taught in: the multi-team architecture supplement
Contract test
One shared test suite, written once against the port, that every adapter implementing that port must pass. It is what stops “the interface says X” from meaning something different in each implementation.
Avoid: testing each adapter against its own bespoke suite, trusting a shared abstract base class alone
Taught in: lesson 0017; run across every port by the lab's fitness functions
Authoritative source
The one system entitled to change a given fact. Every other copy of that fact is a cache, however current it looks, and a disagreement is resolved by asking the authoritative source, not by picking whichever copy is present.
Avoid: “the database” (there may be several), “the latest write” as a stand-in for authority
Taught in: lesson 0020
Last-write-wins
A conflict resolution rule that keeps whichever write has the latest timestamp and discards the other, silently. It enforces no invariant about what the discarded write meant — it is a default for when nobody has decided who owns the fact, not a design choice.
Avoid: “conflict resolution” as if it always applies, treating clock skew as a solved problem
Taught in: lesson 0020
Ratchet
A fitness function that freezes today's count of a known violation and fails the build only if that count goes up. It lets a team ship without a full cleanup while guaranteeing the problem never gets worse — and gives a visible number to bring down over time.
Avoid: a violation count nobody tracks, “we'll clean it up eventually” with no gate
Taught in: lesson 0022
Runtime, failure and evolution
What the system does when the world misbehaves, and how it changes without stopping.
Fail-fast
Failing as close to the cause as possible, loudly and immediately, instead of letting a bad value travel and corrupt far from its origin.
Avoid: silent None, ignored errors, deferred exceptions
State machine
An explicit model of an entity's legal states and the commands that move between them, each with an actor and a rule. It turns "status" from a free-form column into guarded behavior.
Avoid: "status field", a free-form enum
Taught in: lesson 0054
Event-driven architecture
Components communicate by publishing and consuming events rather than by calling each other directly, so producers don't know their consumers.
Avoid: "event-based" (fine), "message-driven" (a sibling, not the same)
Taught in: lesson 0025
Event sourcing
Persisting the stream of facts (events) that happened, and deriving current state by replaying them. The event log is the source of truth; current state is a projection. Justified by audit, history, or replay needs, not fashion.
Avoid: "audit table", "just keep a log"
Message bus
A channel that routes events or commands from publishers to subscribers, decoupling the two sides.
Avoid: "event bus" (a species of message bus)
Taught in: lesson 0025
Idempotent
An operation that produces the same end state whether it runs once or five times. Idempotence is what makes at-least-once delivery survivable and retries safe.
Avoid: “safe to retry” (true only once the operation is idempotent)
Idempotency key
A caller-generated identifier for one intent, reused across every retry of that intent, so the receiver can return the first answer instead of acting twice.
Avoid: a fresh id per attempt (which buys nothing)
Taught in: lesson 0032
Dead-letter queue
Where a message goes after its handler has failed its allotted attempts. It only counts as a design if a human actually watches it.
Avoid: infinite retries, silent drops
Taught in: lesson 0031
Correlation id
An identifier attached to an event and to every log line produced while handling it, so one workflow can be followed across handlers that share no call stack.
Avoid: “grep the timestamps”
Taught in: lesson 0031
Eventual consistency
A guarantee that replicas converge if writes stop — with no promise about what a read sees in the meantime. Useful when a stale answer is cheap; dangerous when the reader will act on it as if it were current.
Avoid: “inconsistent” (it is a guarantee, not the absence of one)
Taught in: lesson 0032
Tolerant reader
A consumer that requires only the fields it uses and ignores everything else, so producers can add fields without a coordinated release.
Avoid: strict schema validation on inbound events
Taught in: lesson 0033
Characterization test
A test that records what code currently does, not what it should do — written to lock behaviour before restructuring unfamiliar or AI-generated code. (Feathers)
Avoid: “I'll refactor first and test after”
Strangler fig
A pattern for replacing a system incrementally: route new behavior around the old implementation, growing the new one until the old one can be deleted.
Avoid: "big bang rewrite" (the anti-pattern)
Taught in: lesson 0035
Branch by abstraction
A safe transition technique: insert an abstraction over a component, migrate all users to it, then swap the implementation behind it.
Avoid: long-lived feature branches (the coordination cost is the point)
Taught in: lesson 0035
Expand / contract
A staged compatibility technique: add the new form, write both, backfill and verify, switch reads, then stop old writes. Removing the old form is a later destructive step after a retention window and recovery decision. Also called parallel change.
Avoid: the one-step rename, the “quick” migration
Taught in: lesson 0035
Feature flag
A runtime switch that separates deploying code from releasing behaviour and can limit further exposure. It does not undo data or external side effects. A flag that outlives its transition becomes configuration debt.
Avoid: permanent flags, flags used as a state model
Taught in: lesson 0035
Deployment pipeline
The automated path from committed code to production: build, test, package once, then promote that artifact through environments. Its job is to make deployments repeatable, observable, and recoverable.
Avoid: "deploy script" (a fragment), manual release checklists
Taught in: lesson 0060
Config graveyard
The accumulated settings nobody has ever varied: knobs added “just in case”, each one documented, validated, tested and combinatorially possible — for no measured variation at all.
Avoid: “configurable”, “flexible” (as unexamined virtues)
Taught in: lesson 0029
Technical-debt ledger
A written record of a shortcut taken on purpose: what was borrowed, the interest (the recurring cost while it stands), and the trigger that repays it. Debt without a ledger is indistinguishable from a mistake, and gets treated as one.
Avoid: a “tech debt” epic, a refactoring wish list, “we’ll clean it up later”
Taught in: lesson 0038 and the brownfield modernization case
Trade-off
A decision between forces that cannot both be maximized. The craft is making the trade-off explicit, deliberate, and reversible — not avoiding it.
Avoid: "best practice" (implies no cost), "silver bullet"
Poison message
An input that fails its handler on every retry, not because of a transient fault but because the message itself is malformed or unhandleable. Left in an ordinary queue it blocks everything behind it; the fix is to detect it and route it to a dead-letter queue after a bounded number of attempts.
Avoid: “just retry harder”, unlimited retry loops
Taught in: lesson 0014
Graceful degradation
Continuing to run with an optional capability missing or failed, in a way that is visible rather than pretending nothing happened. The system does less, on purpose and observably, instead of failing entirely or lying about what it delivered.
Avoid: silently returning stale or default data with no signal, “fail open” used as a synonym
Taught in: lesson 0014
At-least-once delivery
A broker guarantee that a message will be delivered one or more times, never zero — which means a handler can receive the same message twice and must be written to tolerate it. It is the reason idempotency is a delivery-layer requirement, not an optional nicety.
Avoid: “exactly-once” assumed by default, a handler written as if delivery were guaranteed single
Taught in: lesson 0025
PACELC
An extension of the CAP theorem: if the system is Partitioned, choose Availability or Consistency; Else — even with no partition — choose Latency or Consistency. It names the trade-off CAP leaves out: normal operation still forces a choice between a fast answer and a fully consistent one.
Avoid: “CAP” cited as if it covered ordinary, unpartitioned operation
Taught in: lesson 0032
Consumer-driven contract
A test, owned and written by the consumer, that runs against the producer's build and fails it if the producer breaks something the consumer actually relies on. It moves “will this change break anyone” from guesswork to a build result.
Avoid: a producer-authored contract the consumer never reviewed, manual “check with the other team” coordination
Taught in: lesson 0033
Read-your-writes consistency
A guarantee that a user who just made a change will see that change on their own next read, even if other users might still see a stale replica. It is a narrower, cheaper promise than full consistency, and often the only one that actually matters to the person waiting.
Avoid: “strong consistency” claimed when only this narrower guarantee is provided
Taught in: lesson 0032
Reconciliation check
Recomputing a result through an independent path and reporting where it disagrees with the original, rather than trusting either path alone. It catches the class of bug that passes every test but silently drifts from truth in production.
Avoid: “the tests pass” treated as proof of production correctness, no independent recomputation ever run
Taught in: lesson 0037
Detection time
How long a defect can hide before anyone notices it. It multiplies the cost of every other reversibility decision: a cheap-to-fix bug that hides for months does more damage than an expensive-to-fix one caught in code review.
Avoid: “we'll catch it in QA” without asking how long undetected defects actually survive
Taught in: lesson 0037
Golden master
A characterization test that asserts byte-for-byte equality against a previously recorded output, usually of a whole projection or report, rather than checking individual fields. It is coarse but cheap insurance against unintended change in code nobody fully understands yet.
Avoid: hand-written expected values for legacy output no one can explain, skipping characterization because “it's too big to test”
Taught in: the brownfield modernization case
Expected failure (xfail)
A test marked as a known-red result and reported as declared, tracked debt rather than a build failure — but one that fails the suite the moment it unexpectedly starts passing, forcing someone to notice and remove the marker. It keeps a known gap visible without letting it block delivery.
Avoid: a skipped test nobody revisits, commenting out a failing assertion
Taught in: the lab's fitness functions
Open vs closed enum
Whether consumers of a value set must handle values they have never seen (open) or may assume the set is complete (closed). Which one a field is changes whether adding a new value is a safe addition or a breaking change — and it has to be decided and documented, not discovered when a client crashes.
Avoid: adding a new enum value without checking who assumed the set was closed
Taught in: the capstone packet's state and contract review
Blue/green deployment
Running two complete environments and switching a router from one to the other to release, so rollback is switching the router back. It is reversible for the code but not for anything already written to shared data by the new version.
Avoid: “blue/green” treated as reversible for data as well as code
Taught in: lesson 0060
Transactional outbox
A pattern that persists the intent to publish a message in the same transaction as the business change, then has a separate worker deliver it from that table. It avoids the gap where a database commit succeeds but the matching event is lost, or the event goes out for a commit that then rolls back.
Avoid: publishing directly from application code inside the same request, “dual write” without a shared transaction
Taught in: lesson 0059
Compensating action
A new, forward-moving business fact that offsets an action already taken, used when the original action cannot be undone. A refund compensates a charge; it does not erase that the charge happened.
Avoid: “rollback” applied to an action with real-world side effects, deleting the record of what occurred
Taught in: lesson 0054
Batch size
The amount of change released in a single deployment. Larger batches sit behind higher release risk and slower diagnosis, because a failure has more candidate causes to sort through; the lever is usually available even when release frequency feels fixed.
Avoid: “we can't release more often” without examining what is bundled into each release
Taught in: lesson 0035
Effective configuration
The full set of settings a process actually runs with, after defaults, files, environment variables, and flags are all merged and overridden. It is frequently not what any single config file says, which is why “check the config” is not the same as knowing what is running.
Avoid: reading one config source and assuming it is the whole answer
Taught in: lesson 0029
Initiative leadership
The vocabulary for turning an ambiguous brief into measurable system and team decisions.
Product outcome
An observable change in user or business behaviour, stated with a baseline, target, cohort, and time boundary. It preserves freedom to change the solution while holding the initiative accountable for its effect.
Avoid: feature, deliverable, launch date, “ship the portal”
Taught in: lesson 0039
Assumption map
A map of beliefs by impact if false and strength of evidence, often grouped as value, usability, feasibility, and viability. The high-impact, weak-evidence corner becomes the discovery priority.
Avoid: risk list with no evidence axis, brainstorm vote
Taught in: lesson 0040
User story
A placeholder for a conversation about one user's need and the behaviour that may satisfy it. The sentence alone is not a requirement; examples provide confirmation.
Avoid: mini-specification, task disguised as user value
Taught in: lesson 0042
Story map
A two-dimensional product map: user activities run left to right as a journey, while alternatives and detail run downward. Horizontal lines cut coherent release or learning slices across the journey.
Avoid: flat backlog, dependency graph, feature inventory
Taught in: lesson 0042
Vertical slice
A narrow piece of observable behaviour that crosses the necessary technical layers end to end. It is small by cohort, rule, variation, or quality threshold—not hollowed into disconnected component tasks.
Avoid: “backend story”, schema-only milestone, horizontal layer
Taught in: lesson 0042
Architecture driver
A functional need, quality attribute, constraint, or risk important enough to shape system structure. It distinguishes between plausible designs; most requirements do not.
Avoid: every requirement, technology preference, generic “best practice”
Taught in: lesson 0043
Quality-attribute scenario
A measurable statement of how a system must respond: source, stimulus, environment, affected artifact, response, and response measure. It turns “fast” or “available” into design and test input.
Avoid: vague non-functional requirement, unmeasured “-ility”
Taught in: lesson 0043
Blast radius
The people, operations, data, components, or requests exposed when a change or failure goes wrong. Reducing blast radius makes learning and reversal cheaper.
Avoid: impact (too vague), server count alone
Architecture Decision Record
A short, durable record of one consequential decision: context, forces, credible options, decision, consequences, dissent, and a revisit trigger. A later decision supersedes rather than edits it.
Avoid: architecture novel, meeting transcript, rewritten history
Taught in: lesson 0046
Walking skeleton
The thinnest production-shaped end-to-end implementation: real build and deployment, representative boundaries and storage, telemetry, and tiny behaviour. It proves the delivery path before behavior fills it.
Avoid: throwaway prototype, complete infrastructure phase
Taught in: lesson 0047
Decision rights
An explicit statement of who makes the final call in a decision domain and whose advice they must seek. It enables broad collaboration without turning consensus into a veto.
Avoid: RACI for every task, authority left implicit
Work in progress
Work the team has started but not finished into evidence. Limiting it reduces queues, context switching, and elapsed time; swarming values system flow over individual utilization.
Avoid: everyone must stay busy, one item per specialist
Taught in: lesson 0048
Rollout gate
A decision boundary between exposure stages with explicit entry evidence, observation window, advance threshold, stop threshold, decider, and reversal. It turns launch into a controlled sequence.
Avoid: “looks good”, arbitrary percentage ramp, calendar-only gate
Taught in: lesson 0049
Cost of delay
The value lost for every unit of time an outcome arrives later, stated in money per week. It converts sequencing arguments from volume (“everyone says theirs is urgent”) into arithmetic, and it is what makes a queue visibly expensive.
Avoid: priority, “high impact”, deadline pressure
Taught in: the architecture economics supplement
Total cost of ownership
Everything a decision costs across its life: build or licence, operation, on-call, the coordination it imposes between teams, and eventual replacement. Comparing options on purchase price alone reliably picks the more expensive one.
Avoid: cost (unqualified), sticker price, story points
Taught in: the architecture economics supplement
Threat model
A structured answer to “what can go wrong here”: the data flows, the trust boundaries they cross, the abuse cases, and the mitigations that will actually be built. A design activity done with the team, not a review performed on it afterwards.
Avoid: penetration test, compliance checklist, “we use HTTPS”
Taught in: the security, privacy and accessibility supplement
Guardrail
A measure that an initiative commits not to make worse, stated with a threshold and an owner who watches it. It lets a team move fast on the outcome it is chasing while keeping an explicit promise about what it will not sacrifice to get there.
Avoid: an unstated assumption that nothing else will regress, a metric tracked with no threshold or owner
Taught in: lesson 0039
Anchoring
The first idea spoken in a discussion conditions every contribution that follows, whether or not it was any good. Facilitation techniques that collect ideas independently before anyone speaks exist specifically to defeat it.
Avoid: open-floor brainstorming with no independent generation step first
Taught in: lesson 0040
Holdback cohort
A population deliberately kept unexposed to a change so the exposed population's outcome can be read as a comparison against something, not just a before-and-after on a moving baseline. Without it, a metric's normal drift can be mistaken for the change's effect.
Avoid: “holdback control” (the same concept; prefer this term for consistency), a rollout with no unexposed comparison group
Taught in: lesson 0049 and the capstone packet's rollout section
Canary release
Releasing to a small, measured cohort first, with an explicit stop rule agreed before exposure begins. The cohort size and the stop rule are what make it a canary release rather than an ordinary staged rollout with no plan for what happens if it goes wrong.
Avoid: a small rollout with no pre-agreed stop threshold, “we're watching it” as the entire plan
Taught in: lesson 0049
Little's law
Average cycle time equals work in progress divided by throughput. It is the arithmetic reason that limiting WIP shortens how long any one thing takes to finish, without needing anyone to work faster.
Avoid: “just prioritize better” as a substitute for reducing WIP
Taught in: lesson 0048
Spike
A timeboxed investigation into a single question, with the result format and the discard rule for the code decided before it starts. A spike answers a question; it does not ship, and code written during one is thrown away rather than hardened in place.
Avoid: a spike that quietly becomes production code, an open-ended investigation with no timebox
Taught in: lesson 0046
Release train
A fixed, externally imposed deploy schedule that every team's change must fit around. It bounds how much change can go out at once and how fast a bad change can be rolled back, independent of how fast any one team can build.
Avoid: “we'll just deploy whenever it's ready” where a train schedule actually governs release
Taught in: lesson 0047
Reversal rehearsal
A timed practice run of the rollback path, done before each exposure stage of a rollout rather than left untested until a real incident forces it. A rollback plan that has never been executed is a hypothesis, not a safety net.
Avoid: a rollback plan that exists only as a paragraph in a document
Taught in: lesson 0049 and the artifact template pack's rollout contract
Manual seam
A step in a workflow deliberately left to a person instead of automated, with a stated capacity, an owner, and a trigger for when it should be removed. It is a legitimate design choice when the volume does not yet justify automation — as long as those three things are written down.
Avoid: an unstated manual step nobody owns, “temporary” workarounds with no removal trigger
Taught in: the artifact template pack's story map and example card
Evidence milestone
A milestone named for what becomes demonstrable at that point — a real request flowing end to end, a specific decision made — never for a percentage of work done. It forces a delivery map to say what anyone could go look at, not how much effort has been spent.
Avoid: “50% complete”, a milestone named after a task list item rather than an observable capability
Taught in: lesson 0047
STRIDE
A checklist of threat categories — Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege — walked over each data flow in a system to find where it can be attacked. It structures a threat model so coverage does not depend on which attacks happen to come to mind.
Avoid: an ad hoc list of “things that could go wrong”, a threat model with no per-category pass
Taught in: the security, privacy and accessibility supplement
Object-level authorization
Checking not just whether a principal may call an operation, but whether they may act on this specific object — this order, this account, this document. An API that checks the former and not the latter lets any authenticated user reach anyone else's data by changing an id.
Avoid: authentication mistaken for authorization, “the endpoint requires a login” treated as sufficient
Taught in: the security, privacy and accessibility supplement
Trust boundary
A point where data crosses between two parties who trust each other to different degrees — a client and a server, two services, a service and a third party. Every trust boundary is a place validation and authorization actually have to happen, because whatever crossed it cannot be trusted to have already been checked.
Avoid: validating once at the perimeter and trusting everything internal forever after
Taught in: the security, privacy and accessibility supplement
Data minimization
Not collecting or retaining a field you do not need. A field that was never stored has no retention policy to write, no access control to get wrong, and no breach exposure — it is the cheapest privacy control there is, because it is the absence of a liability rather than a defense of one.
Avoid: “we might need it later” as a reason to collect it now, retention policies used as a substitute for not collecting
Taught in: the security, privacy and accessibility supplement
Modernization, economics, and leadership
The vocabulary for changing a system you cannot stop, and for the organizational and economic reasoning that surrounds the decision.
Upgrade tax
The recurring cost of staying on a vendor's or platform's release cadence — migration work, breaking-change churn, retesting — paid whether or not the new version offers anything the team wants. It belongs in the total cost of ownership of a build-vs-buy decision, not as a one-time cost.
Avoid: comparing license price alone, “buy” decisions that never price in future migrations
Taught in: lesson 0027
Exit cost
What it costs to leave a dependency once adopted: building a second adapter, exporting data in a usable form, and verifying the replacement actually works. A dependency with a high exit cost has effectively been chosen for good, whatever the contract says about notice periods.
Avoid: “we can always switch later” without pricing what switching later would take
Taught in: lesson 0027
Sprout and wrap
Two techniques for adding tested behavior to code that resists testing: sprouting a new, tested method or class that the legacy code calls into, or wrapping an existing call with new tested logic around it. Both add new, safe surface without requiring the untestable code to be understood first.
Avoid: editing the untested method directly “just this once”, a full rewrite treated as the only alternative
Taught in: the brownfield modernization case
Enabling point
The place in existing code where a seam's behavior actually gets chosen — a single call, a single construction site. A seam without an identified enabling point is not yet usable: you know where behavior could vary, but not where to intervene to make it vary.
Avoid: naming a seam without locating the one place that decides it
Taught in: the brownfield modernization case
Dual-write window
The stage of an expand/contract migration where both the old and new data forms are written in a single transaction, so reads can switch to the new form and switch back if something is wrong — because the old form is still being kept current underneath.
Avoid: switching reads to the new form before writes are dual, treating dual-write as the finish line rather than a stage
Taught in: the brownfield modernization case
Compensating control
A safeguard your own team owns and operates that offsets a risk introduced by a dependency you do not control — a validation layer around a flaky third party, a circuit breaker around an unreliable service. It substitutes for fixing the dependency when fixing it is not an option.
Avoid: accepting a dependency's risk with no mitigation because “it's their problem”
Taught in: the brownfield modernization case
Repayment trigger
An observable event — not a calendar date — that makes a technical-debt ledger item due: a traffic threshold crossed, a second team blocked by it, a vendor deprecation. Dates get pushed; a trigger tied to an observable fact does not.
Avoid: “we'll fix it next quarter” as the only trigger a debt item gets
Taught in: the brownfield modernization case's debt ledger
Superseding ADR
A new Architecture Decision Record that replaces an earlier one's decision while leaving the earlier record's text untouched. The history of what was believed and why stays intact; only the current answer changes, and anyone can see the sequence that led there.
Avoid: editing an old ADR in place to reflect the new decision, deleting a superseded record
Taught in: the decision-change simulations
Admission control
Refusing new work once a guardrail is reached, explicitly and visibly, instead of letting it queue silently until something else fails. It trades a clear, immediate rejection for an invisible, delayed one.
Avoid: unbounded queues that fail somewhere else instead, silently dropping requests under load
Taught in: the decision-change simulations
CD3 — Cost of Delay Divided by Duration
The ratio of an item's cost of delay to how long it will take to finish. Sequencing a single queue by CD3, highest first, is the rule that maximizes total value delivered over time — it is what turns “everything is urgent” into an actual ordering.
Avoid: sequencing by cost of delay alone, ignoring how long each item takes
Taught in: the architecture economics supplement
Compatibility window
A stated period during which a producer commits to serving both the old and new shape of an interface, so consumers have a bounded, known amount of time to migrate. An unstated window is really a promise to serve the old shape forever, because no consumer knows when it is safe to stop relying on it.
Avoid: deprecating a shape with no date attached, “we'll give people time” left unspecified
Taught in: the multi-team architecture supplement
Advice process
A decision-rights model where anyone may decide in their own area of authority, provided they first seek advice from those affected and those with relevant expertise, and record what they heard. It scales autonomy without needing every decision to become a consensus meeting.
Avoid: unilateral decisions with no advice sought, consensus required for every call
Taught in: the multi-team architecture supplement
Interaction mode
The way two teams work together — collaboration, X-as-a-service, or facilitating — named explicitly, dated, and understood to be temporary rather than a permanent structural fact. The same two teams may need a different mode next quarter, and calling it out is what makes changing it possible.
Avoid: an interaction mode that is never named and so never revisited
Taught in: the multi-team architecture supplement
Forecast range
A prediction stated as a confidence level, an interval, the variable that is driving the uncertainty, and the date at which the interval will narrow. It replaces a single confident-sounding date with something a listener can actually act on.
Avoid: a single point estimate presented with no uncertainty, “trust me” in place of a stated confidence
Taught in: the technical influence supplement
Disagree and commit
Recording a dissent in the dissenter's own words, alongside a specific trigger that would reopen the decision, and then executing the decision as made. It preserves the disagreement as evidence instead of pretending consensus existed, while still letting the team move.
Avoid: silence mistaken for agreement, a decision reopened by anyone at any time with no stated trigger
Taught in: the technical influence supplement
DRI — Directly Responsible Individual
The person accountable for moving one specific decision to a close, distinct from whoever has authority over the broader area or does the most work on it. Naming a DRI answers “who makes this call happen” when a decision-rights model alone leaves it ambiguous which person, today, is the one to chase.
Avoid: a decision with no single accountable person, mistaking a DRI for the only person allowed to have an opinion
Taught in: lesson 0048
Practice, assessment and evidence
The vocabulary for showing that the judgment above is real, in a form somebody else can review.
Capstone
The complete worked initiative packet — frame through closeout — that a learner produces and then compares against a strong and a deliberately weak version of the same problem. It is the course's evidence artifact: judgment made reviewable.
Avoid: final project, portfolio piece (that is what it becomes, not what it is)
Taught in: the worked capstone
Rubric (behaviourally anchored)
A scoring guide whose levels describe observable behaviour rather than quality adjectives: Developing, Practicing, Lead-ready, Architect-ready. Every score cites evidence in the learner's own work, and more than one design can score well.
Avoid: checklist, grade, a job title used as a score
Taught in: the capstone rubric
Decision-change simulation
An exercise that supplies incomplete information, requires a committed direction, and then changes the evidence — a vendor misses its latency target, the deadline loses four weeks, volume arrives ten times over forecast. It scores coherent adaptation, not hindsight-perfect design.
Avoid: case study with one right answer, post-mortem, “what would you have done”
Taught in: the decision-change simulations
Entry diagnostic
Scenario questions that test applied judgment rather than self-reported familiarity, used to route a learner to the earliest phase where their judgment stops being consistent. Its exit counterpart uses comparable scenarios, so growth is measured rather than felt.
Avoid: self-assessment survey, “rate your confidence 1–5”, prior job titles
Taught in: the entry diagnostic
Anonymization
Stripping the identity of the organization, the people, and enough implementation detail from a real workplace artifact that it can be shared and discussed, while keeping the forces and trade-offs that made the decision hard intact. An anonymized artifact that also removes the trade-offs is no longer useful evidence of judgment.
Avoid: renaming the company but leaving other identifying detail in place, deleting so much detail that the actual decision disappears
Taught in: the portfolio guide
Behaviorally anchored rating
A scoring level defined by what the work actually shows — the options compared, the evidence cited, the consequences named — rather than by an adjective like “good” or “excellent” left to the rater's judgment. Two different raters looking at the same evidence should reach the same level.
Avoid: a rating scale of unanchored adjectives, “I know it when I see it” grading
Taught in: the capstone rubric
Adversarial prompt
A prompt to an AI collaborator that demands an enumerated sequence of steps and the mechanism behind a claim, rather than accepting a summarized conclusion. It is a technique for getting past confident-sounding output to the reasoning that can actually be checked.
Avoid: accepting an AI-generated conclusion because the phrasing sounds authoritative
Taught in: the AI collaboration supplement
Evidence rule
AI-generated artifacts — analyses, summaries, generated tests — never substitute for evidence from a real user, the actual domain, or production. They can accelerate reaching that evidence; they cannot stand in for it.
Avoid: citing an AI-generated analysis as if it were user research, treating a generated test suite as proof of correctness
Taught in: the AI collaboration supplement
Source page: reference/glossary.html
Reference document · the forces, not the rules
Design Principles
Principles are not rules you obey; they are forces you balance. Each entry gives the principle's job, the check that applies it, and — the part most lists omit — the way it fails when it is followed as a rule. Terms follow the glossary; every entry names the lesson that teaches it.
The nineteen, at a glance
| Principle | Question it answers | Misused when… | Lesson |
|---|---|---|---|
| High cohesion | Does this module have one purpose? | …you split until every class has one method. | 0001 |
| Low coupling | Can this module change alone? | …you hide every dependency behind an interface. | 0001 |
| SRP | How many reasons does this have to change? | …you fragment code to satisfy an accountant. | 0006 |
| DRY | Where is this knowledge stored once? | …you deduplicate text that encodes different knowledge. | 0002 |
| KISS | What is the least complex solution that works? | …you mistake familiar complexity for simplicity. | 0002 |
| YAGNI | What evidence is there that this will be needed? | …you refuse every generality, even the free ones. | 0030 |
| Information hiding | Which decisions here are likely to change? | …you hide what callers must reason about. | 0019 |
| Deep modules | How much work does this interface hide? | …every class gets an interface “for testability”. | 0034 |
| Law of Demeter | Is this talking to a stranger? | …you forbid every chain, including the owner's own. | 0003 |
| Tell, don't ask | Is this object doing its own work? | …you write methods that return and test nothing. | 0003 |
| Composition > inheritance | Is behaviour shared by structure or delegation? | …you ban inheritance entirely. | 0007 |
| Depend on abstractions | Does the arrow point at a stable interface? | …you add an interface per class. | 0009 |
| Separate creation from use | Who decides which concrete thing runs? | …you inject everything, including constants. | 0023 |
| Open–closed | Does a new case add code or edit code? | …you build the extension point before case two. | 0008 |
| Start with the data | What flows through this, and what is true of it? | …“the data” quietly means the database schema. | 0012 |
| Invalid states unrepresentable | Can a caller build a broken value at all? | …edge validation is called an invariant. | 0011 |
| Fail fast | Where does this failure belong? | …a per-item failure kills the whole batch. | 0014 |
| Define errors out of existence | Can this stop being an error case? | …the error is swallowed rather than designed away. | 0014 |
| Design it twice | What is the genuinely different second design? | …the second design decorates the first. | 0027 |
The shape of one module
What a single module owes its readers.
High cohesion
Job: keep everything that changes together, together. A module has high cohesion when its parts serve one purpose and must be understood together.
Check: would a new engineer describe this module in one sentence, with no comma and no “and”? If the honest sentence needs a conjunction, you have two modules.
Signs of trouble: a function that does “a few things” in sequence; a class named Utils; a module whose name needs a conjunction (“orders and payments”).
Misused when: you split until every class has one method. Method count is not cohesion; change source is.
SRP — one reason to change
Job: gather the code that changes for the same reason; separate the code that changes for different reasons. The unit is the reason, not the function.
Test: “we must change X because the tax rules changed” and “we must change X because we switched databases” — if both sentences are true of one module, it has two reasons.
Where to find the reasons: git log on the file. The commit subjects are the change sources, already written down by the people who made them.
Misused when: you split a cohesive class because it has several methods, or fragment code to satisfy an accountant.
DRY — once and only once
Job: store each unit of knowledge in one place: the interest rate, the field mapping, the business rule.
Subtlety: two snippets can be textually identical and encode different knowledge. Merging them is false DRY — coupling between facts that will diverge.
Rule of three: wait for three occurrences before extracting; by the third the shape of the shared thing is visible rather than guessed.
Misused when: you deduplicate text instead of knowledge.
# identical today, owned by different parties — two facts, not one
def fee_for_bank_transfer(amount: Money) -> Money:
return amount * Decimal("0.01") # set by our bank
def fee_for_wire_transfer(amount: Money) -> Money:
return amount * Decimal("0.01") # set by the clearing house
KISS — keep it simple
Job: minimise the complexity you must carry. Complexity is what makes code hard to understand and change — it is the thing you are designing against.
Note: KISS does not mean “shortest code”. A clear twenty-line function beats a clever five-line one. It means least total cognitive load.
Misused when: you mistake familiar complexity for simplicity — a framework you know well still costs every newcomer.
Taught in: lesson 0002
YAGNI — you aren't gonna need it
Job: keep unproven generality out. Every “someday we'll need X” hook is code with no evidence, paid for on every future read.
Balance: YAGNI is a trade-off, not a dogma. When the generality is nearly free and the retrofit is expensive, take it; see generality vs specificity.
Misused when: you refuse every generality, including the free ones.
# the tell: a parameter no caller has ever passed
def export(rows, fmt="csv", *, compress=False, encoding="utf-8"):
...
export(rows) # …and every call site in the repo looks exactly like this
Information hiding
Job: conceal the design decisions most likely to change — storage format, protocol details, algorithm choice — behind a stable interface.
Check: name the decision the module is hiding. If you cannot name one, the module is a pass-through, not an abstraction.
Pair with: Ports & Adapters as the mechanism; the deep module as the goal.
Misused when: you hide what callers must reason about — a timeout, a cost, a failure mode — and they discover it in production.
Deep modules
Job: maximise the ratio of hidden implementation to exposed interface. Depth, not size, is what makes a module worth having.
Check: count the concepts a caller must learn against the work they get. A large interface over a small implementation is a shallow module — pure overhead.
Misused when: every class gets an interface “for testability”, doubling the vocabulary without hiding a single decision.
# deep: one concept in, all the storage decisions hidden
orders.get(order_id)
# shallow: the caller learns sessions, queries, rows and mapping
session.execute(select(OrderRow).where(OrderRow.id == order_id)).scalar_one()
The relationships between modules
Who may know about whom, and how behaviour is shared.
Low coupling
Job: make modules replaceable. A module with low coupling can change, be tested, or be swapped without its neighbours noticing.
Check: if I rewrite module A's internals, how many files must change? One? Good. Fifteen? That is change amplification.
The degrees, worst to best:
| Degree | What it looks like | Why it hurts |
|---|---|---|
| Content | other._items.append(x) | You depend on internals the owner never promised. |
| Common | both modules read config._settings | Behaviour depends on who ran first — global coupling. |
| Control | send(data, urgent=True) | The caller must understand the callee's branches to use it. |
| Stamp | def price(order: Order), using one field | You depend on a whole structure to read a part of it. |
| Data | def price(total: Money) | The floor: you depend on exactly what you use. |
Misused when: you hide every dependency behind an interface — indirection is not decoupling, and each layer is read by everyone forever.
Law of Demeter
Job: limit each method's acquaintances to its immediate neighbours: itself, its own fields, its arguments, objects it creates, and the direct members of its own collections.
Warning sign: a train wreck — the caller knows the internal geography of three objects it does not own.
Fix: move the behaviour to the object that owns the data. This is Tell, don't ask.
Misused when: you forbid every chain. The law limits callers, not owners: a chain inside the object that owns the data is fine.
# wreck: the caller depends on Customer's and Address's shape
city = order.customer.address.city
# tell: the owner answers, and the chain lives where the data does
if order.ships_free_to(FREE_SHIPPING_CITIES):
...
Taught in: lesson 0003
Tell, don't ask
Job: ask objects to do work rather than to hand over their data so you can do it for them. Behaviour belongs with the state it needs.
Check: does the caller pull fields out of an object and then decide something about them? That decision belongs inside the object.
Misused when: you create command methods that return nothing and can be asserted on by nothing — the pendulum swung past testability.
Composition over inheritance
Job: share behaviour by delegating to held objects instead of inheriting from ancestors. Inheritance fuses the subclass to the superclass's every change; composition leaves a visible seam.
Where inheritance earns its keep: polymorphism against a stable interface, where callers depend on the base type and implementations vary.
Smell: subclassing for reuse (“I need the logging, I'll extend Logger”) — a composition scenario in disguise.
Misused when: you ban inheritance entirely, including the genuine is-a hierarchies and the ABCs with real shared behaviour.
# fuse: FileBackedLogger cannot outlive Logger's constructor
class FileBackedLogger(Logger): ...
# seam: it holds a sink, owns its own lifecycle, and takes a fake in tests
class FileBackedLogger:
def __init__(self, sink: Logger, path: str) -> None: ...
Depend on abstractions
Job: point dependency arrows at stable interfaces owned by the domain, not at volatile concrete implementations.
Test: who owns the interface? If the database driver or the payment SDK defines what your domain looks like, the arrow points the wrong way.
Naming test: a port named after a vendor (StripeGateway) has already surrendered the vocabulary. Name it for the need: PaymentGateway.
Misused when: you add an interface per class. Ports live at the application boundary, not between every pair of objects.
# the domain declares what it needs; infrastructure conforms
class PaymentGateway(Protocol):
def charge(self, amount: Money, token: str) -> str: ...
class StripeAdapter: # lives outside the domain
def charge(self, amount: Money, token: str) -> str: ...
Separate creation from use
Job: the code that decides which implementation runs should not be the code that runs it. Factories, dependency injection and the composition root exist for this.
Test: can you run your domain logic against a fake adapter without changing one line of the domain?
Where creation goes: one composition root — a single visible function where every concrete thing is constructed.
Misused when: you inject everything, including constants and value objects, and the wiring becomes the program.
def build_app() -> App: # the only place technology is named
clock: Clock = SystemClock()
orders: OrderRepository = SqlAlchemyOrderRepository(make_session())
return App(checkout=CheckoutService(orders, StripeAdapter(stripe), clock))
Open–closed
Job: let a module gain new behaviour without being edited. New cases arrive as new code, not as new branches inside old code.
Test: when the fifth payment method arrives, do you add a file or edit a function that four other methods depend on?
Mechanism: the extension point — a protocol, a callable, a registry. It is Strategy read as a principle.
Misused when: you build the extension point before the second case exists — that is speculative generality, and YAGNI wins.
# closed for modification: the caller never grows a branch
def price(total: Money, discount: Discount) -> Money:
return total - discount(total)
# open for extension: a new policy is a new function
def loyalty_discount(years: int) -> Discount: ...
The designer's working method
Not properties of code — habits that produce them.
Start with the data
Job: name the data and its shape before naming classes or functions. What flows through the system, what must stay true of it, and who owns it — those answers decide the structure.
Why it works: most design arguments are really disagreements about the data model, conducted in the vocabulary of classes. Settle the data and the class-or-function question usually answers itself.
Practical form: write the value objects and the transitions first, with no I/O. If they read like the domain, the rest is plumbing.
Misused when: “the data” is taken to mean the database schema, and storage decisions leak inward before the domain exists.
# decide this first: what is true of a price, always?
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str # …and the class-vs-function question answers itself
Make invalid states unrepresentable
Job: design types and constructors so that a broken value cannot be built at all. The bugs you cannot write are cheaper than the bugs you catch.
Test: can a caller reach a field directly and put the object in a state no method would allow? If yes, you have a convention, not an invariant.
Scope limit: an invariant that spans two objects cannot be enforced by either — that is what an aggregate or a transaction is for.
Misused when: validation is added at the edges and called an invariant, leaving every other path unguarded.
class Account:
def __init__(self, balance: Money) -> None:
self._balance = balance # private: no caller can assign it
def withdraw(self, amount: Money) -> None:
if amount > self._balance:
raise InsufficientFunds() # the object refuses, for every caller
Fail fast, at the right boundary
Job: fail as close to the cause as possible, loudly, so the error arrives with its address attached instead of detonating three layers away.
The three boundaries: input (refuse before work starts), invariant (the domain refuses the transition), integration (the outside world's failure surfaces as a failure).
Companion rule: never convert an error to None at a boundary where the error still means something. Convert only where a default genuinely makes sense, and name that decision.
Misused when: “fail fast” becomes “crash the batch”: a per-item failure should be recorded per item, not used to abandon forty-nine good ones.
Define errors out of existence
Job: change the interface so the error case stops being a case. The best error handling is the error that cannot occur.
Examples: a delete that succeeds on an absent key; a range that is empty rather than invalid; a constructor that refuses so no downstream check is needed.
Tension: it is the opposite move to fail-fast, and both are right — fail fast on a violated expectation, define out of existence a case that was never really an error.
Misused when: the error is defined away by swallowing it. Returning a default is only legitimate when the default is genuinely correct, not merely convenient.
Taught in: lesson 0014
Design it twice
Job: produce a second, genuinely different design before committing to the first. The comparison is what surfaces the criteria you were deciding by without knowing it.
Cost: usually under an hour, on a decision you will live inside for a year.
Where it pays most: irreversible choices — data models, public interfaces, boundaries between teams.
Misused when: the second design is a decoration of the first. If both options share the same shape, you have designed once and written it twice.
Sources: ArjanCodes, The 7 Principles of Modern Software Design (The Software Designer Mindset); Ousterhout, A Philosophy of Software Design; Martin, Clean Architecture; McConnell, Code Complete ch. 5; Hunt & Thomas, The Pragmatic Programmer; Evans, Domain-Driven Design.
Source page: reference/design-principles.html
Reference document · quick lookup
Patterns Quick Reference
Patterns are names for recurring structures that solve recurring problems. Learn to recognise them emerging in code before you learn to apply them deliberately — which is why every entry here leads with the problem, not the shape. Terms follow the glossary; each pattern names the lesson that teaches it.
Find the pattern by the problem
Patterns are answers. Read this column first — if none of these is your problem, none of these is your pattern.
| The problem you have | The pattern | Lesson |
|---|---|---|
| One behaviour varies; the caller shouldn't. | Strategy | 0004 |
| Their interface isn't the one you need. | Adapter | 0008 |
| Four calls always happen in the same order. | Facade | 0008 |
| Retry/cache/logging is creeping into callers. | Decorator | 0021 |
| Technology is defining what the domain looks like. | Ports & Adapters | 0009 |
| Nobody can find where things get constructed. | Composition root | 0023 |
| Two primitives travel together and drift apart. | Value object | 0012 |
| A rule spans objects and nobody enforces it. | Aggregate | 0012 |
| The domain is writing SQL. | Repository | 0013 |
| Two writes must succeed or fail together. | Unit of work | 0013 |
| Another team's words are leaking into your model. | Anti-corruption layer | 0036 |
| One event should trigger four unrelated reactions. | Message bus | 0025 |
| The dataset no longer fits in memory. | Generator pipeline | 0018 |
| Domain tests need an event loop. | Async boundary | 0024 |
| A large system must be replaced without a rewrite. | Strangler fig | 0035 |
| Forty call sites must move to a new component. | Branch by abstraction | 0035 |
| A column must be renamed in live data. | Expand / contract | 0035 |
| The code is ready; the users are not. | Feature flag | 0035 |
| It works and you don't know why. | Characterization test | 0005 |
Structural patterns
Shapes that decide who talks to whom.
Strategy
Problem: one behaviour varies (pricing, shipping, discount) while its context stays stable.
Shape: an interface with one method; several implementations; the context holds one and delegates. In Python the interface can simply be a callable.
Discount = Callable[[Money], Money] # the interface, at its cheapest
def final_price(total: Money, discount: Discount) -> Money:
return total - discount(total) # the context never names a variant
Use when: you see an if/elif chain selecting behaviour, or a class with one method that branches on a type field.
Don't: force it for a single variation you could pass as a function — and don't extract at two branches with no third in sight.
Adapter
Problem: a client needs interface A; a service speaks interface B, and B is not yours to change.
Shape: a wrapper that implements A and translates calls into B.
class PaymentGateway(Protocol): # your interface, your words
def charge(self, amount: Money, token: str) -> str: ...
class StripeAdapter: # implements yours, speaks theirs
def charge(self, amount: Money, token: str) -> str:
return self._client.payment_intents.create(...).id
Use when: integrating third-party libraries, old code, or anything whose interface you don't control.
Don't: let the adapter leak B's quirks — its error types, its status codes, its vocabulary — through to the client.
Facade
Problem: clients must orchestrate several subsystems to do one recurring thing.
Shape: one class with a few coarse methods that drive the subsystems underneath.
class CheckoutFacade:
def place_order(self, cart: Cart, token: str) -> Order:
self._inventory.reserve(cart) # one coarse method…
charge = self._payments.charge(cart.total, token)
return self._shipping.create(cart, charge) # …four subsystems hidden
Use when: a setup/teardown sequence recurs across callers; you want one stable face over volatile parts.
Don't: give the facade the subsystems' fine-grained methods — that is relocation, not simplification.
Taught in: lesson 0008
Decorator (wrapper)
Problem: a cross-cutting concern — retry, cache, timing, audit — must apply to one collaborator without entering the domain.
Shape: a class implementing the same interface it wraps, delegating to the inner object and adding behaviour around it.
class RetryingGateway: # same port in, same port out
def __init__(self, inner: PaymentGateway, attempts: int = 3) -> None:
self._inner, self._attempts = inner, attempts
def charge(self, amount: Money, token: str) -> str:
... # retry here; the caller sees no change
Use when: you would otherwise sprinkle the same try, the same cache lookup, or the same timer through every caller.
Don't: stack five decorators whose order matters and is written down nowhere; the wiring becomes the behaviour.
Ports & Adapters (hexagonal)
Problem: the domain must not know about databases, APIs, files — or the clock.
Shape: the domain defines ports (the interfaces it needs); the outside world supplies adapters implementing them; dependency arrows point inward.
# domain/ports.py — everything the core needs from the world
class Clock(Protocol):
def now(self) -> datetime: ...
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
Use when: the domain is the asset and the technology around it is expected to change, or must be faked in tests.
Don't: add a port for every class. Ports exist at the application boundary, not between every pair of objects.
Composition root
Problem: construction of concrete things is scattered, so swapping one means hunting through the codebase.
Shape: one visible function, near the entry point, where every adapter is constructed and injected. Nothing else names a technology.
def build_app() -> App: # the only function that says "Stripe"
clock: Clock = SystemClock()
orders: OrderRepository = SqlAlchemyOrderRepository(make_session())
return App(checkout=CheckoutService(orders, StripeAdapter(stripe), clock))
Use when: always, once there is more than one adapter — it is what makes “separate creation from use” real.
Don't: let it become a service locator that objects reach into; the root pushes dependencies, it is never pulled from.
Domain patterns
Shapes that keep the rules true.
Value object
Problem: a concept with no identity (money, address, interval) is being passed around as loose primitives.
Shape: an immutable class with equality by value — usually a @dataclass(frozen=True) — that validates itself on construction.
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self) -> None:
if self.amount < 0: # a broken Money cannot be built
raise ValueError("amount cannot be negative")
Use when: you catch yourself passing (amount, currency) pairs, or validating the same tuple in ten places.
Don't: make entities value objects — identity-bearing things are not interchangeable.
Taught in: lesson 0012
Aggregate
Problem: several objects must change consistently, and no single object is in charge of the rule.
Shape: a cluster with one root; all external changes go through the root, which enforces the invariants of the whole cluster.
class Order: # the root guards the cluster
def add_line(self, product: ProductId, qty: int, price: Money) -> None:
if self._status is not Status.PENDING:
raise OrderLockedError() # nobody reaches into _lines directly
self._lines.append(OrderLine(product, qty, price))
Use when: a rule spans objects and must hold after every change — “an order cannot be edited once confirmed”, “a customer with open orders cannot be deleted”.
Don't: make the whole object graph one aggregate; that serialises every change and kills concurrency.
Taught in: lesson 0012
Repository
Problem: the domain needs to load and save aggregates without knowing how they are stored.
Shape: a collection-like interface — get, add, delete, plus queries phrased as domain questions — implemented by a storage adapter.
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
def add(self, order: Order) -> None: ...
def get_unshipped(self) -> list[Order]: ... # a question, not a filter
Use when: storage is a detail that will change, or must be replaced by a fake in tests.
Don't: expose the ORM's query API through the repository; the leak test is whether an in-memory fake is a drop-in.
Taught in: lesson 0013
Unit of work
Problem: several repository operations must commit or roll back together, and the domain must not learn that transactions exist.
Shape: an object owning the transaction boundary, exposing the repositories that live inside it.
with uow: # one transaction boundary
order = uow.orders.get(order_id)
order.confirm()
uow.commit() # all of it, or none of it
Use when: any write that touches more than one aggregate, or more than one repository.
Don't: commit inside repositories — once each one commits itself, nothing can be grouped.
Anti-corruption layer
Problem: another context or vendor has a model whose words mean different things, and it is leaking into yours.
Shape: a translation layer at the boundary: their payload in, your domain objects out. Only your vocabulary crosses inward.
def to_domain(payload: dict) -> Shipment: # their words in, ours out
return Shipment(
tracking=TrackingCode(payload["trkNo"]),
status=STATUS_MAP[payload["st"]], # "st" never reaches the domain
)
Use when: integrating a legacy system, a partner API, or another team's bounded context.
Don't: share a DTO package between two contexts — that is the same leak, with a build dependency added.
Taught in: lesson 0036
System patterns
Shapes for data and time.
Message bus / event-driven core
Problem: components must react to each other without knowing each other.
Shape: a bus routing events (facts that happened) or commands (intentions) to registered handlers.
class MessageBus:
def publish(self, event: object) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)
bus.register(OrderConfirmed, send_confirmation_email) # wiring, not domain
Use when: a workflow has many side effects — “when an order is confirmed: email, invoice, stock, metrics”.
Don't: use it where a direct call is clearer; the bus buys decoupling by hiding the flow.
Taught in: lesson 0025
Generator pipeline
Problem: processing large or unbounded data without loading it all into memory.
Shape: lazy generator stages chained together — iterable in, items out — each holding one item at a time.
def error_entries(lines: Iterable[str]) -> Iterator[LogEntry]:
for line in lines: # iterable in, items out
entry = parse(line)
if entry.level == "ERROR":
yield entry
total = summarize(error_entries(read_lines("app.log"))) # flat memory
Use when: streaming files, API pages, or infinite sequences; and wherever the stages should stay separately testable.
Don't: chain generators where a list comprehension reads better and memory is not the constraint.
Taught in: lesson 0018
Async boundary
Problem: I/O concurrency keeps leaking into business logic, so every rule needs an event loop to test.
Shape: the domain stays sync and pure; an async service layer wraps it; await lives only where I/O does.
async def refund_order(order: Order, gateway: AsyncGateway) -> Refund:
result = await gateway.refund(order.total) # I/O at the edge
return apply_refund(order, result.refund_id) # sync rule, no loop needed
Use when: you want domain logic testable without event loops, and callable from a script or a cron job.
Don't: sprinkle async through the whole stack because one endpoint is slow.
Taught in: lesson 0024
Evolution patterns
Shapes for changing a system that is already running.
Strangler fig
Problem: replace a large working system without a rewrite.
Shape: intercept traffic at the edge, route new requests to the new implementation, grow its coverage, delete the old in slices.
def route(request: Request) -> Response:
if request.path in MIGRATED: # the fig grows one route at a time
return new_system.handle(request)
return legacy.handle(request)
Use when: the old system works and is large; a rewrite is a second-system temptation.
Don't: run the fig forever — the pattern is finished only when the last route moves and the old system is deleted.
Taught in: lesson 0035
Branch by abstraction
Problem: replace a component used in dozens of places, in small reviewable merges.
Shape: introduce an interface, migrate all users to it, swap the implementation behind it, delete the old one.
class StockService(Protocol): # 1. insert the seam
def reserve(self, sku: str, qty: int) -> None: ...
# 2. migrate all 40 callers to the seam 3. swap the implementation
# 4. delete the old one — every step small, reviewable, reversible
Use when: the component has many call sites and you want every step mergeable and reversible.
Don't: skip step two — migrating callers before the swap is what keeps each merge small.
Taught in: lesson 0035
Expand / contract
Problem: rename, retype or drop a column that live code is reading and writing.
Shape: four deploys — expand (write both), backfill, switch reads, contract (stop writing, then drop).
# 1. expand add `delivery_address`; write BOTH; keep reading `address`
# 2. backfill copy history in idempotent batches
# 3. switch read `delivery_address`; still writing both
# 4. contract stop writing `address`; only then drop it
Use when: any schema change to data that already exists; it is the only shape that stays reversible at every step.
Don't: compress it into one deploy because the change “looks small”; that is the irreversible version.
Taught in: lesson 0035
Feature flag
Problem: code must reach production before its behaviour should reach users.
Shape: a runtime switch separating deploy from release, so rollback is a flip rather than a revert.
def checkout(cart: Cart, flags: FeatureFlags) -> Order:
if flags.is_enabled("new_pricing"): # deployed, not yet released
return new_pricing_checkout(cart)
return legacy_checkout(cart)
Use when: dark launches, staged rollouts, and any change you want to be able to undo in seconds.
Don't: let it become permanent — a flag that stops varying is config debt and an extra branch forever.
Taught in: lesson 0035
Seam / characterization test
Problem: change legacy or AI-generated code you do not trust.
Shape: find or create a seam, lock the current behaviour with tests, then restructure behind them.
def test_characterizes_current_behaviour() -> None:
# not "what it should do" — what it does today, locked before the refactor
assert legacy_total(SAMPLE_ORDER) == Decimal("118.60")
Use when: anything where “it works, but I don't understand it” — including code generated five minutes ago.
Don't: write the test for what the code should do; a characterization test records what it does, so the refactor can be proved safe.
Sources: Gamma et al., Design Patterns; Freeman et al., Head First Design Patterns; Evans, Domain-Driven Design; Vernon, Implementing DDD; Percival & Gregory, Architecture Patterns with Python; Cockburn, hexagonal architecture; Fowler, Strangler Fig, Branch by Abstraction and Parallel Change; Feathers, Working Effectively with Legacy Code.
Source page: reference/patterns.html
Reference document · Master Designer in one page
Trade-Offs Cheat Sheet
At Master Designer level there are no perfect solutions — only forces. For each trade-off: the two forces, the questions that decide it, the default posture when evidence is thin, and — the entry to read in hindsight — the signal that you chose wrong.
How to run a trade-off
Three moves turn an argument into a decision, and they are the same three every time.
- Write both columns. Not the one you favour — both, with their hidden costs. Most trade-off arguments are two people each holding one column.
- Name the unit. Almost none of these are decided per system. Consistency is decided per operation, sync/async per workflow, build/buy per component. A global answer is usually a refusal to decide.
- Name the reversibility. If the choice can be undone in a deploy, pick fast and learn. If it cannot, spend the hour on designing it twice.
| Trade-off | Decided per… | Default when evidence is thin | Lesson |
|---|---|---|---|
| Build vs Buy | component | buy, until the differentiator argument is made | 0027 |
| Abstraction vs Duplication | occurrence | duplicate, until three copies change together | 0028 |
| Configuration vs Convention | setting | convention, until variation is measured | 0029 |
| Generality vs Specificity | module | specific, until the second real case | 0030 |
| Synchronous vs Asynchronous | workflow | sync, until a concrete reason demands async | 0031 |
| Consistency vs Availability | operation | availability plus an idempotent write path | 0032 |
| Coupling vs Coordination | boundary | independence, with the contract written down | 0033 |
| Performance vs Maintainability | hot path | clean, until a number says otherwise | 0034 |
| Stability vs Speed of Change | change | small reversible steps — buy both | 0035 |
| Isolation vs Integration | concept | isolate where the meanings differ | 0036 |
| Correctness vs Delivery Speed | change | speed if reversible, apparatus if not | 0037 |
| Short-Term Productivity vs Long-Term Maintainability | loan | borrow if you record it | 0038 |
Complexity trade-offs
Build vs Buy
Forces: control and fit (build) vs time and maintenance burden (buy).
Deciding questions: Is this your business's core differentiator? Will a library do 80% now and 80% in three years? Who owns the upgrade path — you or the vendor? Who is paged at 3 a.m. either way?
Default: buy (or take the open-source package) until the differentiator argument is made explicitly. Choose boring technology.
Signal you chose wrong: you built it, and the team's best engineers spend their quarters on a problem no customer has ever mentioned. Or: you bought it, and half the codebase is workarounds for the 20% that never fitted.
Abstraction vs Duplication
Forces: one source of truth (abstraction) vs freedom to diverge (duplication).
Deciding questions: Will these two things change for the same reason? Is the shape proven by three occurrences, or guessed from one? How often have the copies actually changed together?
Default: duplication with intent beats a wrong abstraction. Wrong abstractions are the expensive kind — Sandi Metz.
Signal you chose wrong: the shared function has grown a kind parameter and a branch per caller. Every new case is a negotiation with the shape, and no caller can change alone.
Configuration vs Convention
Forces: explicit flexibility (configuration) vs predictable defaults (convention).
Deciding questions: How often does this actually vary per deployment or per tenant? Who is the operator — engineers or non-technical users? Is the convention documented and discoverable?
Default: convention for the common case; configuration only where variation is measured.
Signal you chose wrong: a settings file of forty knobs, of which four have ever been changed — and an incident caused by the thirty-sixth being set to something nobody tested.
Taught in: lesson 0029
Generality vs Specificity
Forces: reusable and future-proof (general) vs simple and fit-for-purpose (specific).
Deciding questions: What is the actual evidence of a second use case? Does the general version make today's case simpler, or only tomorrow's cheaper? What does the generalisation cost in parameters, indirection and tests?
Default: specific until the second use case is real — then generalise, guided by the rule of three.
Signal you chose wrong: the module has parameters no caller passes and hooks nothing hooks. Reading the one real use case means reading past machinery built for cases that never arrived.
Taught in: lesson 0030
System trade-offs
Synchronous vs Asynchronous
Forces: immediate, simple, debuggable (sync) vs decoupled, resilient, scalable (async).
Deciding questions: Must the caller have the result to continue? Can you answer what happens when a handler dies mid-way, when events arrive out of order, when one is delivered twice?
Default: sync unless a concrete reason — latency, load, decoupling — demands async. Async everywhere turns every feature into a distributed-systems problem.
Signal you chose wrong: an incident whose timeline nobody can reconstruct, because the workflow lives in six handlers and no single trace covers it. Or: a queue whose dead-letter count nobody has looked at in a month.
Consistency vs Availability
Forces: every read sees the last write (consistency) vs every request gets an answer (availability).
Deciding questions: What does a stale read cost here — a retry, an apology, or a financial error? What does a timeout cost? Which of the two failures does the business tolerate better?
Default: don't choose globally — choose per operation. CAP is a framing, not a product picker.
Signal you chose wrong: a customer was shown “in stock” and charged for something that wasn't; or the checkout went down because a read that could have been served from cache waited for a quorum.
Taught in: lesson 0032
Coupling vs Coordination
Forces: independent components (low coupling) vs the standing cost of keeping them aligned (coordination).
Deciding questions: When the contract changes, who must change together? Can one side deploy alone? Does Conway's law already put the boundary at a team edge?
Default: favour independence, and pay the coordination tax explicitly — in a written contract, not a chat thread — where the domain genuinely needs shared consistency.
Signal you chose wrong: a release runbook with the words “deploy A before B”. That sentence means you are paying both currencies at once: coupled systems and a coordination ritual.
Taught in: lesson 0033
Performance vs Maintainability
Forces: fast (performance) vs clear (maintainability).
Deciding questions: Is there a measured requirement, or a guess? Where is the bottleneck actually — profile first. Can the fast version hide behind a clean interface?
Default: clean until measured; then optimise the few hot paths and keep them behind a deep module.
Signal you chose wrong: an optimisation nobody can justify with a number, and a profile showing the real bottleneck somewhere else entirely — the cleverness bought complexity and no speed.
Taught in: lesson 0034
Evolution trade-offs
Stability vs Speed of Change
Forces: predictable, safe, hard to break (stable) vs quick to ship, quick to pivot (fast).
Deciding questions: What does a break cost — users, money, trust? What does waiting cost — market, feedback, debt? Can you buy speed safely with flags, a strangler, branch by abstraction, expand/contract?
Default: make change cheap and safe rather than choosing a side. The techniques that buy both are the master's toolkit.
Signal you chose wrong: a change that “had to” be one big deploy, and a rollback plan that was really a hope. Or the opposite: a process so heavy that a one-line fix takes a week and people route around it.
Taught in: lesson 0035
Isolation vs Integration
Forces: independent evolution (isolated) vs shared consistency and leverage (integrated).
Deciding questions: Do both sides mean the same thing by the same word, with the same rules? What must genuinely stay consistent across them? Who needs the shared schema — the domain, or a report?
Default: isolation at the seams the domain provides: bounded contexts, not blind modularisation.
Signal you chose wrong: a field named status2, or a migration that needs three teams in a meeting. One model is being bent to hold two meanings.
Taught in: lesson 0036
Correctness vs Delivery Speed
Forces: right (correct) vs now (fast).
Deciding questions: What does “wrong” cost — a rolled-back deploy, or a legal liability? Is the risky part cheaply testable? Which slice delivers value while keeping the irreversible part contained?
Default: correctness for irreversible or costly operations; speed for reversible ones, with tests as the safety net rather than the tax.
Signal you chose wrong: an incident that could not be fixed by a revert. That is the signature of an irreversible change shipped at reversible speed.
Taught in: lesson 0037
Short-Term Productivity vs Long-Term Maintainability
Forces: ship this week (productive) vs understand it next year (maintainable).
Deciding questions: Is this code touched once, or for years? Who maintains it — you, a team, or nobody? Is the debt recorded as debt, or disguised as done?
Default: you may borrow from the future — but record the loan. Debt that is invisible is never repaid.
Signal you chose wrong: the team estimates in multiples: “that used to be a day, now it's a week”. The interest is being paid; the loan was never written down.
Taught in: lesson 0038
Sources: Rich Hickey, Simple Made Easy; Kleppmann, Designing Data-Intensive Applications; Bass et al., Software Architecture in Practice; Fowler bliki (Technical Debt, Strangler Fig); Dan McKinley, Choose Boring Technology; ArjanCodes, Software Design Mastery.
Source page: reference/tradeoffs.html
Reference document · brief to outcome
The Initiative Lead Playbook
A minimum set of living artifacts for a 6–8 person product, design, and engineering team. Copy the prompts; keep the answers short; delete any artifact that no longer supports a decision.
The minimum sequence
These are not stage gates. Discovery, design, delivery, and learning overlap. The sequence says which missing fact should pull the team backward before it creates an expensive promise.
| Need to decide | Use | Ready when |
|---|---|---|
| Why act, and what counts? | Initiative frame | Outcome, guardrails, scope, unknowns, owners are arguable. |
| What might be false? | Assumption workshop | High-impact weak beliefs have tests and owners. |
| How does work really happen? | Workflow / EventStorming map | Rules, waits, actors, failures, meanings, and external systems are visible. |
| What is the smallest useful path? | Story map and examples | One vertical slice can produce evidence end to end. |
| What must the design be good at? | Architecture drivers | Five to seven scenarios have measures and checks. |
| Where are the boundaries? | Architecture packet + contracts | Scope, ownership, state, interactions, and critical failure are inspectable. |
| Which option do we choose? | Option table, spike, ADR | Forces, consequences, dissent, and revisit trigger are recorded. |
| How do we retire risk? | Walking skeleton + delivery map | Milestones produce evidence and dependencies have fallbacks. |
| How do eight people act as one? | Team charter | Decision rights, WIP, sources of truth, cadence, escalation are explicit. |
| How do we expose safely? | Rollout contract | Tests, telemetry, gates, reversal, and operational owner exist. |
| What changed and what next? | Weekly review + closeout | Evidence changes decisions and temporary machinery leaves. |
1. Initiative frame
- Why now
- Trigger, evidence, strategic context, and cost of doing nothing.
- Problem
- For [user/cohort] in [situation], [observed condition] causes [impact]. Baseline: [source and date].
- Outcome
- Move [measure] from [baseline] to [target] for [cohort] by [time].
- Guardrails
- Measures that must not degrade, each with threshold and owner.
- First boundary
- Included users/workflows; non-goals; what later expansion remains possible.
- Constraints
- Facts solution design must respect—not preferences disguised as facts.
- Assumptions
- Top value, usability, feasibility, and viability beliefs; evidence strength.
- Risks
- Impact × likelihood, early signal, mitigation, owner, review date.
- Decision rights
- Who decides outcome/scope, experience, architecture, and rollout.
- Next evidence
- What the team will learn before making the next expensive commitment.
Quality check: a reader can distinguish outcomes from outputs, constraints from assumptions, and commitments from options. Every number has a source. Every risk has an owner and signal. Every non-goal prevents a plausible misunderstanding.
2. Discovery and brainstorming workshop
Decision question: one sentence, visible throughout.
People: decider, user/domain knowledge, product, design, engineering, and people carrying operational consequences. Invite for knowledge, not representation.
Pre-read: frame, evidence, constraints, glossary. Mark facts, interpretations, and open questions separately.
Criteria: outcome impact, confidence, effort, reversibility, guardrails. Agree before seeing favored options.
| Time | Mode | Prompt |
|---|---|---|
| 10 min | frame | What decision must leave this room, and what is already known? |
| 10 min | silent diverge | Facts, assumptions, questions—one idea per note. |
| 15 min | share / cluster | Round-robin; clarify without defending; name themes. |
| 15 min | silent sketch | At least three mechanisms; include a manual or no-build option. |
| 15 min | converge | Compare against criteria; state strongest case against your preference. |
| 10 min | close | Direction, dissent, unknowns, tests, owners, decision date. |
Assumption map: place each belief by impact if false and evidence strength. Test high-impact, weak-evidence beliefs first. A vote surfaces sentiment; the named decider closes the choice after advice.
3. Workflow and domain map
- Domain events in past tense, left to right.
- Commands that caused events; actors who issued commands.
- Policies that react to an event and decide a command.
- External systems, manual tools, delays, time triggers, failures.
- Hotspots for unclear policy, competing language, waiting, or ownership.
For each hotspot record: example and counterexample, person who knows, unanswered question, and next evidence. Build a glossary with term, precise local meaning, context, and phrases to avoid. Candidate boundaries require evidence from language, invariants, change cadence, or ownership—not merely a noun.
4. Story map and executable examples
Backbone: user activities across the whole journey, left to right.
Depth: alternatives and rule variations beneath each activity.
Release line: one narrow row that completes the journey and tests a named assumption or outcome.
Manual seam: which uncertain automation can a person perform safely for the first cohort? Add capacity, owner, and removal trigger.
Rule: the behavior being clarified.
Happy: Given [specific state], when [action], then [observable result].
Boundary: exact edge or counterexample and expected reason.
Failure: dependency, retry, conflict, or partial outcome.
Guardrail: accessibility, security, privacy, performance, or operational case.
Telemetry: event or measure that shows the behavior and outcome.
5. Architecture-driver table
When [source] causes [stimulus] during [environment], [artifact] must [response], measured by [number].
Why it matters: outcome, guardrail, regulatory, or operational consequence.
Design pressure: which structural choices this scenario distinguishes.
Evidence: test, fitness function, drill, or production measure; owner and date.
Select only the few scenarios with high business impact, uncertainty, or structural reach. Cover the qualities that actually matter: availability, performance, security/privacy, modifiability, data integrity, interoperability, usability/accessibility, operability, cost, or others demanded by the context. Write conflicts between them.
6. Minimum architecture packet
| View | Must show | Review question |
|---|---|---|
| System context | people, system in scope, external systems, trust boundaries | What is ours and where does uncontrolled behavior enter? |
| Container | deployables, data stores, interactions, protocols, owners | Which driver justifies each operational boundary? |
| Dynamic | ordered interactions for one critical happy or failure path | Where can work, identity, or truth be lost or duplicated? |
| Module/dependency | model boundaries and allowed arrows inside deployables | Can likely change stay local and be enforced? |
Every view needs title, scope, audience, version/date, legend, labelled relationships, and the drivers or risks it addresses. Prefer a modular monolith for one small team unless a measurable driver pays for independent deployment. Supplement C4 with state, data-flow, or sequence views where static structure cannot answer the decision.
7. State and contract review
From → command → to: actor, precondition, invariant, authoritative owner, emitted fact, audit.
Race / retry: expected state on duplicate, stale command, timeout, or reordering.
Compensation: what reverses an external side effect that code rollback cannot undo?
Meaning: command, fact, or query; business promise made by success.
Identity: resource id, intent/idempotency key, ordering key.
Failures: rejected, conflicted, unavailable, timed out, partial; safe caller action for each.
Evolution: required fields, tolerant-reader rule, deprecation window, migration owner.
Data: classification, minimum fields, trust crossing, encryption, retention, deletion, access.
Proof: producer/consumer contract tests and operational signal.
8. Options, spikes, and ADRs
For each genuinely different option: mechanism; drivers it satisfies; drivers it harms; delivery/operational cost; reversibility; largest unknown; evidence already held. Include “do nothing,” manual, or buy when credible.
Decision: what choice is blocked?
Question: one falsifiable uncertainty.
Timebox: fixed hours/days and named investigator.
Method: smallest experiment; production-like where the question requires.
Result: number, trace, compatibility finding, or explicit inconclusive result.
Disposal: throw away, or separately review before productionizing.
Title / status / date / decider.
Context: facts and the decision required.
Forces: named drivers, constraints, risks, and affected owners.
Options: credible alternatives and why not chosen.
Decision: active voice; enough specificity to constrain implementation.
Consequences: benefits, accepted costs, follow-up work, operational ownership.
Dissent / advice: material concern and who carries it.
Revisit when: measurable change in context. Supersede; do not rewrite history.
9. Walking skeleton and delivery map
From [real source/build], move one [controlled user action] through [real deployment, boundaries, storage, representative adapter] and prove it with [telemetry/demo]. State every fake or hard-coded behavior.
Evidence milestone: behavior demonstrable; risk or assumption retired; exit check; target range; owner. Never report “percent complete.”
Dependency row: external owner; exact deliverable/decision; needed-by; current readiness evidence; fallback; escalation trigger and recipient.
Forecast: range and confidence; what changed; fixed variables; movable variable; smallest scope trade that preserves outcome; date of next evidence.
10. Team charter
Purpose: outcome and first cohort.
People: accountabilities, availability, missing expertise, consultation path.
Decision rights: final call for outcome/scope, experience, architecture, implementation, quality, rollout; advice required.
Flow: WIP limit, swarm rule, review/pairing expectations, blocked-item trigger.
Sources of truth: one location each for frame, scope, work, decisions, risk, evidence.
Cadence: purpose, inputs, output changed, and cancellation rule for each meeting.
Escalation: objective triggers, DRI, options format, recipient, response needed by.
Health: sustainable pace, focus protection, learning/redundancy, one team experiment.
11. Readiness and rollout contract
Risk-to-test map: risk/driver → example/unit, integration/contract, end-to-end, quality test, exploratory charter, or recovery drill → owner → evidence.
Telemetry map: outcome and guardrails by cohort; system health and saturation; business-state distribution and age; external dependency health; data quality/privacy; manual/support load. Every measure needs an owner and a decision it can change.
Cohort / percentage: who receives behavior?
Entry: required test, ownership, support, security/privacy, accessibility, and previous-stage evidence.
Observe: measures, segments, minimum volume/duration.
Advance: explicit thresholds and named decider.
Pause / reverse: trigger, authority, action, recovery time, communication.
Data / side effects: rollback, roll-forward, or compensation for in-flight work.
Operational ready: dashboards; actionable alerts; correlation; runbook; support brief; on-call/service owner; access; recovery; known failure states; capacity; disable path exercised; temporary flags have deletion owner/date.
12. Weekly review and closeout
Outcome / guardrails: baseline, current, target, cohort, confidence.
Learned: assumption and evidence; what decision changes.
Delivered: integrated, demonstrable behavior—not activity.
Flow: aging work, WIP, block, swarm.
Risk / dependency: changed signal, owner, action, escalation.
Decisions: made, waiting, dissent, next evidence/date.
Forecast: range, confidence, scope trade, next reforecast.
Ask: one explicit decision or resource, options and consequences.
- Outcome and guardrails reported against baseline, including non-benefiting segments and displaced cost.
- Steady-state service, on-call, vendor, data, support, and roadmap ownership accepted by name.
- Release flags, temporary paths, access, test data, pilot dashboards, and manual steps removed—or debt recorded with interest and trigger.
- Architecture views and contracts updated; decisions superseded rather than rewritten; fitness functions remain.
- Expected / observed / surprised / next-time learning review completed; at most two improvements owned and dated.
- Continue, change, stop, or hand over decided explicitly.
The traceability test
Trace one line through the packet: outcome → assumption → slice → driver → boundary/contract → decision → milestone → test/telemetry → rollout gate → continue/change/stop. A broken line is unnecessary work or unmanaged risk. The packet succeeds by coherence, not weight.
Primary sources behind the playbook
- User Story Mapping, Jeff Patton; Specification by Example, Gojko Adzic; Continuous Discovery Habits, Teresa Torres.
- Software Architecture in Practice, Bass, Clements & Kazman; C4 model, Simon Brown.
- Architecture Decision Record, Fowler; A Philosophy of Software Design, Ousterhout.
- Growing Object-Oriented Software, Guided by Tests, Freeman & Pryce; Accelerate, Forsgren, Humble & Kim.
- Team Topologies, Skelton & Pais; Canarying Releases, Google SRE Workbook.
- Taught across lessons 0039–0050.
Source page: reference/initiative-playbook.html
Reference document · where the material comes from
Resources
Every lesson in this course points at a primary source rather than paraphrasing one. This is the full list, with what each is good for. Books are cited by chapter inside the lessons themselves.
Knowledge
The course model
- Software Design Mastery (ArjanCodes)
The curriculum this course is modeled on: Core Designer → System Designer → Master Designer. Use for: phase/module structure, lesson-level topic framing, and the "design by balancing forces" framing. - The Software Designer Mindset (ArjanCodes course)
Arjan's "7 Principles of Modern Software Design" (composition over inheritance, high cohesion, low coupling, start with the data, depend on abstractions, separate creation from use, keep things simple). Use for: principle-level lessons (0002, 0007, 0015–0017). - Software Design Guide (ArjanCodes, free PDF)
Seven-step design document workflow. Use for: lessons on structuring for stability and module organization. - sdm — code examples for Software Design Mastery (GitHub)
Official example code for the mastery program. Use for: realistic (non-toy) worked examples across all phases. - ArjanCodes YouTube channel
Primary video source; topics include coupling, cohesion, the Law of Demeter, strategy pattern, ports & adapters, error handling. Use for: the "watch first" recommendation in most lessons.
Books (opinionated, referenced by chapter in lessons)
- A Philosophy of Software Design, John Ousterhout — complexity, deep modules, information hiding, "define errors out of existence", design it twice, designing for performance.
- Clean Architecture, Robert C. Martin — SRP as "reason to change", OCP, DIP, component cohesion (REP/CCP/CRP), boundaries.
- The Pragmatic Programmer, Hunt & Thomas — DRY, Law of Demeter, "tell, don't ask", no global mutable state, rule of three.
- Code Complete, Steve McConnell — the deepest treatment of coupling and cohesion (ch. 5–6).
- Refactoring, Martin Fowler — catalog of code smells and their fixes.
- Working Effectively with Legacy Code, Michael Feathers — seams, characterization tests, changing software safely.
- Design Patterns, Gamma et al. (GoF) — canonical pattern catalog; Strategy (behavioral), Adapter & Facade (structural).
- Head First Design Patterns, Freeman et al. — the most accessible pattern walkthroughs; Strategy, Adapter, Facade.
- Domain-Driven Design, Eric Evans — entities, value objects, aggregates, repositories, bounded contexts.
- Applying UML and Patterns, Craig Larman — the complete analysis-to-design path: use cases, domain models, system sequence diagrams, operation contracts, GRASP responsibilities, state machine diagrams, logical architecture. Use for: the System Architect phase foundation (0051–0060).
- Design It!, Michael Keeling — blank-canvas architecture: stakeholders, goals, requirements, constraints, quality attributes, architectural responsibilities, modeling, and evaluation. Use for: the System Architect phase.
- Object Design: Roles, Responsibilities, and Collaborations, Wirfs-Brock & McKean — responsibility-driven design: finding objects, assigning responsibilities, collaborations, contracts, and control style. Use for: boundaries and components (0053, 0056).
- Learning Domain-Driven Design, Vlad Khononov — optional deep-dive for DDD vocabulary (subdomains, bounded contexts, aggregates, event sourcing); not required for the course's foundation.
- Implementing Domain-Driven Design, Vaughn Vernon — practical DDD: value objects (ch. 6), aggregates (ch. 10), repositories (ch. 12).
- Architecture Patterns with Python, Percival & Gregory — repository pattern, ports & adapters, service layer, message bus, event-driven integration. Use for: most System Designer lessons.
- Fluent Python, Luciano Ramalho — protocols, ABCs, generators, higher-order functions, context managers.
- Effective Python, Brett Slatkin — idioms: generators, composition, exceptions, concurrency.
- Designing Data-Intensive Applications, Martin Kleppmann — reliability, consistency, availability, stream processing, transactions.
- Release It!, Michael Nygard — stability patterns, fail-fast, circuit breakers, concurrency.
- Building Evolutionary Architectures, Ford, Parsons & Kua — incremental change, fitness functions.
- Monolith to Microservices, Sam Newman — strangler fig, decomposition.
- Software Architecture in Practice, Bass, Clements & Kazman — quality attributes and trade-off analysis (ATAM).
- Team Topologies, Skelton & Pais — team boundaries, cognitive load, coordination.
- Test-Driven Development by Example, Kent Beck — the discipline of correctness via tests.
- Robust Python, Patrick Viafore — Python-specific error handling and typing discipline.
- Continuous Discovery Habits, Teresa Torres — outcomes, opportunity mapping, assumptions, and continuous product discovery.
- User Story Mapping, Jeff Patton — shared understanding, workflow maps, release slices, and discovery through stories.
- Specification by Example, Gojko Adzic — collaborative examples that connect business rules, acceptance, and tests.
- Introducing EventStorming, Alberto Brandolini — collaborative discovery of events, commands, policies, hotspots, and domain boundaries.
- Gamestorming, Gray, Brown & Macanufo, and Sprint, Knapp, Zeratsky & Kowitz — structured divergence, facilitation, convergence, and decision workshops.
- Inspired, Marty Cagan — product discovery and separating customer problems from requested features.
- Fundamentals of Software Architecture, Richards & Ford, and Software Architecture: The Hard Parts, Ford et al. — architecture characteristics, styles, trade-offs, and decision records.
- API Design Patterns, JJ Geewax — resource-oriented API contracts: naming, resource hierarchy, standard and custom methods, versioning, validation, retries, and revisions. Use for: contract design (0057).
- Growing Object-Oriented Software, Guided by Tests, Freeman & Pryce — walking skeletons and growing an end-to-end system through feedback.
- Accelerate, Forsgren, Humble & Kim, and The DevOps Handbook, Kim et al. — small batches, delivery performance, telemetry, and operational learning.
- Continuous Delivery, Humble & Farley — the deployment pipeline, environments, build and deployment scripting, release strategies, data management, and component dependencies. Use for: deployment and release design (0060).
- The Principles of Product Development Flow, Donald Reinertsen — WIP, queues, batch size, and economic sequencing.
- Thinking in Systems, Donella Meadows — feedback, delay, and the difference between local output and system outcome.
Sources for the capstone, code lab, cases, and supplements
Several of these also appear in the book list above; here they carry a link and the specific page that leans on them.
- Working Effectively with Legacy Code, Michael Feathers
Seams and sensing points (ch. 4), getting a class into a test harness (ch. 9–10), characterization tests (ch. 13), "I don't understand this code well enough to change it" (ch. 16). Use for: the brownfield modernization case and the code lab's characterization-test checkpoints. - Strangler Fig Application (Martin Fowler)
Growing the replacement around the edges of a live system rather than rewriting it. Use for: the brownfield modernization case. - Branch By Abstraction (Martin Fowler)
Making a large change incrementally on the mainline, behind an abstraction that both implementations satisfy. Use for: the brownfield case, and stability vs speed of change (0035). - Team Topologies, Skelton & Pais
Stream-aligned, platform, enabling and complicated-subsystem teams; cognitive load as a design constraint; the three interaction modes. Use for: the multi-team architecture supplement — team APIs, Conway's law, cross-team contracts. - The Principles of Product Development Flow, Donald Reinertsen
Cost of delay, queueing, batch size, WIP constraints, and economic sequencing (ch. 2–7). Use for: the architecture economics supplement, and delivery sequencing in lessons 0047–0048. - Threat Modeling: Designing for Security, Adam Shostack
The four-question frame, STRIDE, data-flow diagrams and trust boundaries, and how to run the workshop with a whole team. Use for: the security, privacy and accessibility supplement. - OWASP Application Security Verification Standard (ASVS)
Leveled, testable security requirements — including object-level authorization and multi-tenant isolation. Use for: abuse cases and negative acceptance examples. - Web Content Accessibility Guidelines (WCAG) 2.2
The normative success criteria behind an "AA" commitment. Use for: accessibility as an architecture and acceptance concern — the returns-resolution case's WCAG 2.2 AA guardrail. - Release It! (2nd ed.), Michael Nygard
Stability antipatterns and patterns, circuit breakers, bulkheads, and designing for production rather than for the demo. Use for: the code lab's unsafe-retry checkpoint and the rollout lessons. - Designing Data-Intensive Applications, Martin Kleppmann
Replication, partitioning, transactions, consistency models, and stream processing. Use for: consistency vs availability (0032) and the capstone's data-ownership decisions. - Monolith to Microservices, Sam Newman
Decomposition patterns, database decomposition, expand/contract, and the chapter on when not to split. Use for: the brownfield case's "deciding when not to extract a service". - Building Evolutionary Architectures, Ford, Parsons & Kua
Fitness functions, incremental change, and architectural characteristics that can be measured continuously. Use for: the code lab's fitness functions and lesson 0022's enforced import table.
Articles and talks
- Simple Made Easy, Rich Hickey (talk) — the cost of "easy" vs "simple"; foundational for all Master Designer trade-off lessons.
- Tell, Don't Ask (Martin Fowler)
- Strangler Fig Application (Martin Fowler)
- Technical Debt (Martin Fowler)
- Bounded Context (Martin Fowler)
- Convention over Configuration (The Rails Doctrine)
- What Do You Mean by Event-Driven? (Martin Fowler)
- Event Sourcing (Martin Fowler)
- Choose Boring Technology (Dan McKinley)
- Hexagonal Architecture (Alistair Cockburn) — the origin of Ports & Adapters.
- Please stop calling databases CP or AP (Martin Kleppmann) — why CAP is a framing, not a trilemma for choosing products.
- The Wrong Abstraction (Sandi Metz) — duplication is cheaper than the wrong abstraction.
- The Twelve-Factor App — Config — configuration vs convention applied.
- Python typing.Protocol docs — structural subtyping reference.
- Python functional programming HOWTO — generators, iterators, higher-order functions.
- itertools reference — building blocks for streaming pipelines.
- Refactoring Guru — pattern and code-smell reference with motivations.
- The C4 model, Simon Brown — official context, container, component, and supporting diagram guidance.
- Architecture Decision Record, Martin Fowler — short, durable records of context, decision, and ramifications.
- OWASP Threat Modeling Cheat Sheet — model data flows and trust boundaries, ask what can go wrong, choose mitigations, and review.
- Canarying Releases, Google SRE Workbook — partial, time-limited release exposure evaluated against a control.
- Feature Toggles, Pete Hodgson — deployment versus release, flag categories, testing, and carrying cost.
Wisdom (Communities)
- r/softwarearchitecture — discussion of real trade-offs and architecture decisions.
- r/Python — general Python practice; code-review threads.
- Python Software Foundation / local Python meetups — meet people who review real code.
- ArjanCodes community (linked from the mastery waitlist and YouTube channel) — cohort of designers working through the same curriculum; closest match to the wisdom layer of the program.
- Code Review Stack Exchange — structured external review of real code.
Gaps
- Arjan Egges has not (yet) published a book; the flagship written source for the exact mastery curriculum is the course itself plus the
sdmGitHub examples. Book recommendations in lessons therefore lean on the classics (Ousterhout, Martin, Evans, Fowler). - No dedicated high-trust resource found yet for "structural issues in AI-generated code"; until one exists, lessons on that topic lean on primary sources about structure (Ousterhout ch. 16, Feathers) and the learner's own practice.
This page is generated from RESOURCES.md in the repository root, which remains the source of truth.
Source page: reference/resources.html