# 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.