# Phase 2 — reference solution Code in this directory: `p2_errors.py`, `p2_events.py`, `p2_composition_root.py`. Verified by `verify_phase_2.py` (14 tests). --- ## 2.1 / 2.2 / 2.3 Classes, injection, protocols The ports in `../phase-1/p1_ports.py` are `typing.Protocol`s, written from the caller's need. `ReturnsService` becomes a class because it now holds collaborators — a repository, a label port, a refund gateway, a clock, an event bus — which is the third of the three reasons a class earns its keep. **Forces.** Protocols are structural, so a rename can silently orphan an adapter and nothing complains until runtime. ABCs are explicit and rigid. **Consequence accepted.** The reference chooses Protocol and pays for it with `RepositoryContract` in `verify_phase_1.py` — one contract test every implementation must pass. That is cheaper than making the whole system rigid to catch one class of mistake. **A second valid solution.** Use an ABC when you want shared behaviour in the base (a `LoggingPort` mixin), when you need a registration hook, or when implementations are written by another team who will thank you for the explicit `class X(Port)` declaration. None of those apply inside one package. --- ## 2.4 Streaming `refund_rows_for_export` becomes a generator; the backfill in `../phase-3/p3_expand_contract.py` shows the batched form — `LIMIT ?` on `WHERE refund_amount_minor IS NULL`, committing per batch, restartable. **Forces.** Laziness moves failures to the consumer and makes retries harder, because a partly-consumed generator cannot be replayed. **Consequence accepted.** For a nightly export over two years of history that is the right trade. For a request-response handler with a 2,000-row cap it is not, and the reference does not make `for_customer` lazy. --- ## 2.5 / 2.6 Globals and ownership → `p2_composition_root.py` `Settings` is a frozen dataclass with a `with_()` copier. `Application` owns the connection and closes it. Routes are registered explicitly and duplicates are refused, so the route table no longer depends on import order. `RequestContext` carries the actor and the correlation id as an argument. The return-id sequence is not in here on purpose: a module counter that resets on restart will collide. It is either an autoincrement the database owns or a UUID the caller supplies. **Forces.** Passing settings through five frames is tedious, and the tedium is the honest price of a visible dependency. The counter-argument — "it is read-only in practice" — is falsified by this system, where the tests, the demo and any runtime caller can all mutate it. **A second valid solution — when you would choose it.** `contextvars` for the request context. It fixes the concurrency bug and keeps the ambient convenience, and it is idiomatic in async web frameworks where threading an actor through every signature really is noise. Choose it knowingly: you have made the coupling safe, not removed it, and a background job with no request context will get a surprising default. --- ## 2.7 / 2.8 Errors and observability → `p2_errors.py` Three kinds, because three is what callers branch on: `ClientError` (do not retry), `UpstreamUnavailable` (retrying may work), and everything else (a bug). `to_status_code` translates once, at the edge, logging refusals at INFO and bugs at ERROR with the traceback. `RetryingPort` wraps a port with backoff and jitter and a budget. It retries `UpstreamUnavailable` and never retries a `ClientError` — the test proves it, because "retry the permanent failure three times" is the most common way a retry makes an outage worse. **Forces.** Typed errors are a contract: adding one is a client-visible change because clients branch on them. Aim for the smallest set the callers actually use. **Consequence accepted.** A denial is *not* an error, and the API keeps returning 201 for it. That surprises people, so it is documented in the external contract (exercise 5.4) rather than quietly changed. --- ## 2.9 Module organisation Four packages: `domain/` (the aggregate, money, the policies), `application/` (the workflow), `adapters/` (SQLite, the vendors, the transports), `edge/` (the JSON layer). The rule is enforced by `fitness/test_fitness_dependency_direction.py`; when you move files, update `DOMAIN_MODULES` and `INFRASTRUCTURE_MODULES` in `fitness/_marks.py`. **A second valid solution — when you would choose it.** Feature-first (`returns/`, `refunds/`, `labels/`, each with its own domain and adapters). Layer-first makes the dependency rule easy to state and spreads one feature over four directories; feature-first makes a change local and makes the dependency rule harder to check. Choose feature-first when the features are genuinely independent and the team is large enough that two people are rarely in the same feature. For six people and one workflow, layers win. Lesson 0022's point is that either is fine and *unenforced* is not. --- ## 2.10 Adapters, including the clock `SystemClock` and `FrozenClock` in `p2_composition_root.py`. The clock is the port people skip and the one that makes the 30-day window testable without fixture dates computed relative to `now()` — which is what `orders.py` does today, and why those tests would break if you ran them a year from now. **The rule that holds up.** Port it if it makes a test slow, flaky or impossible, or if you might buy it differently later. That is the database, the order system, the carrier, the payment gateway, the two transports and the clock. Not `json.dumps`. --- ## 2.11 / 2.12 Async out, events in → `p2_events.py` Six events, all facts in the past tense. `UnitOfWork` collects them, commits, and *then* publishes — the ordering matters, because publishing inside the transaction notifies customers about refunds that later roll back. The test for that is `test_a_rollback_publishes_nothing`. The bus is in-process and synchronous. Do not add a broker. **Forces.** Events decouple the producer from its consumers and make the call graph unreadable: you can no longer answer "what happens when a refund is issued?" by following the code. **Consequence accepted.** The price is real. It is worth paying here because the consumer list grows — notifications today, analytics and the fraud model next quarter — and `service.py`'s fan-out of ten sibling imports is the visible symptom. It would not be worth paying for a single consumer that will never have a sibling. --- ## 2.13 Buy the retry or build it `RetryingPort` is the forty lines. It handles backoff, jitter and a budget. It does **not** handle a circuit breaker, a bulkhead, or persistence across a restart, and the docstring says so — a build decision is only honest when it names what you are not building. **The decision.** Build here, buy in production. Retry semantics are not this business's differentiator, but the lab must run with no dependencies. In a real deployment: `tenacity` for the in-process retry, and a real broker for the notification queue the moment durability matters — which is the moment volume makes a lost message a customer complaint rather than a rounding error. **The 80% test.** `tenacity` does 80% of this now and will still do 80% in three years, because retry is a solved problem that does not track your domain. Eligibility rules fail that test in the other direction, which is why you would never buy those.