From 37ebb211826546c05ce466445c1b614fbb21fff2 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 16 Sep 2026 18:41:33 +0100 Subject: [PATCH] EXERCISE.md: three-step discount-mapping exercise with failing stub tests yak: exercisemd-with-failing-stub-integration-test-z5pg --- EXERCISE.md | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 EXERCISE.md diff --git a/EXERCISE.md b/EXERCISE.md new file mode 100644 index 0000000..1889365 --- /dev/null +++ b/EXERCISE.md @@ -0,0 +1,151 @@ +# Exercise: the discount that wouldn't map + +You are in the **"after" situation** of this code base: the domain objects +(`Client`, `Order`) are plain — no `DbBase` inheritance, no knowledge of the +`DbContext`. Two hand-written mapping families lead out of the domain: + +- `OrderDto` / `ClientDto` — transport shapes consumed by the WebApp; +- `OrderData` / `ClientData` — the data-only records the persistence framework + stores (one row per object, object graph flattened to ids). + +The *before* situation was Active Record: the domain object and the stored +shape were one and the same class, so there was nothing to map. Now mapping is +explicit — and that is where this exercise lives. + +## What you get + +- `Order.TotalInCents` — the order total, an integer in cents, persisted and + mapped one-on-one into `OrderDto` and `OrderData`. +- `DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage)` — + a pure domain service. It returns a **`DiscountedOrder`** (a separate domain + type, correlated to the order by `OrderId`) carrying the order's total, the + percentage *actually applied* (0 when the total does not strictly exceed the + barrier), and the discounted total. Rounding is half-up, in integer math. +- Unit tests for all of the above already pass. + +## The challenge + +The `DiscountedOrder` is **not** a one-on-one copy of the order, and it must +not become one: + +- `Order` needs to learn the *discounted total* — but neither `OrderDto` nor + `OrderData` may gain it. The discount is derived data: it lives in the + domain only, and the storage schema stays untouched. +- The `DiscountedOrder.DiscountPercentage` is **not** copied onto the order — + it can be derived from the two totals (and `TotalInCents` never changes). + +So the flow is: order → `DiscountedOrder` (calculate) → order (correlate + +copy, dropping what is derivable) → persistence (which stores *less* than the +domain now knows). + +Work through the three steps below — each is small enough to hand to a coding +agent as a single instruction. Run the tests after each step: + +```sh +scripts/run-tests.sh +``` + +## Step 1 — teach the order its discounted total + +Add a single property `DiscountedTotalInCents` (`int`, default 0) to the +**domain** `Order` in `src/After/Order.cs`. Do not touch `OrderDto`, +`OrderData`, or the mappers: the discounted total must exist in the domain +only. Acceptance: the solution builds and the full test suite still passes. + +## Step 2 — correlate a discounted order onto its order + +Create `tests/BeforeAfter.Tests/DiscountedOrderMappingTests.cs` from the stub +below and make the **first** test pass. The point is the correlation: the +`DiscountedOrder` knows the id of the order it was calculated from — use it +instead of assuming there is only one order around. + +```csharp +using After; + +namespace BeforeAfter.Tests; + +/// +/// EXERCISE — the mapping from a DiscountedOrder onto an Order is NOT +/// one-on-one. Make these tests pass, following EXERCISE.md step by step. +/// +public class DiscountedOrderMappingTests +{ + [Fact] // EXERCISE STEP 2 + public void A_discounted_order_can_be_correlated_onto_its_order() + { + var db = new DbContext(); + var client = new Client { Name = "Acme" }; + var order = new Order { Description = "big order", TotalInCents = 150_00 }; + client.Orders.Add(order); + db.Save(client); + db.Save(order); + + // 10 % for orders above €100: + var discounted = DiscountCalculator.CalculateDiscount( + order, discountBarrierCents: 100_00, discountPercentage: 10); + + // TODO (step 2): correlate the discounted order to the saved order and + // copy the discounted total onto the order. + // Hints: DiscountedOrder.OrderId identifies the order; the discount + // percentage is deliberately NOT copied — it is derivable. + + // ... your code here ... + + Assert.Equal(135_00, order.DiscountedTotalInCents); + } + + [Fact] // EXERCISE STEP 3 + public void Saving_a_discounted_order_keeps_the_stored_total_untouched() + { + var db = new DbContext(); + var client = new Client { Name = "Acme" }; + var order = new Order { Description = "big order", TotalInCents = 150_00 }; + client.Orders.Add(order); + db.Save(client); + db.Save(order); + + var discounted = DiscountCalculator.CalculateDiscount( + order, discountBarrierCents: 100_00, discountPercentage: 10); + + // TODO (step 3): correlate + copy as in step 2, then save the updated + // order through the context. Afterwards find the order's persisted + // OrderData row (hint: db.Find(...)) and assert that the + // stored total is STILL the original 150_00: the database knows + // nothing about discounts — derived data lives in the domain only. + + // ... your code here ... + + OrderData row = null!; // TODO (step 3): find the OrderData row for this order + Assert.Equal(150_00, row.TotalInCents); + } +} +``` + +Acceptance: the first test is green, the second still red. + +## Step 3 — save, and check what actually persisted + +Make the **second** test pass: re-save the updated order through the context +(the context refreshes the stored row in place), then assert on the persisted +`OrderData` row. Note what you are *not* asserting: there is no discounted +column to check — the row simply keeps the original total. + +Acceptance: the whole suite is green — including the pre-existing tests, which +guard exactly the invariants you must not break (the DTO and the persistence +records still map the same fields as before). + +## Facilitator notes + +- Expected end state per step: (1) one new domain-only property; (2) a mapping + fragment *outside* the `Mappers` class — correlation by id plus a field + copy, dropping the derivable percentage; (3) `db.Save(order)` after which + the `OrderData` row still shows `TotalInCents == 150_00`. +- Discussion points: why two mapping families out of the domain (transport vs + persistence) and why neither carries the discount; what an Active Record + "before" world would have done here (the discounted value would have been + a column, or nothing at all); what happens to `DiscountedTotalInCents` when + the order travels to the WebApp and back (the DTO round-trip silently drops + it — derived data must be recomputed, not transported). +- The unit tests in `DiscountCalculatorTests.cs` pin the calculation contract + (strict barrier, half-up rounding, validation); participants should not need + to touch them.