Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b91fc3ce | ||
|
|
89a1158c93 |
-191
@@ -1,191 +0,0 @@
|
|||||||
# 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<OrderLine> 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;
|
|
||||||
|
|
||||||
/// <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
|
|
||||||
|
|
||||||
- Trainer's solutions live on the `exercise-1-solution` and
|
|
||||||
`exercise-2-solution` branches — keep them off the participant path.
|
|
||||||
|
|
||||||
- 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.
|
|
||||||
@@ -103,9 +103,11 @@ public static class Mappers
|
|||||||
{
|
{
|
||||||
Id = dto.Id,
|
Id = dto.Id,
|
||||||
Description = dto.Description,
|
Description = dto.Description,
|
||||||
TotalInCents = dto.TotalInCents,
|
|
||||||
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
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;
|
seen[dto] = order;
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
@@ -161,6 +163,10 @@ public static class Mappers
|
|||||||
public static Order FromOrderData(OrderData row)
|
public static Order FromOrderData(OrderData row)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+19
-4
@@ -18,11 +18,26 @@ public class Order
|
|||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Order total in cents (integer — no floating-point money). A normal
|
/// Order total in cents (integer — no floating-point money), computed
|
||||||
/// attribute: it is persisted in <see cref="OrderData"/> and mapped
|
/// from the order lines. Since the lines arrived this is derived data:
|
||||||
/// one-on-one into <see cref="OrderDto.TotalInCents"/>.
|
/// 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>
|
/// </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
|
||||||
|
/// the discount flow (see <see cref="DiscountCalculator"/>). Derived
|
||||||
|
/// data: it lives in the domain only — neither <see cref="OrderDto"/>
|
||||||
|
/// nor <see cref="OrderData"/> carries it — and the applied percentage
|
||||||
|
/// is not stored either, being derivable from the two totals.
|
||||||
|
/// </summary>
|
||||||
|
public int DiscountedTotalInCents { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The client that owns this order. Set automatically by
|
/// The client that owns this order. Set automatically by
|
||||||
|
|||||||
@@ -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);
|
db.Save(order);
|
||||||
|
|
||||||
order.Description = "one (edited)";
|
order.Description = "one (edited)";
|
||||||
order.TotalInCents = 99_00;
|
order.Lines[0].AmountInCents = 99_00;
|
||||||
client.Name = "Acme GmbH";
|
client.Name = "Acme GmbH";
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EXERCISE — the mapping from a <see cref="DiscountedOrder"/> onto an
|
||||||
|
/// <see cref="Order"/> is NOT one-on-one. (This is the solution participants
|
||||||
|
/// arrive at from the stub in EXERCISE.md.)
|
||||||
|
/// </summary>
|
||||||
|
public class DiscountedOrderMappingTests
|
||||||
|
{
|
||||||
|
[Fact] // EXERCISE STEP 2
|
||||||
|
public void A_discounted_order_can_be_correlated_onto_its_order()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 10 % for orders above €100:
|
||||||
|
var discounted = DiscountCalculator.CalculateDiscount(
|
||||||
|
order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
|
||||||
|
// Correlate: the discounted result belongs to the order carrying this id.
|
||||||
|
Assert.Equal(order.Id, discounted.OrderId);
|
||||||
|
|
||||||
|
// Copy the discounted total onto the order. The discount percentage is
|
||||||
|
// deliberately NOT copied — it is derivable from the two totals.
|
||||||
|
order.DiscountedTotalInCents = discounted.DiscountedTotalInCents;
|
||||||
|
|
||||||
|
Assert.Equal(135_00, order.DiscountedTotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] // EXERCISE STEP 3
|
||||||
|
public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
|
||||||
|
{
|
||||||
|
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 discounted = DiscountCalculator.CalculateDiscount(
|
||||||
|
order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||||
|
Assert.Equal(order.Id, discounted.OrderId);
|
||||||
|
order.DiscountedTotalInCents = discounted.DiscountedTotalInCents;
|
||||||
|
|
||||||
|
// Save the updated order: the context refreshes the stored row in place.
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
// The persisted record keeps the ORIGINAL total: the database knows
|
||||||
|
// nothing about discounts — derived data lives in the domain only.
|
||||||
|
var row = db.Find<OrderData>(candidate => candidate.Id == discounted.OrderId)!;
|
||||||
|
Assert.Equal(150_00, row.TotalInCents);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
public sealed class OrderBuilder
|
||||||
{
|
{
|
||||||
private readonly Order _order = new();
|
private readonly Order _order = new();
|
||||||
|
private int? _totalInCents;
|
||||||
|
|
||||||
/// <summary>A valid, fresh order — the neutral starting point for tests.</summary>
|
/// <summary>A valid, fresh order — the neutral starting point for tests.</summary>
|
||||||
public static OrderBuilder ValidOrder() => new();
|
public static OrderBuilder ValidOrder() => new();
|
||||||
@@ -33,15 +34,23 @@ public sealed class OrderBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the total in cents (<c>150_00</c> = €150.00). How the requested
|
/// Sets the total in cents (<c>150_00</c> = €150.00). Since OrderLines
|
||||||
/// total is realised inside the order is an implementation detail that may
|
/// arrived, the total is computed — the requested total is realised in
|
||||||
/// change as the domain evolves; this API stays.
|
/// <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>
|
/// </summary>
|
||||||
public OrderBuilder WithTotal(int totalInCents)
|
public OrderBuilder WithTotal(int totalInCents)
|
||||||
{
|
{
|
||||||
_order.TotalInCents = totalInCents;
|
_totalInCents = totalInCents;
|
||||||
return this;
|
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