From 46b91fc3ce80836e428f54603140fa98fbcf0ac2 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Thu, 17 Sep 2026 08:56:14 +0100 Subject: [PATCH] Exercise 2 solution: OrderLines in the domain, edges unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrderLine + computed TotalInCents; both inbound mappers reconstruct a one-line order from the flat DTO/row (derived data is re-derived, not written); builder realises WithTotal as a single line. Outbound mappers, DTO, OrderData, DbContext and WebApp untouched — the small-steps payoff. yak: exercise-2-solution-on-branch-exercise-2-solution-hw7e --- src/After/Mappers.cs | 10 +++- src/After/Order.cs | 14 +++-- src/After/OrderLine.cs | 15 ++++++ .../AfterPersistenceTests.cs | 2 +- .../OrderLinesEvolutionTests.cs | 51 +++++++++++++++++++ .../TestData/OrderBuilder.cs | 19 +++++-- 6 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 src/After/OrderLine.cs create mode 100644 tests/BeforeAfter.Tests/OrderLinesEvolutionTests.cs diff --git a/src/After/Mappers.cs b/src/After/Mappers.cs index c9f016b..ce9503c 100644 --- a/src/After/Mappers.cs +++ b/src/After/Mappers.cs @@ -103,9 +103,11 @@ public static class Mappers { Id = dto.Id, Description = dto.Description, - TotalInCents = dto.TotalInCents, Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen), }; + // Derived data cannot be written, only re-derived: the flat DTO total + // becomes exactly one line. + order.Lines.Add(new OrderLine(dto.Description, dto.TotalInCents)); seen[dto] = order; return order; } @@ -161,6 +163,10 @@ public static class Mappers public static Order FromOrderData(OrderData row) { ArgumentNullException.ThrowIfNull(row); - return new Order { Id = row.Id, Description = row.Description, TotalInCents = row.TotalInCents }; + var order = new Order { Id = row.Id, Description = row.Description }; + // Read-back: the flat row becomes exactly one synthetic line, so the + // computed total — and everything calculated from it — keeps working. + order.Lines.Add(new OrderLine(row.Description, row.TotalInCents)); + return order; } } \ No newline at end of file diff --git a/src/After/Order.cs b/src/After/Order.cs index 95777a9..755f7d0 100644 --- a/src/After/Order.cs +++ b/src/After/Order.cs @@ -18,11 +18,17 @@ public class Order public string Description { get; set; } = string.Empty; /// - /// Order total in cents (integer — no floating-point money). A normal - /// attribute: it is persisted in and mapped - /// one-on-one into . + /// Order total in cents (integer — no floating-point money), computed + /// from the order lines. Since the lines arrived this is derived data: + /// it cannot be written, only re-derived — which is exactly why the + /// inbound mappings (DTO, storage row) reconstruct a one-line order. + /// Persisted in and mapped one-on-one into + /// . /// - public int TotalInCents { get; set; } + public int TotalInCents => Lines.Sum(line => line.AmountInCents); + + /// The lines this order consists of. The total is their sum. + public List Lines { get; } = []; /// /// The discounted total in cents, copied onto the order by the caller of diff --git a/src/After/OrderLine.cs b/src/After/OrderLine.cs new file mode 100644 index 0000000..a7bc3f4 --- /dev/null +++ b/src/After/OrderLine.cs @@ -0,0 +1,15 @@ +namespace After; + +/// +/// One line of an : its own description and amount. A +/// plain, mutable domain type in the same style as and +/// (only the DTOs are records). +/// +public class OrderLine(string description, int amountInCents) +{ + /// Free-text description of what this line is for. + public string Description { get; set; } = description; + + /// Line amount in cents (integer — no floating-point money). + public int AmountInCents { get; set; } = amountInCents; +} diff --git a/tests/BeforeAfter.Tests/AfterPersistenceTests.cs b/tests/BeforeAfter.Tests/AfterPersistenceTests.cs index 08db56b..6981b9a 100644 --- a/tests/BeforeAfter.Tests/AfterPersistenceTests.cs +++ b/tests/BeforeAfter.Tests/AfterPersistenceTests.cs @@ -55,7 +55,7 @@ public class AfterPersistenceTests db.Save(order); order.Description = "one (edited)"; - order.TotalInCents = 99_00; + order.Lines[0].AmountInCents = 99_00; client.Name = "Acme GmbH"; db.Save(order); db.Save(client); diff --git a/tests/BeforeAfter.Tests/OrderLinesEvolutionTests.cs b/tests/BeforeAfter.Tests/OrderLinesEvolutionTests.cs new file mode 100644 index 0000000..e9177ce --- /dev/null +++ b/tests/BeforeAfter.Tests/OrderLinesEvolutionTests.cs @@ -0,0 +1,51 @@ +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. (This is the solution participants arrive at from the +/// stub in 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); + + var loaded = db.LoadOrder(order.Id)!; + + // The discount works 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); + + var loaded = db.LoadOrder(order.Id)!; + + // Storage flattened the order: one line, the flat total, and the + // order-level description. Documented, not hidden. + var line = Assert.Single(loaded.Lines); + Assert.Equal(150_00, line.AmountInCents); + Assert.Equal("mixed order", line.Description); + } +} diff --git a/tests/BeforeAfter.Tests/TestData/OrderBuilder.cs b/tests/BeforeAfter.Tests/TestData/OrderBuilder.cs index d9a33a6..5099547 100644 --- a/tests/BeforeAfter.Tests/TestData/OrderBuilder.cs +++ b/tests/BeforeAfter.Tests/TestData/OrderBuilder.cs @@ -16,6 +16,7 @@ namespace BeforeAfter.Tests.TestData; public sealed class OrderBuilder { private readonly Order _order = new(); + private int? _totalInCents; /// A valid, fresh order — the neutral starting point for tests. public static OrderBuilder ValidOrder() => new(); @@ -33,15 +34,23 @@ public sealed class OrderBuilder } /// - /// Sets the total in cents (150_00 = €150.00). How the requested - /// total is realised inside the order is an implementation detail that may - /// change as the domain evolves; this API stays. + /// Sets the total in cents (150_00 = €150.00). Since OrderLines + /// arrived, the total is computed — the requested total is realised in + /// as a single line carrying the order's description, + /// so WithDescription/WithTotal work in any order. This API + /// stayed identical across that domain evolution; only these internals + /// changed. /// public OrderBuilder WithTotal(int totalInCents) { - _order.TotalInCents = totalInCents; + _totalInCents = totalInCents; return this; } - public Order Build() => _order; + public Order Build() + { + if (_totalInCents is int total) + _order.Lines.Add(new OrderLine(_order.Description, total)); + return _order; + } }