Software Design Reference Assessment · Phase II · System Designer

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.

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

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:

  1. 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.
  2. The composition root — real code that builds the object graph once, in one visible place, and hands the pieces to whoever needs them.
  3. 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

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.

StateOwnerLifetimeWriters
CONFIGthe process entry point read once at start-up, then frozenone, before anything else runs
_CONNECTIONa Database object per process, but re-openablethe pool, nobody else
FEATURE_FLAGSa FeatureFlags object per request, so a flag flip takes effect without a deploy the flag source, read-only to the app
_carrier_clientthe composition root per processconstructed 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:

  1. The allowed-import table: for each package, which packages it may import.
  2. The three imports in the list below that break your table, and what each one costs in practice — not in principle.
  3. 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

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.

PackageMay importMay not import
domainthe standard library only application, adapters, api, common
applicationdomainadapters, api, common
adaptersdomain, and its vendor libraries application, api
apiapplication, domain, and the composition root adapters directly — it receives them wired
commonthe 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.

  1. The carrier’s label endpoint answers 503 Service Unavailable.
  2. The item is 34 days old and the policy window is 30 days.
  3. 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

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 missedWhat it points atWhat to practice next
p2q1 or p2q2Choosing the unit — class, function, protocol — from what varies rather than from habit.0015, 0016, 0017.
p2q3Global coupling — the dependency that no import declares.0019 and 0020, then Task 1 again.
p2q4Dependency direction as an enforced rule rather than a diagram.0022, then the lab’s fitness functions.
p2q5Keeping infrastructure concerns — including concurrency — out of business rules.0024 and 0023.
p2q6Events versus commands — whether the producer cares about the outcome.0025, then 0033 for the system-scale version.
Task 1 scored below PracticingState without a stated lifetime, or a container standing in for a composition root.0020 and lab checkpoint 2.
Task 2 scored below PracticingA 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 PracticingRefusals 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.