Software Design Reference Assessment · Phase I · Core Designer

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.

Scenarios: 20 minWritten tasks: 60 min Self-scoring: 20 min

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:

  1. The reasons-to-change list. Every entry names who or what causes the change, not the layer it lives in.
  2. The one boundary you would cut first, what is on each side of it, and what the cut buys within a week.
  3. 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

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:

  1. List every invariant this code implies, and for each, name the object that should own it.
  2. Rewrite one of them as a refusal — real code, in which no caller can produce the invalid state.
  3. 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

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:

  1. Three findings, each with the force it violates, the consequence somebody would notice in production, and the smallest change that removes it.
  2. A merge decision per finding: block, or file and merge. Say why.
  3. 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

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 missedWhat it points atWhat to practice next
p1q1 or p1q2Reasons to change and dependency chains — you are seeing symptoms rather than sources.0006 and 0003, then Task 1 again from scratch.
p1q3 or p1q4Emerging structure — branches and hierarchies that are asking to become composition.0004, 0007, 0008.
p1q5Value objects versus entities — identity is doing the work, not the attribute list.0012, then redo Task 2 part 3.
p1q6Fail-fast — you are tolerant of errors that convert themselves into plausible successes.0014 and 0021 for the layered version.
Task 1 scored below PracticingThe 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 PracticingRules are being moved rather than enforced, or the cross-object invariant was skipped.0011 and 0055.
Task 3 scored below PracticingFindings 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.