Files
explicit-mapping/EXERCISE2.md
T
mostalive ff6dbfb269 EXERCISE2.md: OrderLines evolution in three steps, sequenced after Exercise 1
Steps: (1) OrderLine + computed total — the compiler enumerates the writers,
(2) one-line reconstruction in the two inbound mappers, (3) discount on a
reloaded order + the accepted-loss test. Dry-run verified on a scratch
branch: 98 tests green, diff confined to domain/builder/two mappers.

yak: exercise2md-orderlines-evolution-sequenced-accepted-loss-test-efap
2026-09-16 22:35:13 +01:00

7.8 KiB

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:

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):

    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<OrderLine> Lines { get; } = [];, and turn the total into a computed property (delete the stored one):

    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:

    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

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:

using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;

namespace BeforeAfter.Tests;

/// <summary>
/// EXERCISE 2 — the domain gained OrderLines; persistence still stores one
/// flat row per order. Make these tests pass (see EXERCISE2.md).
/// </summary>
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.