Assessment · Phase I
Phase 1 assessment · Core Designer
Six scenarios and three pieces of writing. The scenarios check that you can name the force; the writing checks whether you can act on it when there are four defects and only one afternoon. Lessons 0001–0014.
The written tasks carry the signal. A scenario question can be answered by recognition; a reasons-to-change list cannot. Write your answer somewhere before you open any reveal — the self-review checklist above each reveal is designed to be answered against your own text, and it stops working the moment you have read someone else’s.
Part 1 · Six scenarios
A service method from a billing package.
class InvoiceService:
def issue(self, invoice_id: str) -> None:
invoice = self._db.load(invoice_id)
invoice["tax"] = self._tax_api.rate(invoice["country"]) * invoice["net"]
document = self._pdf.render(invoice)
self._db.save(invoice)
self._mailer.send(invoice["email"], document)
The tax rate comes from an outside service. The reason-to-change list here is:
A helper in an order module.
def can_expedite(order) -> bool:
return (order.customer.account.tier.name == "gold"
and order.warehouse.region.code == "EU")
The cost this chain imposes is best described as:
A fee calculation that has grown one branch at a time.
def fee(method: str, amount: Decimal) -> Decimal:
if method == "card":
return amount * Decimal("0.029") + Decimal("0.30")
if method == "bank":
return Decimal("0.50")
if method == "wallet":
return amount * Decimal("0.015")
raise ValueError(method)
The signal that a strategy is trying to emerge here is:
A Report base class has grown the subclasses PdfReport,
CsvReport, EmailedPdfReport and ScheduledCsvReport. The new
requirement is a scheduled, emailed PDF.
That growth pattern is evidence that:
In an order system, which of these is best modeled as a value object?
Discount application, as it survived a code review.
def apply_discount(order: Order, code: str) -> Order:
try:
discount = DISCOUNTS[code]
except KeyError:
discount = Decimal("0")
order.total -= order.total * discount
return order
What does this except block cost the system?
Part 2 · Three written tasks
Twenty minutes each, in a text file or on paper. Each one ends with a self-review, a self-score against the capstone rubric, and then — only then — a strong and a weak sample answer.
Task 1 · The reasons-to-change list and the first cut
What to write
Below is a 45-line returns module of the kind that grows from a working prototype. Write three things, in prose and one code block, in about twenty minutes:
- The reasons-to-change list. Every entry names who or what causes the change, not the layer it lives in.
- The one boundary you would cut first, what is on each side of it, and what the cut buys within a week.
- The interface at that boundary, written in the language of returns, plus one sentence on what you are deliberately not cutting and what leaving it costs.
# returns/service.py
import csv
import smtplib
from datetime import datetime, timedelta
from decimal import Decimal
import requests
RETURN_WINDOW_DAYS = 30
CARRIER_URL = "https://carrier.example.com/labels"
audit_rows: list[dict] = []
class ReturnService:
def __init__(self, connection):
self.connection = connection
def request_return(self, order_id: str, reason: str) -> dict:
cursor = self.connection.execute(
"SELECT id, placed_at, total_cents, email FROM orders WHERE id = ?",
(order_id,),
)
row = cursor.fetchone()
if row is None:
return {"status": "rejected", "why": "unknown order"}
placed_at = datetime.fromisoformat(row[1])
if datetime.utcnow() - placed_at > timedelta(days=RETURN_WINDOW_DAYS):
return {"status": "rejected", "why": "outside window"}
refund = Decimal(row[2]) / 100
if reason == "damaged":
refund = refund + Decimal("4.99")
label = requests.post(CARRIER_URL, json={"order": order_id}, timeout=30).json()
self.connection.execute(
"INSERT INTO returns (order_id, refund_cents, label) VALUES (?, ?, ?)",
(order_id, int(refund * 100), label["tracking"]),
)
self.connection.commit()
server = smtplib.SMTP("mail.example.com")
server.sendmail("returns@example.com", row[3], "Your label: " + label["url"])
server.quit()
audit_rows.append({"order": order_id, "at": datetime.utcnow().isoformat()})
with open("audit.csv", "a", newline="") as handle:
csv.writer(handle).writerow([order_id, reason, str(refund)])
return {"status": "accepted", "refund": str(refund), "label": label["url"]}
Self-review — answer these before you open the reveal
- Read your list again. Does every entry name a source of change — a vendor, a policy owner, a platform team — or does it name a layer? “Persistence” is a layer; “whoever owns the orders schema” is a source.
- Count your entries. Fewer than four means you folded two owners together: check storage, refund policy, the carrier, mail, and the audit format against each other and ask whether any two could really change on the same day.
- For your first cut, write the sentence “after this cut, X can change without Y noticing.” If you cannot finish it, the cut is a file move.
- Search your interface for the words
sql,http,smtp,jsonand the carrier’s name. Any hit means the boundary leaks what is behind it. - Did you say what you are not cutting? An answer that extracts everything has made no decision and priced no cost.
- One thing in this module is shared by every instance of the class and by every test in the suite. Did you spot it, and did you rank it?
Now score what you actually wrote, not what you meant. Each dimension links to its level descriptions.
Coupling, cohesion, and dependency direction
Quality and depth of module boundaries
Domain fidelity and invariant protection
Technical communication and decision clarity
Not scored yet.
Reveal: a strong answer, a weak one, and the difference
Strong
Five reasons to change, five different owners. The orders schema (whoever owns that table, and it is not this team). The returns policy — the 30-day window and the €4.99 damaged-item top-up (operations, and it changes at least seasonally). The carrier’s label API (a vendor under contract). The mail transport (platform). The audit file’s columns (finance). Only two of the five belong to the returns team; the other three are other people’s release schedules reaching into one method.
First cut: the carrier. It is external, slow, and the most likely of the five to
change contract this year. It is currently a requests.post with a 30-second timeout on
the request path, which means today the returns policy cannot be tested at all without a network. The
cut buys, within a week: policy rules testable in milliseconds, and a place to put a retry that is
not the domain.
class LabelService(Protocol):
def create_label(self, request: ReturnRequest) -> ShippingLabel: ...
No word in that signature is HTTP, JSON, or the carrier’s name. The adapter that does speak
those lives in returns/adapters/, and ReturnService receives it in its
constructor.
Not cutting yet: the SQL. It is uglier and it is more stable — the orders
table changes far less often than the carrier does — and cutting it means inventing a
repository and a domain Return object in the same move. The accepted cost is that this
class still needs a database to exercise its policy, so the window rule stays awkward to test. That
is tolerable for one iteration, not for a quarter.
Cheapest fix, unrelated to the cut: audit_rows is module-level
mutable state shared by every instance and every test in the suite. Delete it or give it an owner
today; it costs ten minutes and removes a class of test-order bug.
Weak — and plausible
This class does too much and violates the Single Responsibility Principle. It mixes persistence,
HTTP, email and file I/O with business logic. It should be split into a ReturnService,
a ReturnRepository, a CarrierClient, an EmailSender and an
AuditLogger, each behind an interface and injected through the constructor, following
hexagonal architecture. That would make it clean, testable, and easy to extend.
The weak answer is not wrong. It is unarguable. Every noun in it is a layer name; not one is a reason to change. It proposes five extractions with no order, no first step and no cost, which in practice means none of them happens. There is nothing in it a reviewer can disagree with, which is the tell — you can disagree with the strong answer (cut the database first, the schema is the thing that hurts), and that disagreement would be a useful conversation.
And CarrierClient is the boundary failing while naming the right pattern.
An interface named after the vendor ends up with the vendor’s methods on it, which is the leak
the extraction was meant to fix (lesson 0009). LabelService commits to what returns needs;
CarrierClient commits to what the carrier offers.
Note what the strong answer does not claim: it does not say the result is clean. It says what the first cut buys, what the uncut part costs, and when to revisit.
Task 2 · The invariant list and the refusal
What to write
Below is a returns model whose rules are enforced by a function. In about twenty minutes:
- List every invariant this code implies, and for each, name the object that should own it.
- Rewrite one of them as a refusal — real code, in which no caller can produce the invalid state.
- Name the invariant that no single object can enforce, and say what that implies about where the consistency boundary has to sit.
from dataclasses import dataclass
@dataclass
class ReturnRequest:
order_id: str
lines: list[dict]
refund_cents: int
status: str = "draft"
def submit(request: ReturnRequest, orders: dict) -> None:
order = orders[request.order_id]
if request.status != "draft":
raise ValueError("already submitted")
if request.refund_cents > order["total_cents"]:
raise ValueError("refund exceeds the order total")
if not request.lines:
raise ValueError("nothing to return")
request.status = "submitted"
Self-review — answer these before you open the reveal
- For each invariant on your list, write the one line of caller code that breaks it today. If you cannot write that line, it may already be enforced — or you may have written a wish rather than a rule.
- In your rewrite, is
statusstill assignable from outside? A guarded method beside a public attribute guards nothing. - Which of your rules needs data that lives in a different object? That one is the interesting answer, and it is not solved by a private attribute.
- Would your refusal still hold if the next caller were written by an agent that never opened this file? That is the actual test of an invariant.
refund_centsis anint. Write down two things a caller can do with an int that they could not do with aMoneyvalue object.- Did you leave anything as a caller-side check on purpose? Say why, and what it costs.
Now score what you actually wrote, not what you meant. Each dimension links to its level descriptions.
Domain fidelity and invariant protection
Quality and depth of module boundaries
Data ownership, contract evolution, and failure semantics
Not scored yet.
Reveal: a strong answer, a weak one, and the difference
Strong
Four invariants, three owners. (1) A request is submitted at most once —
owned by ReturnRequest. (2) A request has at least one line — owned by
ReturnRequest, and it should hold at construction, not at submission: a lineless request
should never exist. (3) A refund is a non-negative money amount in the order’s currency —
owned by a Money value object that does not exist yet. (4) The refund never exceeds the
order total — owned by nobody here, because it spans two objects.
The refusal, for (1) and (2):
class ReturnRequest:
def __init__(self, order_id: str, lines: list[ReturnLine]) -> None:
if not lines:
raise EmptyReturn(order_id)
self._order_id = order_id
self._lines = tuple(lines)
self._status = Status.DRAFT
@property
def status(self) -> Status:
return self._status
def submit(self) -> None:
if self._status is not Status.DRAFT:
raise AlreadySubmitted(self._order_id)
self._status = Status.SUBMITTED
Emptiness is now unrepresentable rather than rejected, the status is an enum behind a read-only
property, and submit() is the only door. The three call-site checks in the old
submit function can be deleted rather than duplicated.
The one that cannot live in one object: “refund never exceeds the order
total” needs the order. Three honest options, and they are not equivalent. Put the request
inside the Order aggregate, so the order approves its own refunds — correct, and
it makes every order write contend on returns traffic. Copy the order total onto the request at
creation as a RefundCap value object — cheap, and it goes stale if the order is
amended. Or accept it as eventually consistent and reconcile nightly — viable only if an
over-refund is recoverable, which for money usually means a compensating charge and a conversation.
I would take the second for the pilot, because orders are immutable after dispatch in this system,
and write down the assumption that makes it safe.
Weak — and plausible
The invariants are: status must be draft before submitting, refund must not exceed the order
total, and lines must not be empty. These should all be enforced inside the ReturnRequest
class instead of in the submit function, so that the domain object protects itself and
invalid states become impossible:
@dataclass
class ReturnRequest:
order_id: str
lines: list
refund_cents: int
status: str = "draft"
def submit(self, order_total_cents: int) -> None:
if self.status != "draft":
raise ValueError("already submitted")
if self.refund_cents > order_total_cents:
raise ValueError("refund exceeds total")
if not self.lines:
raise ValueError("nothing to return")
self.status = "submitted"
The weak answer moved the code and kept every hole. It is still a
@dataclass with public fields, so request.status = "submitted" and
request.lines = [] both still work, and now they work from anywhere rather than from one
function. Moving a guard inside a class that does not own its state is relocation dressed as
encapsulation.
It also dodged the interesting question. Passing order_total_cents in
as a parameter does not solve the cross-object invariant; it hands the caller the job of fetching the
right number at the right time, which is exactly the “everyone remembers” pattern the task
was about. The strong answer names three ways to place the consistency boundary, picks one, and states
the assumption that makes the choice safe — which is also the thing a reviewer can attack.
The str status is a smaller miss with a long tail: "submited" is a valid
value of str, and the object cannot tell. Lesson 0054 turns this into the state machine
it wants to be.
Task 3 · The structural review of generated code
What to write
An agent was asked for “a repository for return requests with search and caching” and produced the class below. It works, it has tests, and it is in a pull request in front of you. In about twenty minutes write:
- Three findings, each with the force it violates, the consequence somebody would notice in production, and the smallest change that removes it.
- A merge decision per finding: block, or file and merge. Say why.
- One sentence you would add to the original prompt so this shape does not come back next time — a constraint the model can check itself against, not a style request.
class ReturnRepository:
_cache: dict = {}
def __init__(self, session):
self.session = session
def get(self, return_id: str):
if return_id in self._cache:
return self._cache[return_id]
row = self.session.query(ReturnRow).filter_by(id=return_id).first()
self._cache[return_id] = row
return row
def search(self, **filters):
query = self.session.query(ReturnRow)
for field, value in filters.items():
query = query.filter(getattr(ReturnRow, field) == value)
return query.all()
def save(self, row) -> None:
self.session.add(row)
self.session.commit()
Self-review — answer these before you open the reveal
- Read each finding aloud. Does it end in a consequence somebody would notice — a wrong number on a screen, a page on call — or does it end in the name of a principle?
- One piece of state in this class is shared by every instance and every test in the suite, and is never invalidated. Did you find it, and did you say what a stale entry does to a refund?
- What type does
getreturn, and what does the calling code get to do with it? Follow that one step further than feels necessary. - Look at
search’s signature. What exactly is its public interface — and who gets to change it without touching this file? savecommits. Name one workflow that becomes impossible to write correctly because of that.- Your prompt sentence: could the model check its own output against it? “Follow clean architecture” cannot be checked. “No storage type may appear in a return value” can.
Now score what you actually wrote, not what you meant. Each dimension links to its level descriptions.
Coupling, cohesion, and dependency direction
Quality and depth of module boundaries
Testability, observability, security, and reversibility
Technical communication and decision clarity
Not scored yet.
Reveal: a strong answer, a weak one, and the difference
Strong
Finding 1 — _cache is class-level, shared, and never invalidated.
Block. Force: shared mutable state with no owner (lesson 0020). Consequence: two requests in
the same process, or two tests in the same suite, see one another’s data; a return amended by an
agent keeps serving the pre-amendment refund until the process restarts, so a customer is quoted a
figure the database disagrees with. Smallest change: delete the cache. If a cache is genuinely
needed, it belongs on an instance whose lifetime somebody owns, with a stated invalidation rule.
Finding 2 — get returns the ORM row. Block. Force: the
boundary leaks the storage model (lesson 0013). Consequence: callers reach through
row.order.customer.email and trigger lazy loads from inside the domain, so a page that
looked like one query becomes forty, and every column rename becomes a change to business code.
Smallest change: map to a Return domain object inside the repository. That is a real
cost, not a free win — someone has to write and maintain the mapping.
Finding 3 — search(**filters) publishes the table’s columns as
its interface. File, do not block. Force: the interface is defined by the implementation,
so it cannot be implemented twice. Consequence: no fake for tests, no second backing store ever, and
a column rename silently breaks callers at run time with an AttributeError. Smallest
change: replace with the two or three named queries the application actually uses —
find_pending_for(order_id) and so on. Filed rather than blocked because today there is
one caller and the fix is mechanical.
Also, but not a finding: save commits, so this repository owns the
transaction. That makes “record the return and decrement stock atomically” unwritable.
Raised as a design question for the use-case layer rather than as a defect in this pull request,
because fixing it changes who owns the unit of work.
Prompt sentence: “No type from the ORM may appear in a parameter or return value of the repository’s public methods, and the repository must hold no state between calls.” Both halves are checkable by reading the signatures, which is why they work.
Weak — and plausible
This code has several issues. The cache is a mutable class attribute, which is a Python anti-pattern.
The repository leaks SQLAlchemy into the domain layer, which breaks the dependency rule. The
search method uses **kwargs, which is not type-safe and should use explicit
parameters. It also has no error handling and no logging.
I would ask for all of these to be fixed before merging, and add “follow clean architecture and use proper typing” to the prompt.
Both reviews found the same defects. Only one of them can be acted on. The weak review names four principles and stops at the point where the work begins: what breaks, for whom, how badly, and what the smallest fix is. “Mutable class attribute” is a fact; “two tests in the same suite see one another’s data, and a customer is quoted a stale refund” is a reason to stop a release.
Blocking everything is the same as blocking nothing. The strong review blocks two findings and files one, and says why — one caller, mechanical fix. A review with no ranking hands the ranking back to the author, who will pick the easiest.
“Follow clean architecture and use proper typing” is not a constraint. Nothing in the model’s output can be checked against it, which is why the next generation will produce the same shape with better names. The strong prompt sentence can be verified by reading the signatures — by the model, by the reviewer, and eventually by an import test.
Part 3 · What to practice next
Keyed on what you missed. One or two rows, not the whole table.
| If you missed | What it points at | What to practice next |
|---|---|---|
| p1q1 or p1q2 | Reasons to change and dependency chains — you are seeing symptoms rather than sources. | 0006 and 0003, then Task 1 again from scratch. |
| p1q3 or p1q4 | Emerging structure — branches and hierarchies that are asking to become composition. | 0004, 0007, 0008. |
| p1q5 | Value objects versus entities — identity is doing the work, not the attribute list. | 0012, then redo Task 2 part 3. |
| p1q6 | Fail-fast — you are tolerant of errors that convert themselves into plausible successes. | 0014 and 0021 for the layered version. |
| Task 1 scored below Practicing | The list is layer-shaped, or the cut has no stated cost. | 0009, then lab checkpoint 1, which is this exercise on a running codebase. |
| Task 2 scored below Practicing | Rules are being moved rather than enforced, or the cross-object invariant was skipped. | 0011 and 0055. |
| Task 3 scored below Practicing | Findings end in principles rather than consequences, or everything was blocked equally. | 0005, 0010, 0013. |
Exit capability for Phase I. Given any unfamiliar module, you can produce a reasons-to-change list whose entries name sources rather than layers, choose one boundary and say what it buys and what it costs, and move a rule into the object that owns the state it guards. If any of those three is still effortful, the phase is not finished — regardless of what the scenarios said.
Next: the Phase 2 assessment, after lessons 0015–0026. The same module from Task 1 returns there, one layer up.