# Phase 5 — System Architect Lessons 0051–0060. Design the system you would have built, using everything the lab taught you about the one that exists. Phase 5 is where the small fixes become an architecture. Several of these exercises will make you undo something from phase 1 or 2 — that is the point. You now have evidence you did not have then. --- ## 5.1 Make Return a state machine **Lessons:** 0054 (Design State as a Machine, Not a Column), 0052 (Building the Domain Model) **Do this.** Status is a free string with nine values, two of which are the same state spelled two ways. Nothing constrains transitions: a refunded return can go back to submitted. Approving a held return does not close its review case. And the carrier outage leaves a return in `APPROVED` — a state the product has no name for and nothing ever moves out of. Write the transition table first, on paper: states down the side, events across, the target state in each cell, and a mark in every cell that is illegal. Then implement it so illegal transitions raise, and so every state has a way out. **Observable outcome.** In `fitness/test_fitness_domain_invariants.py`: - `test_status_is_an_enum_not_a_string` - `test_there_is_exactly_one_spelling_of_each_state` - `test_an_illegal_transition_is_refused` `SubmitStatusVocabulary` in `tests/test_characterization_submit.py` goes red in its entirety. That whole class exists to record the two spellings; delete it. **Hints.** - The table will expose states that are missing. `APPROVED` with no label is a real state — call it `awaiting_label` — and it needs a transition that retries, not silence. - `review_reason` is carrying two different facts (a denial code and a fraud explanation). The state machine will not close until you separate them. - Terminal states matter: which states can a return never leave? `refunded`, `denied`, `cancelled`. Everything else needs an exit and a timeout. - The review case has its own small machine — open, assigned, closed — and approving the return must close it. That is the transition nobody wrote. **Forces to weigh.** A transition table is more code than a string column and it will refuse things the current system allows, including things support does manually today. Find out what those are before you ship it: an escape hatch with an audit trail beats a machine so strict that operations route around it. --- ## 5.2 Put the invariant at the consistency boundary **Lessons:** 0055 (Protect Invariants at the Consistency Boundary), 0012 (Value Objects & Aggregates) **Do this.** `db.py` runs in autocommit, so every write is its own transaction and nothing wraps the multi-step `submit`. A crash between "mark the return held" and "open the review case" leaves a return under review with nobody looking at it. The daily cap check has the same shape: two concurrent submits both read 49 and both insert. Name the invariants, then place each one: - A held return has exactly one open review case. *(Same transaction.)* - A refunded return has exactly one payment reference. *(Same transaction, and the idempotency record from exercise 3.4 is part of it.)* - No more than 50 review cases are opened per day. *(A boundary you cannot hold transactionally across processes — decide what you actually promise.)* - A return's refund never exceeds the order line's value. *(Needs a fact from another team's system — so it is a check at submission, not an invariant.)* **Observable outcome.** A unit of work around the workflow: the return, its review case and its events commit together or not at all. The events from exercise 2.12 publish *after* the commit. **Hints.** - Lesson 0055's rule: what must be true together must change together, in one transaction, inside one aggregate boundary. What must merely be *eventually* true gets a reconciliation job and a metric, not a lock. - The 50/day cap is the interesting one. It is a promise to the support team, not a correctness invariant. Enforcing it exactly costs you a lock on a hot path; enforcing it approximately costs you 52 cases on a bad day. Say which you chose and what the support lead thinks of it. - `db._WRITE_LOCK` is a single-process fiction. Two workers and it is gone. **Forces to weigh.** Every invariant you enforce transactionally shrinks what you can distribute later. Every one you relax needs a detection mechanism and someone who acts on it. The architecture question is which invariants are worth a transaction boundary, and the answer determines your component boundaries — not the other way round. --- ## 5.3 Carve the components as deep modules **Lessons:** 0056 (Carve Components as Deep Modules), 0053 (Responsibility and Change Boundaries) **Do this.** `ReturnsService` exposes eight public methods over seven responsibilities. Redraw the system as three or four components, each with a narrow interface and real substance behind it. Candidates: return resolution (the workflow and its state machine), risk (eligibility and fraud), fulfilment (labels and carriers), settlement (refunds and idempotency), and customer communication. For each, write the interface — the operations, the errors, and nothing else — and then argue the split *against itself*. Four components for one team of six may be worse than two. **Observable outcome.** In `fitness/test_fitness_deep_modules.py`: - `test_the_returns_component_offers_at_most_six_operations` - `test_no_module_is_named_after_a_layer_instead_of_a_capability` **Hints.** - Ousterhout's depth ratio: interface small, implementation large. A component with five methods and forty lines is not deep, it is a pass-through. - `agent_review` is the honest test case. It is currently a separate module with four functions and no rules. Is it a component, or is it part of return resolution? Argue both, then pick. - Names: `service.py`, `models.py` and `ai_helpers.py` all name a layer or a provenance. `resolution.py`, `risk.py`, `fulfilment.py` name a capability. **Forces to weigh.** Deep modules hide decisions, which means changing a hidden decision needs no coordination — and means readers cannot see it. Shallow modules are transparent and expensive. The number of components should track the number of *independent reasons to change* you found in exercise 1.5, not the number of nouns in the domain. --- ## 5.4 Write both contracts for one seam **Lessons:** 0057 (Design Contracts: Internal and External), 0045 (Data, State and Contracts) **Do this.** Pick the risk component (eligibility plus fraud). Write two contracts for it. The **internal** one: the Python interface. What it promises, what it requires of callers, what it raises, what it does not do. Preconditions, postconditions, error types, thread-safety, whether it reads the clock. The **external** one: the JSON envelope in `api.py`, which three teams parse. Field by field: required or optional, the vocabulary of each enum, what happens when you add a field, and what a consumer must tolerate. Then say what "breaking" means for each and how you would deprecate. **Observable outcome.** In `fitness/test_fitness_deep_modules.py`: - `test_there_is_a_declared_boundary_between_components` — a `ports.py` or `contracts.py` that holds the internal contracts as protocols. `tests/test_consumer_contract_api.py` is your external contract's test. Its `ENVELOPE_KEYS` and `RETURN_KEYS` sets are the specification; write the prose that explains them. **Hints.** - The two contracts have different change costs. Renaming an internal method is a refactor; renaming an external field is a quarter of coordination. - The status vocabulary appears in both. Decide whether the external one is the internal enum serialized, or a separate vocabulary with a mapping. The second is more work and lets the domain evolve without a client release. - Write the deprecation policy as a sentence with a duration in it. "Two releases, minimum six weeks" is a policy; "we will give notice" is not. **Forces to weigh.** A contract you write down is a contract you are held to. The temptation is to under-specify so you keep freedom, but under-specified contracts are discovered by consumers through experiment — and then you are bound by behaviour you never intended to promise. `display_status` is the example: nobody designed it, and now the support console renders it. --- ## 5.5 Map the domain model onto the data model **Lessons:** 0058 (Map the Domain Model to a Data Model), 0045 (Data, State and Contracts) **Do this.** The current schema is one flat `returns` table that mirrors the old dataclass field for field, including a `review_reason` column doing two jobs and money in a `REAL`. Redesign it from the access patterns rather than from the objects. The patterns to design for: fetch one return by id (customer app, on every page load); list a customer's returns (support console); count a customer's returns in a window (fraud, on every submit); the review queue ordered by age (agents, polled); and finance's nightly aggregate over refunded rows. **Observable outcome.** A schema with the state machine's states as a constrained column, money in minor units with currency, the two `review_reason` facts separated, an index that makes the fraud count cheap, and a documented read model for finance that does not force them through your repository. **Hints.** - The aggregate boundary is not the table boundary. A `Return` and its transitions can be one row plus an event table; they do not need five tables. - The fraud count is a `WHERE customer_id = ? AND created_at >= ?` on every submit. Index it, and notice that it currently counts denied returns — exercise 1.1 found that; here you decide whether to fix it with a predicate or with a separate counter. - Finance is a reporting consumer. Give them a view or a materialised export, not the base table. That is what makes exercise 3.5's contract manageable next time. **Forces to weigh.** A schema shaped to the domain is easy to write and can be slow to read; one shaped to the queries is fast and drifts from the model. The deciding facts here are volume and latency: a few thousand returns a day, five access patterns, one of them on the request path. That does not justify a read store — say so explicitly, and name the volume at which it would. --- ## 5.6 Choose the architecture style from the drivers **Lessons:** 0059 (Choose the Architecture Style from Drivers), 0043 (Architecture Drivers) **Do this.** With the components from exercise 5.3, decide the style. Write the drivers first — median resolution under 4 hours, the review queue cap, the order system as an external authority that can be down, three consumer teams on their own release schedules, one team of six, a pilot on 30 September — and let them choose. Then build the composition root: one file that constructs every adapter, wires every port, and is the only place that knows a concrete type. **Observable outcome.** In `fitness/test_fitness_deep_modules.py`: - `test_the_composition_root_is_a_single_named_place` And a written comparison of at least two styles with the driver that decides between them. **Hints.** - The default for one team of six is a modular monolith, and the burden of proof is on anything else. Lesson 0059's test: name the driver that pays for a deployable boundary. "Scalability" is not a driver; "the fraud model needs a GPU and a different release cadence" is. - The notification worker is the one component with a real case for its own process: different failure mode, different scaling profile, and it must survive a restart of the API. - The composition root is allowed to be long and boring. If it is clever, you have hidden the wiring again. **Forces to weigh.** A modular monolith gives you one deploy, one transaction boundary and refactorable seams; it gives up independent scaling and independent release. Splitting gives those back at the cost of a network between your invariants — and exercise 5.2 just showed you which invariants would end up on opposite sides. Decide with the drivers, and write down the driver that would change the answer. --- ## 5.7 Design the deployment and release path **Lessons:** 0060 (Design the Deployment and Release Path), 0049 (Testing, Observability and Safe Rollout) **Do this.** Take the system from a package that runs a demo to something with a defensible release path. Importing `returns_lab.api` currently mutates a global route table, and importing anything constructs four vendor clients — so "start the app" and "import the module" are the same event, and neither is configurable. Design: how the process starts, how configuration arrives, what the health check asserts, how the notification worker is started and stopped cleanly, what happens to in-flight work during a deploy, and how you would roll back exercise 3.5's migration at each of its four steps. **Observable outcome.** In `fitness/test_fitness_deep_modules.py`: - `test_the_deployment_unit_can_be_started_without_import_side_effects` Plus an entry point that takes configuration, builds the system, starts the worker, and shuts it down on a signal without losing queued work. **Hints.** - Import-time side effects are the reason the tests need `reset_world()`. Removing them is exercise 2.5 finishing its job. - A health check that returns 200 unconditionally is decoration. Assert the things whose failure means "do not send me traffic": the database answers, the worker thread is alive, the schema version is the one this build expects. - Draining: on shutdown, stop accepting, finish the queue with a deadline, then exit. Whatever is left must be recoverable from the database, which is why the in-memory queue from exercise 3.3 had to go. - Rollback for a migration is not "run the down script". For steps 1–3 it is "deploy the previous build"; for step 4 it is a restore. Write which step is the point of no return. **Forces to weigh.** Zero-downtime deployment costs you a drain path, a readiness check, a rule that every migration is backward compatible for one release, and the discipline to never combine a schema change with a code change that requires it. It buys you the ability to ship on a Tuesday afternoon. For a pilot with 50 cases a day, a two-minute window at 04:00 is genuinely cheaper — argue it, and name the volume or the SLA at which it stops being true. --- ## Done when - These go green: `test_status_is_an_enum_not_a_string`, `test_there_is_exactly_one_spelling_of_each_state`, `test_an_illegal_transition_is_refused`, `test_the_returns_component_offers_at_most_six_operations`, `test_no_module_is_named_after_a_layer_instead_of_a_capability`, `test_there_is_a_declared_boundary_between_components`, `test_the_composition_root_is_a_single_named_place`, `test_the_deployment_unit_can_be_started_without_import_side_effects`. - Every fitness function in `fitness/` is green and no `@checkpoint` decorator remains — `python3 run_tests.py` reports `OK` with zero expected failures. - The demo still runs, and prints more than it used to: correlation ids, resolution times, and no duplicate emails. - You can hand someone the transition table, the two contracts, the schema, the style comparison and the ADR, and they can review your judgement without reading the code. That set of artifacts is the lab's contribution to your capstone packet — see `../capstone/index.html` and `../assessments/capstone.html`.