Software Design Reference Code Lab · Companion practice system

Code lab · The returns-resolution service

The Code Lab

One small Python system that works, has tests, and is deliberately built badly. You improve it as you move through the course — forty-four exercises, each with a test that goes green and a trade-off you have to name.

The lab exists for the learner who cannot practise on a work codebase, and as a second opinion for the one who can. It is the same case the course uses from lesson 0039 onwards: a customer asks to return something, the service decides whether that is allowed, holds suspicious requests for a human, buys a return label, moves the money, and tells the customer what happened.

The code is not good, and that is the point. It carries mixed concerns, hidden global state, leaking vendor contracts, weak invariants, a retry that is not safe to retry, an API three other teams are coupled to, a schema that needs a careful migration, almost no observability, and one module that was generated by an assistant and never reviewed. The defects are not listed on this page. Finding them is exercise 1.1.

Setup, and the one command

Nothing to install. Python 3.10 or newer, standard library only. No network, no accounts, no containers: the order system, the carrier and the payment gateway are in-process fakes, and the database is SQLite in memory.

From lab/

python3 -m returns_lab.demo
Runs a scripted scenario: thirteen numbered steps through submission, denial, a fraud hold, an agent approval, a carrier outage, a notification retry, the finance report and the process counters.
python3 run_tests.py
The one command. Uses pytest if you have it, falls back to unittest if you do not. Both runners see the same tests.
python3 -m pytest -q
The same suite, if you prefer to type the runner. Install it with pip install -r requirements-dev.txt; it is optional.
python3 -m unittest discover -s . -p "test_*.py"
The standard-library equivalent. Always available.
python3 run_tests.py fitness
Just the architecture fitness functions, while you are working one.

A green run today looks like this — 85 behavioural tests pass, three fitness functions are already true, and 43 fitness functions fail on purpose and are marked as expected failures. Nothing is broken.

# Ran 131 tests in 0.4s
#
# OK (expected failures=43)

The map of the system

lab/
├── returns_lab/                the system (17 modules, ~1,600 lines)
│   ├── __init__.py
│   ├── config.py               settings, the current user, counters, the route table
│   ├── db.py                   the SQLite connection
│   ├── migrations.py           the schema, at version 1
│   ├── models.py               ReturnRequest and friends
│   ├── policy.py               eligibility rules and the refund amount
│   ├── fraud.py                the guardrail that sends returns to a human
│   ├── agent_review.py         the manual review queue
│   ├── service.py              ReturnsService: the workflow
│   ├── notifications.py        email and SMS, inline and on a worker thread
│   ├── orders.py               client for the order system (another team owns it)
│   ├── vendor_shipmaster.py    the carrier SDK
│   ├── payments.py             the refund gateway SDK
│   ├── reporting.py            finance's nightly job, the oldest consumer
│   ├── ai_helpers.py           formatting and validation helpers
│   ├── api.py                  the JSON request layer three other teams call
│   └── demo.py                 the scripted scenario
├── tests/                      characterization tests + simulated consumers
├── fitness/                    architecture fitness functions (mostly red)
├── checkpoints/                phase-1.md … phase-5.md — the exercises
└── solutions/                  reference solutions, with the forces written down
Who is outsideWhat they are to youWhat that costs
The order systemAuthoritative for what was bought, for how much, and when it arrived. You read; you never write.It can be down, and their payload is their schema.
ShipMasterThe carrier. Sells return labels, serves US, CA and MX.Their outage strands your returns; their country list is currently your product's market rule.
PayPortalMoves the money. Speaks in minor units.No idempotency, so a retry pays twice.
FinanceReads the returns table directly, nightly, from a job you cannot deploy.Any schema change becomes a four-step migration and a conversation.
Three client teamsParse the JSON envelope in api.py.One of them branches on the raw status string, so a rename breaks their release.

Two kinds of test, and why both

Characterization tests — tests/

They answer does it still do what it did? They were written before you changed anything, and they pin the behaviour nobody likes as carefully as the behaviour everybody wants: the cent lost to floating-point arithmetic, the customer who receives the same email twice, the return stranded in APPROVED after a carrier outage.

They are green today. If one goes red during a refactor you have changed behaviour — decide whether you meant to, and if you did, change the test in the same commit with a message that says why. That discipline is Michael Feathers' safety net from Working Effectively with Legacy Code, and it is what makes restructuring unfamiliar code safe rather than brave.

Three of the files are not tests of the lab at all: they are simulated consumers. A support console, a customer web app, a partner portal and finance's nightly job, each written the way that team actually wrote its client. They are the coordination artifact — when you change the API, they tell you whose release you just broke.

Fitness functions — fitness/

They answer is it still built the way we said? Not behaviour — structure. "The domain package imports no infrastructure." "No vendor names a domain concept." "Every handler answers with a correlation id." You cannot check those by calling the system; you check them by reading the imports and the source.

Forty-three of them fail today, because each describes the system you are building rather than the one you have. Each carries a @checkpoint("phase-1 / exercise 1.7") marker — unittest.expectedFailure plus the label of the exercise that turns it green. A named, expected failure is a debt you have declared; it does not fail the build.

To take one on, delete its @checkpoint line and make it pass. If you fix the code and leave the decorator, the runner reports an unexpected success and the suite goes red — deliberately. A fitness function still marked as failing after you fixed it is a lie about the architecture.

Phase 1 · Core Designer — checkpoints/phase-1.md

Lessons 0001–0014. See the structure, then protect the domain core. Twelve exercises, worked in order.

ExerciseLessonsObservable outcome
1.1 Make the coupling and cohesion visible0001, 0002Your own defect inventory, ranked by cost, compared afterwards against solutions/DEFECT_MAP.md.
1.2 Kill the train wrecks0003test_the_order_system_answer_is_translated_before_the_domain_sees_it goes green.
1.3 Name the strategy that is already there0004The refund tests still pass and check_eligibility holds no arithmetic branch.
1.4 Review the module the assistant wrote0005, 0010All three tests in test_fitness_generated_code.py go green; the characterization tests that pinned its defects get deleted on purpose.
1.5 Count the reasons to change0006A written split of ReturnsService, with the first seam named and justified.
1.6 Compose the workflow instead of branching it0007, 0008submit fits on one screen with no try in it; every submit test still passes.
1.7 Put the carrier behind a port0009Four fitness functions go green, including test_no_vendor_name_appears_in_a_domain_module.
1.8 Make invalid money impossible0011, 0012Three money fitness functions go green; the demo prints the same number in the ledger and at the vendor for the first time.
1.9 Give the Return an aggregate and its rules0011, 0012ReturnRequestHasNoInvariants goes red in its entirety — replace it with tests that assert the refusals.
1.10 One vocabulary, one validation0006, 0028test_the_reason_vocabulary_is_defined_once and test_incoming_payloads_are_validated_in_one_place.
1.11 Draw the repository boundary0013Most tests stop needing SQLite; the domain contains no SQL.
1.12 Fail fast at the right boundary0014The review-queue cap stops letting 51 through, and the order-system outage stops silently weakening the fraud guardrail.

The interesting one is 1.12. When the order system is down the watch-list rule cannot run, and you have three defensible answers — fail the request, proceed without the rule, or hold the return for a human. Each spends a different budget. Write down which you chose and what would change your mind.

Phase 2 · System Designer — checkpoints/phase-2.md

Lessons 0015–0026. Flexible, composable code, and eleven module-level globals that have to find owners.

ExerciseLessonsObservable outcome
2.1 Decide what deserves to be a class0015service.py shrinks; a class with no state stops pretending to be one.
2.2 Inject the collaborators0016tests/support.reset_world() gets measurably shorter — it is a direct measure of the system's hidden state.
2.3 Depend on protocols, not implementations0017A ports.py exists; every port has a three-line fake and a shared contract test.
2.4 Stream the export instead of building it0018The finance export stops scaling with history; the batched backfill 3.5 needs already exists.
2.5 Cut the phone lines0019Three global-state fitness functions go green, including test_no_module_holds_mutable_state_at_import_time.
2.6 Give the shared state an owner0020test_there_is_no_process_wide_current_user; the test that pinned the leaking singleton gets deleted.
2.7 Build an error taxonomy0021api.py has one except per error class and no catch-all; each status code is one you can defend.
2.8 Stop swallowing failures0021, 0014test_no_exception_is_swallowed_without_a_trace and test_the_package_configures_a_logger.
2.9 Decide where SQL is allowed to appear0022test_no_domain_module_contains_sql; the import rule is a table you wrote and a test that enforces it.
2.10 Adapters for everything outside, including the clock0023Window rules become testable against a frozen clock instead of fixture dates computed from now().
2.11 Get the async work out of the domain0024test_the_domain_does_not_start_background_work.
2.12 Publish events instead of calling consumers0025, 0022test_no_module_depends_on_more_than_five_siblings — service.py currently imports ten.
2.13 Buy the retry, or build it0026A written decision that names what you are not building.

Phase 3 · Master Designer — checkpoints/phase-3.md

Lessons 0027–0038. Every exercise here has more than one defensible answer. The deliverable is the decision and the cost you accepted.

ExerciseLessonsObservable outcome
3.1 Build or buy the carrier0027, 0009test_the_carrier_is_reachable_through_a_port_named_for_the_job, plus a ledger with a break-even you can defend.
3.2 Abstraction or duplication0028, 0034The five order-system calls per submit either become one, or stay with a written justification. Both are acceptable; silence is not.
3.3 Synchronous or asynchronous0031, 0024test_delivery_is_idempotent_per_channel and test_a_notification_that_never_lands_ends_up_somewhere_visible. One SMS failure stops producing two emails.
3.4 Consistency or availability: idempotent refunds0032test_a_repeated_refund_moves_the_money_once — and the API contract grows an idempotency key.
3.5 Stability or speed: expand and contract0035, 0058Three migration fitness functions, and — the real test — every finance test staying green at each of the four steps.
3.6 Coupling or coordination: version the contract0033, 0057test_the_response_envelope_declares_a_version; the status rename ships without breaking the support console.
3.7 Read the upstream payload tolerantly0033test_the_upstream_order_payload_is_read_tolerantly — two unknown keys added, one optional key removed, still working.
3.8 Correctness or delivery speed: the debt ledger0037, 0038Three rows, each with a monthly cost, a repayment trigger and an owner.

3.5 is the exercise the whole lab is built around. Making money correct in the domain (1.8) forces a schema change; the schema has a consumer you cannot deploy; so a one-line fix becomes four deploys, a backfill, and a conversation with another team. That sequence is the lesson.

Phase 4 · Initiative Lead — checkpoints/phase-4.md

Lessons 0039–0050. The part of an initiative that is code: telemetry that answers the outcome question, a gate that can fail a release, and a record of one decision.

ExerciseLessonsObservable outcome
4.1 Give every request a correlation id0049, 0021test_every_response_carries_a_correlation_id and test_the_audit_log_records_a_correlation_id — and you can reconstruct one customer's return from the logs alone.
4.2 Instrument the outcome, not just the system0049, 0043, 0050test_the_outcome_metric_is_measured_not_just_counted, plus median and p95 resolution time printed for a demo run.
4.3 Make the rollout gate a test0049, 0035A tests/test_rollout_gate.py that passes on a good week and fails on a bad one, naming the breached condition and the distance.
4.4 Write the ADR for one lab decision0046One record with a fairly-stated rejected option and an observable revisit trigger. A worked example ships at solutions/phase-4/ADR-0001-expand-contract.md.

The initiative's stated outcome is the one the course uses throughout: median resolution 72 hours to under 4; support-contact rate down 25% from 41%; the manual review queue under 50 cases a day. Exercise 4.2's point is that none of the system's seven counters can tell you whether any of that happened.

Phase 5 · System Architect — checkpoints/phase-5.md

Lessons 0051–0060. Design the system you would have built, using the evidence the lab gave you. Several of these will make you undo something from phase 1 — that is the point.

ExerciseLessonsObservable outcome
5.1 Make Return a state machine0054, 0052Three state fitness functions; the return stranded in APPROVED gets a name and a way out.
5.2 Put the invariant at the consistency boundary0055, 0012A unit of work: the return, its review case and its events commit together or not at all, and events publish after the commit.
5.3 Carve the components as deep modules0056, 0053test_the_returns_component_offers_at_most_six_operations and test_no_module_is_named_after_a_layer_instead_of_a_capability.
5.4 Write both contracts for one seam0057, 0045test_there_is_a_declared_boundary_between_components, plus a deprecation policy with a duration in it.
5.5 Map the domain model onto the data model0058, 0045A schema designed from five access patterns, with a declared read model for finance.
5.6 Choose the architecture style from the drivers0059, 0043test_the_composition_root_is_a_single_named_place, and a comparison where a named driver decides.
5.7 Design the deployment and release path0060, 0049test_the_deployment_unit_can_be_started_without_import_side_effects; a health check that asserts something.

The finish line

Every fitness function green, no @checkpoint decorator left, and python3 run_tests.py reporting OK with zero expected failures. What you hand someone else is not the code: it is the transition table, the two contracts, the schema, the style comparison and the ADR — enough for a reviewer to judge your judgement without reading a line of Python.

The fitness functions in detail

Eight files, forty-six checks, forty-three of them red today. Full notes in fitness/README.md.

FileProperty under testPhases
test_fitness_dependency_direction.pyThe arrows point at the domain; no module depends on more than five siblings.1, 2
test_fitness_vendor_names.pyNo vendor names a domain concept; upstream payloads are translated at the edge.1, 2, 3
test_fitness_global_state.pyNo module-level mutable state; no process-wide current user; configuration is passed, not imported.2
test_fitness_observability.pyCorrelation ids on responses and audit rows; nothing swallowed; the outcome measured.2, 4
test_fitness_domain_invariants.pyMoney is a value object; status is a state machine; each rule has one home; refunds are idempotent.1, 3, 5
test_fitness_generated_code.pyNothing admits to being unreviewed; no unbounded cache; no Manager or Helper class names.1
test_fitness_contracts_and_async.pyThe envelope is versioned; upstream is read tolerantly; delivery is idempotent; the migration reached version 2.2, 3
test_fitness_deep_modules.pyNarrow interfaces over real substance; boundaries declared; no import-time side effects.5

Three are green today. Two are green and worth having. One — test_no_domain_module_imports_sqlite3_directly — is green and nearly worthless, and is kept on purpose as a worked example of a fitness function that measures the wrong thing: it passes only because the coupling it should catch is one import hop away. Writing a fitness function that cannot fail is the most common way to feel protected while drifting.

Writing your own. The helpers in fitness/_marks.py are the whole toolkit: source(module) for text, tree(module) for an AST, imported_siblings(module) for the dependency graph. Most useful fitness functions are ten lines of ast and one assertion against an empty collection — assert the list of offenders rather than a boolean, so the failure message names them.

Reference solutions

Read them after you have tried the exercise. They are not an answer key: several make a call you could reasonably make differently, and where that is true the file gives the second answer and the circumstance that would make it right — a maintenance window instead of expand/contract, a Decimal scale instead of minor units, coordination instead of versioning, feature-first packages instead of layers.

WhereWhat it contains
solutions/DEFECT_MAP.mdEvery defect in the lab, with the module, the lesson and the exercise. Spoilers — write your own list first.
solutions/phase-1/Money, the Return aggregate, the refund strategy, the carrier port, the repository with a contract test run against two implementations.
solutions/phase-2/The error taxonomy, retry in the adapter, events published after commit, the composition root.
solutions/phase-3/The four-step migration, idempotent refunds, the tolerant reader, and a worked debt ledger.
solutions/phase-4/Outcome telemetry, the rollout gate as a pure function, and ADR-0001.
solutions/phase-5/The state machine as a reviewable table, the invariant placement argument, the component split and the style decision.

Every reference solution is executable and tested — tests/test_reference_solutions.py runs all five verification scripts as part of the lab's own build, so a broken solution fails the suite. They are pieces, not a finished replacement system: wiring them together is exercises 2.2, 2.5 and 5.6, and doing that is where the seams that do not quite fit show up.

Where this fits

Full setup notes, the honest warning, and the file-by-file map are in lab/README.md, which is the copy that travels with the code.