Assessment · Phase II
Phase 2 assessment · System Designer
The same eye, one level up: state with no owner, imports that point the wrong way, concurrency leaking into rules, and the difference between a failure and a refusal. Lessons 0015–0026.
Phase I asked what is wrong with this code. Phase II asks what rule would have prevented it and what that rule costs. Every written task below ends in something enforceable — a table, a composition root, a test — because a design principle that nothing checks is a preference.
Part 1 · Six scenarios
A validator, and its only call site.
class PriceValidator:
def __init__(self, minimum: Decimal, maximum: Decimal) -> None:
self.minimum = minimum
self.maximum = maximum
def validate(self, price: Decimal) -> bool:
return self.minimum <= price <= self.maximum
ok = PriceValidator(Decimal("1"), Decimal("999")).validate(price)
The class earns its keep over a plain function only if:
You need a seam over a third-party type you do not own. Choose:
A metrics helper, imported by nine modules.
# metrics.py
_counters: dict[str, int] = {}
def increment(name: str) -> None:
_counters[name] = _counters.get(name, 0) + 1
def snapshot() -> dict[str, int]:
return dict(_counters)
Which failure does this module make possible, and hard to diagnose?
A returns package that has been growing for six months.
returns/
├── domain/ # ReturnRequest, policy rules
├── application/ # use cases
├── adapters/ # carrier HTTP client, SQL repository
└── common/ # utils.py, settings.py, models.py
Which single import rule catches the most damage in this package?
A business rule that grew an await.
class ReturnPolicy:
async def is_eligible(self, order_id: str) -> bool:
order = await self.orders.fetch(order_id)
return order.age_days <= 30
Marking the policy rule async costs you:
A subscriber raises so the producer will not commit. That subscriber is:
Part 2 · Three written tasks
Twenty minutes each. Write your answer down before opening any reveal; the self-review checklists are meant to be run against your own text.
Task 1 · The ownership table and the composition root
What to write
Below is the module that four services in one process reach into. In about twenty minutes write:
- An ownership table. One row per piece of state: what it is, who owns it, when it is created and destroyed, and who may write it.
- The composition root — real code that builds the object graph once, in one visible place, and hands the pieces to whoever needs them.
- One thing you would leave global, and the property that makes leaving it safe.
# app/state.py
CONFIG: dict = {}
_CONNECTION = None
FEATURE_FLAGS = {"new_pricing": False}
_carrier_client = None
def configure(**values) -> None:
CONFIG.update(values)
def connection():
global _CONNECTION
if _CONNECTION is None:
_CONNECTION = connect(CONFIG["dsn"])
return _CONNECTION
def carrier():
global _carrier_client
if _carrier_client is None:
_carrier_client = CarrierClient(CONFIG["carrier_key"])
return _carrier_client
Self-review — answer these before you open the reveal
- For every row, finish the sentence “this is created at … and destroyed at …”. The rows you cannot finish are the ones whose lifetime nobody has ever decided.
- Which of these does a test have to reset between cases? What happens to the next test when one forgets?
- Count how many objects
main()now knows about. If that number worries you, ask whether the alternative was fewer dependencies or merely hidden ones. - If you kept something global, name the property that makes it safe: is it immutable after start-up, is there exactly one writer, or does no reader depend on when the write happened? “It is only config” is not one of those.
- What breaks today if
carrier()is called beforeconfigure()? Now say where in the code that ordering rule is written down. - Did you say anything about the failure mode where a connection dies and the cached one is returned forever?
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
Not scored yet.
Reveal: a strong answer, a weak one, and the difference
Strong
Ownership table.
| State | Owner | Lifetime | Writers |
|---|---|---|---|
CONFIG | the process entry point | read once at start-up, then frozen | one, before anything else runs |
_CONNECTION | a Database object |
per process, but re-openable | the pool, nobody else |
FEATURE_FLAGS | a FeatureFlags object |
per request, so a flag flip takes effect without a deploy | the flag source, read-only to the app |
_carrier_client | the composition root | per process | constructed once, never reassigned |
The row that matters most is FEATURE_FLAGS. Today it is a dict frozen at import, so
the one thing a flag exists for — changing behavior without a deploy — is exactly what
it cannot do. Giving it a per-request lifetime is a behavior change, not a tidy-up, and it is worth
saying so out loud.
Composition root.
def build(settings: Settings) -> ReturnsApp:
database = Database(settings.dsn)
labels = CarrierLabelService(settings.carrier_key, timeout=2.0)
flags = FeatureFlags(source=settings.flag_url)
returns = ReturnService(
orders=SqlOrderRepository(database),
labels=labels,
flags=flags,
)
return ReturnsApp(returns)
if __name__ == "__main__":
build(Settings.from_environment()).run()
Three things changed that are not about tidiness. Construction order is now a fact of the code rather than of the import graph. A test builds the same graph with an in-memory repository and a fake label service, in one line, with nothing to reset afterwards. And the carrier timeout moved from 30 seconds to 2, because it became visible at the point where somebody had to choose it.
Left global: nothing mutable. Settings.from_environment() reads the
environment once, at the top of main, and returns a frozen object. The property that
makes that safe is not that it is “only config” — it is that it is immutable after
construction and has exactly one writer, so no reader can depend on when the write happened. If
settings later need to be reloadable, that property disappears and it stops being safe.
Weak — and plausible
Global state is bad because it creates hidden dependencies and makes testing difficult. I would
remove all of it and use dependency injection instead. Each of CONFIG,
_CONNECTION, FEATURE_FLAGS and _carrier_client becomes a
constructor parameter, and I would use a DI container so that the wiring is handled automatically
and services can be resolved wherever they are needed.
The container puts the phone line back. A resolver that any code can call to fetch
any dependency is a global registry with better manners: the dependency still does not appear in the
signature, and construction order is still implicit. The strong answer’s build() is
twelve lines that a reviewer can read top to bottom, and its whole value is that it is readable.
“Remove all of it” skips the only interesting decision. These four pieces of state have three different correct lifetimes, and one of them is currently wrong in a way that costs the business something — a feature flag that cannot be flipped. A table with lifetimes finds that; a blanket rule does not.
Keeping something global on purpose is a stronger answer than removing everything. It shows the rule is understood as a property (immutable, single writer, order-independent) rather than as a prohibition on a keyword.
Task 2 · The import table and the rule that enforces it
What to write
The package below has the tree you would draw on a whiteboard and the imports you find by grepping. In about twenty minutes write:
- The allowed-import table: for each package, which packages it may import.
- The three imports in the list below that break your table, and what each one costs in practice — not in principle.
- A fitness function: a test that fails if the rule is broken again. Real code, and it must fail on today’s codebase.
returns/
├── domain/ # ReturnRequest, EligibilityPolicy, Money
├── application/ # RequestReturn, ApproveReturn use cases
├── adapters/ # sql_repository.py, carrier_http.py, mailer_smtp.py
├── api/ # FastAPI routes
└── common/ # utils.py, settings.py, models.py, exceptions.py
# the imports, as grep finds them
# domain/eligibility.py: from returns.common.models import ReturnRow
# domain/money.py: from decimal import Decimal
# application/request.py: from returns.domain.eligibility import EligibilityPolicy
# application/request.py: from returns.adapters.carrier_http import CarrierHttpClient
# adapters/sql_repository.py: from returns.domain.request import ReturnRequest
# adapters/carrier_http.py: from returns.common.settings import CONFIG
# api/routes.py: from returns.application.request import RequestReturn
# api/routes.py: from returns.adapters.sql_repository import SqlReturnRepository
# common/models.py: from returns.domain.money import Money
Self-review — answer these before you open the reveal
- Is your table a total function? Every ordered pair of packages should be allowed or forbidden, with no pair left to judgement.
- For each violation you found, finish: “the day X changes, Y has to be redeployed/retested, and that costs…”. If you cannot, you found a rule violation and not a cost.
- Look hard at
common. Two of the nine imports involve it, in opposite directions. What does that make it? - Your fitness function: does it fail today? A check that passes on a codebase you have just declared broken is checking the wrong thing.
- Would your test also catch the violation arriving through a transitive import, or only a direct one? Say which, rather than assuming.
- Which allowed import in your table is the one you are least sure about? Write the sentence you would say in review to defend 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
Testability, observability, security, and reversibility
Technical communication and decision clarity
Not scored yet.
Reveal: a strong answer, a weak one, and the difference
Strong
Allowed-import table. Direction runs one way, from volatile to stable.
| Package | May import | May not import |
|---|---|---|
domain | the standard library only | application, adapters, api, common |
application | domain | adapters, api, common |
adapters | domain, and its vendor libraries | application, api |
api | application, domain, and the composition root | adapters directly — it receives them wired |
common | the standard library only | everything in this project |
Three violations, and what each costs.
domain/eligibility.py imports common.models.ReturnRow. The eligibility
rule — the most valuable thing in the package — now needs a database row to run. Cost:
every test of the policy needs a schema, and a column rename edits a business rule. This is the one
to fix first.
application/request.py imports CarrierHttpClient concretely. The use
case is welded to HTTP, so the carrier cannot be faked and cannot be swapped. Cost: no test of the
request flow can run without a network, which is why there are none.
common/models.py imports domain.money, while domain imports
common. That is a cycle, and it is what common always becomes: not a
library but a junction, where any change reaches everything. Cost: common cannot be
versioned, extracted, or reasoned about, and its import graph makes the other two violations look
local when they are not.
Fitness function. It fails today, three times, which is the point.
import ast
import pathlib
FORBIDDEN = {
"returns.domain": ("returns.application", "returns.adapters",
"returns.api", "returns.common"),
"returns.application": ("returns.adapters", "returns.api"),
"returns.common": ("returns.domain", "returns.application",
"returns.adapters", "returns.api"),
}
def imported_modules(path):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
yield node.module
elif isinstance(node, ast.Import):
for alias in node.names:
yield alias.name
def test_dependency_direction():
violations = []
for path in pathlib.Path("returns").rglob("*.py"):
package = "returns." + path.relative_to("returns").parts[0]
for module in imported_modules(path):
if module.startswith(FORBIDDEN.get(package, ())):
violations.append(str(path) + " imports " + module)
assert not violations, violations
It checks direct imports only. Transitive violations slip through, which I am accepting for now and writing down: the direct check catches the way this rule is actually broken, which is somebody adding an import to get a job finished on a Friday.
Weak — and plausible
The package should follow clean architecture, where dependencies point inwards towards the domain. The violations are that the domain imports from common, the application imports from adapters, and common imports from the domain. These break the dependency inversion principle and should be fixed by introducing interfaces in the domain layer and injecting the implementations.
For the fitness function I would use an existing tool such as import-linter with a layers contract
configured in setup.cfg.
The weak answer found all three violations and priced none of them. Every consequence is the name of a principle. A reviewer cannot use it to decide which one to fix on Monday, and neither can the author — which is why lists like this survive in codebases for years.
Reaching for the tool is fine; reaching for it instead of the table is not. Import-linter is a good answer to “how do I enforce this” and no answer at all to “what is the rule”. The contract still has to be written, and writing it is the entire exercise. The strong answer’s twenty lines exist to prove the rule is precise enough to run; on a real project, swapping them for a configured tool afterwards is a good trade.
The cycle is the finding the weak answer under-weights. It lists
common as one violation among three. It is the structural one: while it exists, no import
rule can hold, because everything is allowed to reach everything through the junction.
Task 3 · Three failures, three strategies
What to write
The returns request flow can go wrong in the three ways below. For each one, in about twenty minutes, write: which layer detects it, which layer decides what to do, what the customer sees, whether it is retried and by whom, and what appears in the logs.
- The carrier’s label endpoint answers
503 Service Unavailable. - The item is 34 days old and the policy window is 30 days.
- The database rejects the insert: a return for this order already exists.
Then answer the question underneath them: one of these three is not an error. Which, and what follows from that?
Self-review — answer these before you open the reveal
- For each case, did you name a different layer for detects and decides? If they are always the same layer, the decision is being made wherever the exception happened to surface.
- Which of your three retries? Now say what makes that one safe to retry and the others not.
- What does the customer see in case 2? If the answer contains the word “error”, re-read the case.
- In case 3, is the second request a failure or a repeat? Your answer determines whether the customer gets a label or an apology.
- Did any of your three answers put a retry inside the domain? Say which layer you moved it to and why that layer.
- What is in the log line, and would it let you find the one customer this happened to?
Now score what you actually wrote, not what you meant. Each dimension links to its level descriptions.
Data ownership, contract evolution, and failure semantics
Testability, observability, security, and reversibility
Domain fidelity and invariant protection
Not scored yet.
Reveal: a strong answer, a weak one, and the difference
Strong
1. Carrier 503 — an infrastructure failure, handled at the adapter. Detected by the HTTP adapter, which is the only code that knows what 503 means. Decided by the adapter for the retry (two attempts, 200 ms and 800 ms, jittered, only on 502/503/504 and timeouts — never on 4xx) and by the application layer for what happens when retries are exhausted: accept the return, mark the label pending, and hand the intent to a worker. The customer sees “your return is approved, the label is on its way” and an email when it arrives. The domain never learns that a carrier exists. Log: one line per attempt at debug, one warning when the request finally degrades to pending, with the correlation id and the return id.
2. Item is 34 days old — not an error at all. Detected by the
EligibilityPolicy in the domain, which is the point of it existing. Decided by the
domain too: it returns a Refused decision with a reason, rather than raising. The
customer sees a plain explanation of the window and a route to appeal. Nothing is retried, because
nothing failed. Log: info at most, or nothing — and definitely not the error stream, where a
normal business outcome pollutes every alert threshold built on top of it.
3. Duplicate return for the order — a race, and probably a repeat.
Detected by the SQL adapter as a unique-constraint violation, which is the database doing its job.
Decided by the application layer, and the decision depends on the idempotency key that came in with
the request: same key means this is a retry of an intent already satisfied, so return the original
return’s id and label with the original result. A different key means a genuine second attempt
to return the same order, which is a domain refusal (case 2’s shape:
AlreadyReturned). Either way the customer never sees a constraint name. Log: info for
the idempotent replay, with both keys, so a support agent can tell a duplicate click from a duplicate
request.
The one that is not an error is case 2, and two things follow. First, a refusal is a value the domain returns, not an exception it raises: exceptions are for the paths the caller cannot handle, and every caller here handles a refusal. Second, refusals must not reach the error log, or the initiative’s own guardrail metric — how many returns are refused, and why — ends up buried in the same stream as carrier outages. That number is a product measure. It wants a counter, not a stack trace.
Case 3 is the interesting near-miss: a constraint violation looks like case 1 (infrastructure) and is often case 2 (a domain rule the schema happened to notice first). Deciding which it is needs the idempotency key, which is why the key belongs in the request contract rather than in the adapter.
Weak — and plausible
All three should be caught and turned into a proper application exception with a clear message.
The carrier 503 should be retried with exponential backoff. The policy violation should raise a
ValidationError that the API layer converts to a 400 response. The duplicate should
raise a ConflictError that becomes a 409. All three should be logged at error level with
the stack trace so we can investigate, and we should add alerting on the error rate.
The weak answer is a coherent, common, and expensive design. Its central mistake is
treating a business outcome as an error. Once refusals raise ValidationError and log at
error level, the error rate becomes a measure of how many customers returned things late — and
the alert built on it fires during a promotion. Teams then raise the threshold, and the alert stops
finding carrier outages too.
“Retry with exponential backoff” is missing its two hard parts: which status codes are safe to retry (retrying a 400 is just slower failing), and what happens when the retries run out. The strong answer degrades to a pending label and keeps the customer’s outcome, which is the decision the application layer exists to make.
The duplicate is answered without the key. A 409 tells the customer their second click was a mistake. With an idempotency key, the second click returns the first label, which is what they wanted and what they will otherwise phone support to ask for.
Part 3 · What to practice next
| If you missed | What it points at | What to practice next |
|---|---|---|
| p2q1 or p2q2 | Choosing the unit — class, function, protocol — from what varies rather than from habit. | 0015, 0016, 0017. |
| p2q3 | Global coupling — the dependency that no import declares. | 0019 and 0020, then Task 1 again. |
| p2q4 | Dependency direction as an enforced rule rather than a diagram. | 0022, then the lab’s fitness functions. |
| p2q5 | Keeping infrastructure concerns — including concurrency — out of business rules. | 0024 and 0023. |
| p2q6 | Events versus commands — whether the producer cares about the outcome. | 0025, then 0033 for the system-scale version. |
| Task 1 scored below Practicing | State without a stated lifetime, or a container standing in for a composition root. | 0020 and lab checkpoint 2. |
| Task 2 scored below Practicing | A rule with no cost attached, or a check that would pass on broken code. | 0022, then write the check for your own repository. |
| Task 3 scored below Practicing | Refusals treated as failures, or retry policy living in the wrong layer. | 0021 and 0014. |
Exit capability for Phase II. You can point at any shared state and name its owner and lifetime, write the import rule for a package and a test that enforces it, and place a retry, a translation and a refusal in three different layers on purpose. If the fitness function in Task 2 was the hard part, that is the one to repeat — it is the habit the rest of the course keeps borrowing.
Next: the Phase 3 assessment, after lessons 0027–0038, where the questions stop having a best answer.