diff --git a/EXERCISE2.md b/EXERCISE2.md new file mode 100644 index 0000000..425c2a5 --- /dev/null +++ b/EXERCISE2.md @@ -0,0 +1,188 @@ +# Exercise 2: OrderLines — the domain evolves, the edges don't + +> Run `EXERCISE.md` first (or apply its solution from the +> `exercise-1-solution` branch): this exercise continues from that state. + +You are in the "after" situation: plain domain objects, two mapping families +out of the domain (DTOs for transport, `*Data` records for persistence), and +test data builders so tests don't reach into domain construction. The order +total is a stored `int TotalInCents`. + +The scenario: orders should consist of **order lines** — each with its own +description and amount. The total becomes *derived* data: the sum of the +lines. And here is the point of the exercise — the persistence framework and +the DTO keep their flat shape (one `Description`, one `TotalInCents` per +order), so the domain changes while **nothing at the edges does**: same DTO, +same storage row, same WebApp. + +## The trick, and the accepted loss + +Storage holds one flat row per order. Reading an order back therefore +reconstructs it with **one synthetic order line**: description and amount +taken from the row's `Description` and `TotalInCents`. Calculation (discount, +totals) then just works on reloaded orders. + +The honest limitation: an order with **more than one line** cannot round-trip +through storage — saving flattens it, reading back collapses it to one line. +That is fine as long as multi-line orders never reach the front-end. The +exercise includes a test that documents this loss instead of hiding it. + +Work through the three steps — each is one instruction for a coding agent. +Run the tests after each step: + +```sh +scripts/run-tests.sh +``` + +## Step 1 — introduce OrderLines (and meet the compiler) + +In `src/After`: + +1. Add an `OrderLine` — a plain, mutable domain type in the same style as + `Order`/`Client` (only the DTOs are records): + + ```csharp + public class OrderLine(string description, int amountInCents) + { + public string Description { get; set; } = description; + public int AmountInCents { get; set; } = amountInCents; + } + ``` + +2. In `Order`: keep `Description` (it stays stored — it is the order-level + description), add `public List Lines { get; } = [];`, and turn + the total into a computed property (delete the stored one): + + ```csharp + public int TotalInCents => Lines.Sum(line => line.AmountInCents); + ``` + +3. Update `OrderBuilder`: `WithTotal` must keep its API but can no longer set + a computed total. Store the request and express it in `Build()` as a single + line carrying the order's description — so `WithDescription`/`WithTotal` + work in any order: + + ```csharp + private int? _totalInCents; + public OrderBuilder WithTotal(int totalInCents) { _totalInCents = totalInCents; return this; } + public Order Build() + { + if (_totalInCents is int total) + _order.Lines.Add(new OrderLine(_order.Description, total)); + return _order; + } + ``` + +Now build. The compiler enumerates every remaining *writer* of the total — +exactly two, both **inbound** mappers (`Mappers.FromOrderDto` and +`Mappers.FromOrderData`). Everything that only *reads* the total — +`ToOrderDto`, `ToOrderData`, `DiscountCalculator`, the WebApp — compiles +untouched. That is the whole lesson in one compiler output. + +One test also writes the total (`order.TotalInCents = 99_00` in +`Saving_again_refreshes_the_row_instead_of_duplicating_it`); fix it now by +mutating through the new domain API instead: `order.Lines[0].AmountInCents = 99_00;` + +Acceptance: the **only** remaining build errors are the two inbound mappers. + +## Step 2 — reconstruct instead of write + +A DTO (and a storage row) carries one description and one flat total, so the +reconstructed order is an order with **one line** — derived data cannot be +written, only re-derived. In both `Mappers.FromOrderDto` (`UnmapOrder`) and +`Mappers.FromOrderData`: keep the field copies as they are, drop the +`TotalInCents = ...` assignment, and after creating the order add + +```csharp +order.Lines.Add(new OrderLine(dto.Description, dto.TotalInCents)); // dto / row respectively +``` + +(always exactly one line — even for a zero total — so the reconstruction is +deterministic). `DbContext.LoadOrder` / `LoadClient` need no changes. + +Acceptance: the full suite is green again, and `git diff --stat` shows the +change confined to the domain, the builder, that one test, and the two +inbound mapper bodies — `OrderDto`, `OrderData`, `DbContext`, `WebApp`, and +the outbound mappers are untouched. + +## Step 3 — prove it: discount a reloaded order, and document the loss + +Create `tests/BeforeAfter.Tests/OrderLinesEvolutionTests.cs` from the stub +below and make both tests pass: + +```csharp +using After; +using static BeforeAfter.Tests.TestData.ClientBuilder; +using static BeforeAfter.Tests.TestData.OrderBuilder; + +namespace BeforeAfter.Tests; + +/// +/// EXERCISE 2 — the domain gained OrderLines; persistence still stores one +/// flat row per order. Make these tests pass (see EXERCISE2.md). +/// +public class OrderLinesEvolutionTests +{ + [Fact] // EXERCISE STEP 3a + public void A_reloaded_order_can_be_discounted() + { + var db = new DbContext(); + var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build(); + var client = ValidClient().WithName("Acme").WithOrder(order).Build(); + db.Save(client); + db.Save(order); + + Order loaded = null!; // TODO (step 3a): load the order back from the context + + // The discount must work off the computed total of the RELOADED order: + var discounted = DiscountCalculator.CalculateDiscount( + loaded, discountBarrierCents: 100_00, discountPercentage: 10); + + Assert.Equal(135_00, discounted.DiscountedTotalInCents); + Assert.Equal(10, discounted.DiscountPercentage); + } + + [Fact] // EXERCISE STEP 3b — the accepted loss + public void A_multi_line_order_flattens_to_a_single_line_when_stored() + { + var db = new DbContext(); + var order = ValidOrder().WithDescription("mixed order").Build(); + order.Lines.Add(new OrderLine("first item", 100_00)); + order.Lines.Add(new OrderLine("second item", 50_00)); + + db.Save(order); + + Order loaded = null!; // TODO (step 3b): load the order back from the context + + // Storage flattened the order: one line, the flat total, and the + // order-level description. Document this — don't hide it. + var line = Assert.Single(loaded.Lines); + Assert.Equal(150_00, line.AmountInCents); + Assert.Equal("mixed order", line.Description); + } +} +``` + +Acceptance: the whole suite is green — including Exercise 1's tests +(`DiscountedOrderMappingTests`), which keep passing unchanged: the discount +flow reads the computed total exactly as it read the stored one. + +## Facilitator notes + +- Expected diff per step: (1) `OrderLine` + `Order.Lines` + computed total + + builder internals + one test line — build red in exactly the two inbound + mappers; (2) two one-line reconstructions — green again; (3) two new tests. + If `git diff` touches `OrderDto`, `OrderData`, `DbContext`, `WebApp`, or the + outbound mappers, something went off the small-steps path. +- The `WithTotal` pain the builder absorbs: internally it now fabricates a + line; no test noticed. That is the TestDataBuilder dividend. +- Discussion points: what the next evolution would look like with a real + database (`order_line` rows and a foreign key — the persistence contract + would finally change, and only then); why the DTO cannot carry lines back + into the domain honestly (derived data is re-derived on the way in, never + transported); what the user of a reloaded multi-line order actually gets + (the flat total under the order's description) and why that must stay away + from the front-end. +- `Before` stays untouched throughout — its Active Record world would have + needed a schema change on day one, which is the contrast this exercise + series is built on.