# Phase 3 — Master Designer Lessons 0027–0038. Trade-offs. Every exercise here has more than one defensible answer; the deliverable is the decision *and* the cost you accepted. For each exercise, write the two columns before you write code: what you get, what you give up. Then name the unit of the decision — is this per system, per operation, per workflow, or per component? The trade-offs cheat sheet (`../reference/tradeoffs.html`) has the unit for each. --- ## 3.1 Build or buy the carrier **Lessons:** 0027 (Build vs Buy), 0009 (Ports & Adapters) **Do this.** ShipMaster sells return labels, serves US/CA/MX, and is currently welded into `service.issue_label`. Two facts make this a real decision: `SUPPORTED_COUNTRIES` — a product rule about which markets you serve — lives in the vendor's module; and the vendor is down often enough that `test_a_carrier_outage_leaves_the_return_stranded_in_APPROVED` exists. Build the ledger. Buying: integration cost, per-label fee, the outage you do not control, the countries you cannot serve. Building: months of work, carrier contracts, customs paperwork, and a permanent team. Then design the seam that lets you change your mind: a `ReturnLabels` port with the ShipMaster adapter behind it, and the market rule moved into the domain where it belongs. **Observable outcome.** In `fitness/test_fitness_vendor_names.py`: - `test_the_carrier_is_reachable_through_a_port_named_for_the_job` — a module named for labels, not for the seller. `policy.check_eligibility` decides which countries are served; the adapter translates a country the carrier cannot handle into a domain error. **Hints.** - The answer is buy. The exercise is the seam, and the ledger that justifies it — lesson 0027's break-even arithmetic with real numbers. - A second adapter you never ship is a cheap test of whether the port is real. Write a `FlatRateLabels` fake and see what the port has to change. - "International returns are not supported" is currently enforced in two places: `config.SETTINGS["allow_international"]` and the vendor's country list. Those can disagree. **Forces to weigh.** The port costs you an indirection and gives you: testable outage behaviour, a market rule you own, and a switching cost measured in one adapter. It does not give you an actual second carrier — do not pretend the abstraction is a migration plan. Write down what a real carrier swap would still cost with the port in place. --- ## 3.2 Abstraction or duplication **Lessons:** 0028 (Abstraction vs Duplication), 0034 (Performance vs Maintainability) **Do this.** Three candidates, and they do not all resolve the same way. 1. **Validation** is duplicated across `api.submit_return` and `ai_helpers.validate_return_payload`, with different rules. That is false duplication in one direction — the two copies serve one purpose and disagree. Unify it (you started in exercise 1.10). 2. **The five refund branches** in `policy` are four copies of "refund the gross". Unify, but notice you are unifying because the *reason is the same*, not because the code looks the same. 3. **Five order-system calls per submit** for the same order looks like duplication and is actually a caching question. Pinned by `test_one_submit_asks_the_order_system_for_the_same_order_five_times`. **Observable outcome.** `test_one_submit_asks_the_order_system_for_the_same_order_five_times` either stays as-is with a written justification, or changes to `1` with a per-request cache. Both are acceptable; a change with no justification is not. **Hints.** - The rule of three, and the question behind it: will these change *for the same reason*? Two eligibility checks that happen to look alike but belong to different rules should stay apart. - If you cache the order, cache it for the life of one request and no longer. A cache that outlives the request is a staleness bug waiting for exercise 3.4. - Five calls to an in-process fake cost nothing. Five calls to a service with a 120 ms p99 cost 600 ms of a 4-hour resolution target. Do the arithmetic before you decide it matters. **Forces to weigh.** Lesson 0028's two rents: duplication charges you every time you change one copy and forget the other; a wrong abstraction charges you every time a caller needs the variation you removed. Both are real. Pick the cheaper rent for *this* change, and say what would make you re-let it. --- ## 3.3 Synchronous or asynchronous **Lessons:** 0031 (Sync vs Async), 0024 (Async Out of Domain Logic) **Do this.** Four things currently happen inside the customer's request: eligibility, fraud, the carrier call and the refund. Notifications happen on a worker. Decide, per step, which side of the line it belongs on — and then fix the worker, which is where the real damage is. The worker's retry unit is the batch, not the channel. Email is sent, SMS fails, the whole item is requeued, and the customer receives the same email twice. The row is marked `sent` before the transport is called. The queue is in memory, so a restart loses it. Messages that exhaust their retries vanish into a counter. **Observable outcome.** In `fitness/test_fitness_contracts_and_async.py`: - `test_delivery_is_idempotent_per_channel` - `test_a_notification_that_never_lands_ends_up_somewhere_visible` `test_an_sms_failure_makes_the_customer_receive_two_emails` and `test_the_row_says_sent_before_anything_was_sent` go red — invert them. **Hints.** - Per-channel delivery records with their own state, not one row per batch. Mark `sent` after the transport returns, not before. - The carrier call is the interesting sync/async decision. Synchronous, the customer gets a label immediately and a carrier outage denies them a return. Asynchronous, the request always succeeds and the label arrives in a minute — which is what the outage characterization test shows you already half-built by accident. - A dead-letter table is four columns and answers "what did we fail to send?" **Forces to weigh.** Asynchronous means the customer's request succeeds before the work does, so you owe them a status they can see and you owe yourself a way to find work that got stuck. Synchronous means the p99 of your endpoint is the sum of everyone else's p99. The unit of this decision is the *step*, not the system: eligibility should be synchronous, notification should not, and the carrier is a judgement call you should write down. --- ## 3.4 Consistency or availability: make the refund idempotent **Lessons:** 0032 (Consistency vs Availability) **Do this.** `returns.refund` called twice creates two refunds at the vendor — `test_calling_refund_twice_moves_the_money_twice` proves it. Add an idempotency key: the caller supplies one, you record the first outcome against it, and a repeat returns the first answer rather than performing the operation again. Then answer the harder question from exercise 1.12: when the order system is down, does the fraud watch-list rule fail the request, skip itself, or hold the return for a human? **Observable outcome.** In `fitness/test_fitness_domain_invariants.py`: - `test_a_repeated_refund_moves_the_money_once` — note that it passes an `idempotency_key` in the request body, so the API contract changes too. **Hints.** - Lesson 0032's shape: look up the key, return the recorded answer if there is one, otherwise perform and record. The record must be written in the same transaction as the effect, or you have moved the race. - The key comes from the client. Generating it server-side from the return id makes a *second, legitimate* refund impossible — which may be what you want. Decide, and say which. - PayPortal has no idempotency support in the lab. Real gateways do. Note where you would pass it through and what you do without it. **Forces to weigh.** Idempotency buys you safe retries and costs you a storage table, a key lifetime you must choose, and an API change your three consumer teams have to make. The availability side: refusing to act when you cannot verify uniqueness is the consistent choice and makes you unavailable during a database blip. State which you chose per operation — refunds and label purchases can differ. --- ## 3.5 Stability or speed: expand and contract the money column **Lessons:** 0035 (Stability vs Speed of Change), 0058 (Mapping Domain to Data) **Do this.** Exercise 1.8 made money an integer count of minor units. The schema still stores `refund_amount REAL` in dollars. And `reporting.py` — the nightly finance job — reads that column, runs on a schedule you do not control, and cannot be deployed with you. Migrate to `refund_amount_minor INTEGER` in four steps, with no downtime and no moment at which finance's job is wrong: 1. **Expand** — add `refund_amount_minor`; write both; keep reading `refund_amount`. 2. **Backfill** — copy historical rows in batches, idempotently, restartable. 3. **Switch** — read `refund_amount_minor`; still write both. 4. **Contract** — stop writing `refund_amount`; only then drop it, and only after finance has deployed their read of the new column. **Observable outcome.** In `fitness/test_fitness_contracts_and_async.py`: - `test_the_schema_has_reached_version_2` - `test_money_is_stored_in_minor_units` - `test_the_old_column_is_still_there_during_the_expand_phase` And the point of the exercise: every test in `tests/test_legacy_consumer_reporting.py` stays green at **every one of the four steps**. Run the suite after each step, not at the end. **Hints.** - Write the migration as four separately-runnable functions plus a version marker, not one script. You must be able to stop between any two. - The backfill must be safe to run twice — `WHERE refund_amount_minor IS NULL`, batched by id, committing per batch. - Step 4 is the one you cannot take unilaterally. It needs a conversation with finance and a date. Write the message you would send them. - `notifications` has the same problem in miniature: no unique key, and `_deliver` updates by `(return_id, template)`. Fix it in the same migration and notice how much easier it is when nobody outside reads the table. **Forces to weigh.** Expand/contract costs you a period where two columns hold the same fact and can disagree, plus four deploys instead of one, plus a coordination dependency on a team that does not report to you. It buys you the ability to stop and reverse at every step. The alternative — one deploy with a maintenance window — is genuinely cheaper if you can get the window. Price both and say which you would actually do, given a pilot on 30 September. --- ## 3.6 Coupling or coordination: version the published contract **Lessons:** 0033 (Coupling vs Coordination), 0057 (Designing Contracts) **Do this.** `api.serialize` is a dict literal in a handler. Three teams parse it: the support console branches on the raw `status` string, the customer web app needs `tracking` and `label_url`, the partner portal reads three fields. Exercise 1.9 is about to rename `APPROVED` to one canonical spelling — which breaks the support console's release. Give the envelope a version, define the response as a declared type rather than a literal, and work out the compatible path from the old status vocabulary to the new one. **Observable outcome.** In `fitness/test_fitness_contracts_and_async.py`: - `test_the_response_envelope_declares_a_version` - `test_the_serializer_is_written_from_a_named_contract` `tests/test_consumer_contract_api.py` is the coordination artifact — it must keep passing, or you must change it *with* the change to the consumer it represents. **Hints.** - Additive changes are free for a tolerant reader and fatal for a strict one. You have both kinds of consumer; the tests tell you which is which. - The compatible path for the status rename: emit both spellings for a release (`status` unchanged, `state` canonical), let consumers migrate, then remove. That is expand/contract applied to an API rather than a schema. - Version the *envelope*, not every field. A version per field is a configuration graveyard. **Forces to weigh.** Versioning buys the three teams independent release schedules and costs you two code paths and a deprecation calendar you must actually run. Coordinating instead — one flag day, everyone deploys together — is cheaper if it works and expensive when it does not. Conway's law decides this more than taste: if those three teams sit in your standup, coordinate; if they are in another timezone with their own roadmap, version. --- ## 3.7 Read the upstream payload tolerantly **Lessons:** 0033 (Coupling vs Coordination) **Do this.** The order team owns their payload and will change it. Today `policy` and `service` index it directly, so a moved field is an exception and an added field is invisible. Turn the order client into a tolerant reader: required fields are named and asserted, optional fields degrade, unknown fields are ignored on purpose. **Observable outcome.** In `fitness/test_fitness_contracts_and_async.py`: - `test_the_upstream_order_payload_is_read_tolerantly` — it adds two unknown keys and deletes `customer.phone`, then expects the system to keep working. **Hints.** - Lesson 0033's `parse_order_confirmed` is the shape: a function that names every field it depends on, so the coupling is one readable list. - "Tolerant" is not "silent". A missing *required* field should fail loudly at the boundary, with the field name. A missing optional one should have a documented default — no phone number means email only. - This is the same translation layer exercise 1.2 built. If you did 1.2, this is twenty lines. **Forces to weigh.** Tolerance hides upstream changes you might want to know about, and a default that silently kicks in is a bug that takes months to surface. The mitigation is to log when a default fires. The alternative — strict parsing — makes you break loudly and often, which is right when the upstream team is yours and wrong when it is not. --- ## 3.8 Correctness or delivery speed: write the debt ledger **Lessons:** 0037 (Correctness vs Delivery Speed), 0038 (Short-Term vs Long-Term) **Do this.** The pilot is 30 September. You cannot do everything in this file. Write the ledger: three items you are taking on deliberately, and for each — what it costs per month, what triggers repayment, and who owns it. Then pick the two exercises in this phase you would *not* do before the pilot, and say why. **Observable outcome.** A three-row ledger, in `checkpoints/` or your own notes, with a date and an owner against each row. Not a wish list: each row names the signal that says "now". **Hints.** - Good candidates for deliberate debt: the five order-system calls (3.2), the contract version (3.6) if all three consumer teams are in your building, the dead-letter table (3.3) if volume is under 50/day. - Bad candidates: the double refund (3.4) and the money truncation (1.8). Both move real money incorrectly. Correctness debt in a payments path is not debt, it is a defect. - The distinction lesson 0038 draws: a deliberate shortcut with a repayment trigger is a loan. The same shortcut without one is a loss. **Forces to weigh.** Every row you take on buys days now and charges interest until repaid. The interest is real and compounding: an unversioned contract gets more expensive with each consumer, and a missing dead-letter table gets more expensive with each order of magnitude of volume. Price the interest, not just the principal. --- ## Done when - These go green: `test_the_carrier_is_reachable_through_a_port_named_for_the_job`, `test_delivery_is_idempotent_per_channel`, `test_a_notification_that_never_lands_ends_up_somewhere_visible`, `test_a_repeated_refund_moves_the_money_once`, `test_the_schema_has_reached_version_2`, `test_money_is_stored_in_minor_units`, `test_the_old_column_is_still_there_during_the_expand_phase`, `test_the_response_envelope_declares_a_version`, `test_the_serializer_is_written_from_a_named_contract`, `test_the_upstream_order_payload_is_read_tolerantly`. - `tests/test_legacy_consumer_reporting.py` passed at every step of the migration, and you can name the step at which finance had to deploy. - You have a three-row debt ledger with owners and repayment triggers. Next: `phase-4.md`.