Exercise 2 solution: OrderLines in the domain, edges unchanged
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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -18,11 +18,17 @@ public class Order
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order total in cents (integer — no floating-point money). A normal
|
||||
/// attribute: it is persisted in <see cref="OrderData"/> and mapped
|
||||
/// one-on-one into <see cref="OrderDto.TotalInCents"/>.
|
||||
/// 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 <see cref="OrderData"/> and mapped one-on-one into
|
||||
/// <see cref="OrderDto.TotalInCents"/>.
|
||||
/// </summary>
|
||||
public int TotalInCents { get; set; }
|
||||
public int TotalInCents => Lines.Sum(line => line.AmountInCents);
|
||||
|
||||
/// <summary>The lines this order consists of. The total is their sum.</summary>
|
||||
public List<OrderLine> Lines { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The discounted total in cents, copied onto the order by the caller of
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace After;
|
||||
|
||||
/// <summary>
|
||||
/// One line of an <see cref="Order"/>: its own description and amount. A
|
||||
/// plain, mutable domain type in the same style as <see cref="Order"/> and
|
||||
/// <see cref="Client"/> (only the DTOs are records).
|
||||
/// </summary>
|
||||
public class OrderLine(string description, int amountInCents)
|
||||
{
|
||||
/// <summary>Free-text description of what this line is for.</summary>
|
||||
public string Description { get; set; } = description;
|
||||
|
||||
/// <summary>Line amount in cents (integer — no floating-point money).</summary>
|
||||
public int AmountInCents { get; set; } = amountInCents;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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. (This is the solution participants arrive at from the
|
||||
/// stub in 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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ namespace BeforeAfter.Tests.TestData;
|
||||
public sealed class OrderBuilder
|
||||
{
|
||||
private readonly Order _order = new();
|
||||
private int? _totalInCents;
|
||||
|
||||
/// <summary>A valid, fresh order — the neutral starting point for tests.</summary>
|
||||
public static OrderBuilder ValidOrder() => new();
|
||||
@@ -33,15 +34,23 @@ public sealed class OrderBuilder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the total in cents (<c>150_00</c> = €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 (<c>150_00</c> = €150.00). Since OrderLines
|
||||
/// arrived, the total is computed — the requested total is realised in
|
||||
/// <see cref="Build"/> as a single line carrying the order's description,
|
||||
/// so <c>WithDescription</c>/<c>WithTotal</c> work in any order. This API
|
||||
/// stayed identical across that domain evolution; only these internals
|
||||
/// changed.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user