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
unittestif 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 outside | What they are to you | What that costs |
|---|---|---|
| The order system | Authoritative 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. |
| ShipMaster | The carrier. Sells return labels, serves US, CA and MX. | Their outage strands your returns; their country list is currently your product's market rule. |
| PayPortal | Moves the money. Speaks in minor units. | No idempotency, so a retry pays twice. |
| Finance | Reads the returns table directly, nightly, from a job you cannot deploy. | Any schema change becomes a four-step migration and a conversation. |
| Three client teams | Parse 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.
| Exercise | Lessons | Observable outcome |
|---|---|---|
| 1.1 Make the coupling and cohesion visible | 0001, 0002 | Your own defect inventory, ranked by cost, compared afterwards against solutions/DEFECT_MAP.md. |
| 1.2 Kill the train wrecks | 0003 | test_the_order_system_answer_is_translated_before_the_domain_sees_it goes green. |
| 1.3 Name the strategy that is already there | 0004 | The refund tests still pass and check_eligibility holds no arithmetic branch. |
| 1.4 Review the module the assistant wrote | 0005, 0010 | All 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 change | 0006 | A written split of ReturnsService, with the first seam named and justified. |
| 1.6 Compose the workflow instead of branching it | 0007, 0008 | submit fits on one screen with no try in it; every submit test still passes. |
| 1.7 Put the carrier behind a port | 0009 | Four fitness functions go green, including test_no_vendor_name_appears_in_a_domain_module. |
| 1.8 Make invalid money impossible | 0011, 0012 | Three 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 rules | 0011, 0012 | ReturnRequestHasNoInvariants goes red in its entirety — replace it with tests that assert the refusals. |
| 1.10 One vocabulary, one validation | 0006, 0028 | test_the_reason_vocabulary_is_defined_once and test_incoming_payloads_are_validated_in_one_place. |
| 1.11 Draw the repository boundary | 0013 | Most tests stop needing SQLite; the domain contains no SQL. |
| 1.12 Fail fast at the right boundary | 0014 | The 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.
| Exercise | Lessons | Observable outcome |
|---|---|---|
| 2.1 Decide what deserves to be a class | 0015 | service.py shrinks; a class with no state stops pretending to be one. |
| 2.2 Inject the collaborators | 0016 | tests/support.reset_world() gets measurably shorter — it is a direct measure of the system's hidden state. |
| 2.3 Depend on protocols, not implementations | 0017 | A ports.py exists; every port has a three-line fake and a shared contract test. |
| 2.4 Stream the export instead of building it | 0018 | The finance export stops scaling with history; the batched backfill 3.5 needs already exists. |
| 2.5 Cut the phone lines | 0019 | Three global-state fitness functions go green, including test_no_module_holds_mutable_state_at_import_time. |
| 2.6 Give the shared state an owner | 0020 | test_there_is_no_process_wide_current_user; the test that pinned the leaking singleton gets deleted. |
| 2.7 Build an error taxonomy | 0021 | api.py has one except per error class and no catch-all; each status code is one you can defend. |
| 2.8 Stop swallowing failures | 0021, 0014 | test_no_exception_is_swallowed_without_a_trace and test_the_package_configures_a_logger. |
| 2.9 Decide where SQL is allowed to appear | 0022 | test_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 clock | 0023 | Window rules become testable against a frozen clock instead of fixture dates computed from now(). |
| 2.11 Get the async work out of the domain | 0024 | test_the_domain_does_not_start_background_work. |
| 2.12 Publish events instead of calling consumers | 0025, 0022 | test_no_module_depends_on_more_than_five_siblings — service.py currently imports ten. |
| 2.13 Buy the retry, or build it | 0026 | A 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.
| Exercise | Lessons | Observable outcome |
|---|---|---|
| 3.1 Build or buy the carrier | 0027, 0009 | test_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 duplication | 0028, 0034 | The 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 asynchronous | 0031, 0024 | test_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 refunds | 0032 | test_a_repeated_refund_moves_the_money_once — and the API contract grows an idempotency key. |
| 3.5 Stability or speed: expand and contract | 0035, 0058 | Three 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 contract | 0033, 0057 | test_the_response_envelope_declares_a_version; the status rename ships without breaking the support console. |
| 3.7 Read the upstream payload tolerantly | 0033 | test_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 ledger | 0037, 0038 | Three 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.
| Exercise | Lessons | Observable outcome |
|---|---|---|
| 4.1 Give every request a correlation id | 0049, 0021 | test_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 system | 0049, 0043, 0050 | test_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 test | 0049, 0035 | A 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 decision | 0046 | One 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.
| Exercise | Lessons | Observable outcome |
|---|---|---|
| 5.1 Make Return a state machine | 0054, 0052 | Three state fitness functions; the return stranded in APPROVED gets a name and a way out. |
| 5.2 Put the invariant at the consistency boundary | 0055, 0012 | A 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 modules | 0056, 0053 | test_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 seam | 0057, 0045 | test_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 model | 0058, 0045 | A schema designed from five access patterns, with a declared read model for finance. |
| 5.6 Choose the architecture style from the drivers | 0059, 0043 | test_the_composition_root_is_a_single_named_place, and a comparison where a named driver decides. |
| 5.7 Design the deployment and release path | 0060, 0049 | test_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.
| File | Property under test | Phases |
|---|---|---|
test_fitness_dependency_direction.py | The arrows point at the domain; no module depends on more than five siblings. | 1, 2 |
test_fitness_vendor_names.py | No vendor names a domain concept; upstream payloads are translated at the edge. | 1, 2, 3 |
test_fitness_global_state.py | No module-level mutable state; no process-wide current user; configuration is passed, not imported. | 2 |
test_fitness_observability.py | Correlation ids on responses and audit rows; nothing swallowed; the outcome measured. | 2, 4 |
test_fitness_domain_invariants.py | Money is a value object; status is a state machine; each rule has one home; refunds are idempotent. | 1, 3, 5 |
test_fitness_generated_code.py | Nothing admits to being unreviewed; no unbounded cache; no Manager or Helper class names. | 1 |
test_fitness_contracts_and_async.py | The envelope is versioned; upstream is read tolerantly; delivery is idempotent; the migration reached version 2. | 2, 3 |
test_fitness_deep_modules.py | Narrow 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.
| Where | What it contains |
|---|---|
| solutions/DEFECT_MAP.md | Every 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
- Start at lesson 0001 and open phase-1.md alongside it.
- The worked initiative packet for the same case is the capstone; the lab's phase-4 and phase-5 artifacts feed straight into it.
- Scored assessment of the judgement these exercises build lives in assessments.
- Terms used here — characterization test, fitness function, expand/contract, idempotency key, tolerant reader, correlation id, dead-letter queue, unit of work, composition root — are all in the glossary.
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.