Author SHA1 Message Date
mostalive 46b91fc3ce 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
2026-09-17 08:56:14 +01:00
mostalive 89a1158c93 Exercise 1 solution: DiscountedTotalInCents on the domain order + integration tests
Domain-only field: OrderDto and OrderData stay without it; the stored row
keeps the original total after saving a discounted order. The discount
percentage is not copied (derivable).

yak: exercise-1-solution-on-branch-exercise-1-solution-4s5q
2026-09-16 22:32:35 +01:00
mostalive 08b52806de TestDataBuilders: ValidOrder()/ValidClient() with explicit Build(); After tests migrated
Domain objects in After-side tests are constructed only through the
builders, so the upcoming OrderLines evolution changes builder internals
instead of every test. Before keeps object initializers (legacy world).

yak: testdatabuilders-validordervalidclient-after-test-migration-91vl
2026-09-16 22:32:13 +01:00
mostalive 53586d33a4 identity read-back: FromOrderData/FromClientData + DbContext.LoadOrder/LoadClient
Plain field-copy materialization so the persistence round-trip closes.
When OrderLines arrive, FromOrderData becomes the one-line reconstruction
(row description + total become a single line) — the hinge Exercise 2
pivots on.

yak: orderdata-read-back-identity-version-j9ew [order-data-mapping]
2026-09-16 22:17:28 +01:00
mostalive 37ebb21182 EXERCISE.md: three-step discount-mapping exercise with failing stub tests
yak: exercisemd-with-failing-stub-integration-test-z5pg
2026-09-16 18:41:33 +01:00
mostalive d39426d328 DiscountedOrder domain type + DiscountCalculator (half-up, strict barrier) with unit tests
yak: discountedorder-discountcalculator-with-unit-tests-zrtn
2026-09-16 18:40:34 +01:00
mostalive 803580786b order total: TotalInCents on both Orders, mapped into OrderDto and OrderData
yak: ordertotalincents-on-both-sides-dto-mapping-j00p
2026-09-16 18:39:48 +01:00
mostalive 49f4ca395f persistence records carry data: ClientData/OrderData + domain->persistence mappers
The fake DbContext now stores data-only DbBase subclasses (renamed from
the identity-only Shadow wrappers) and refreshes rows in place on re-Save;
the object graph is flattened to ids in storage.

yak: data-carrying-persistence-records-orderdataclientdata-mappers-tb3a
2026-09-16 18:38:38 +01:00
mostalive 9895ef87e4 edit readme for structure 2026-09-15 11:11:37 +01:00
19 changed files with 1068 additions and 93 deletions
+156
View File
@@ -0,0 +1,156 @@
# Exercise: the discount that wouldn't map
You are in the **"after" situation** of this code base: the domain objects
(`Client`, `Order`) are plain — no `DbBase` inheritance, no knowledge of the
`DbContext`. Two hand-written mapping families lead out of the domain:
- `OrderDto` / `ClientDto` — transport shapes consumed by the WebApp;
- `OrderData` / `ClientData` — the data-only records the persistence framework
stores (one row per object, object graph flattened to ids).
The *before* situation was Active Record: the domain object and the stored
shape were one and the same class, so there was nothing to map. Now mapping is
explicit — and that is where this exercise lives.
## What you get
- `Order.TotalInCents` — the order total, an integer in cents, persisted and
mapped one-on-one into `OrderDto` and `OrderData`.
- `DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage)` —
a pure domain service. It returns a **`DiscountedOrder`** (a separate domain
type, correlated to the order by `OrderId`) carrying the order's total, the
percentage *actually applied* (0 when the total does not strictly exceed the
barrier), and the discounted total. Rounding is half-up, in integer math.
- Unit tests for all of the above already pass.
- `ValidOrder()` / `ValidClient()` test data builders in the test project
(`tests/BeforeAfter.Tests/TestData/`) — construct domain objects in tests
through them, so tests survive domain evolution. Import their static
factories with `using static BeforeAfter.Tests.TestData.OrderBuilder;`
(and `...ClientBuilder;`).
## The challenge
The `DiscountedOrder` is **not** a one-on-one copy of the order, and it must
not become one:
- `Order` needs to learn the *discounted total* — but neither `OrderDto` nor
`OrderData` may gain it. The discount is derived data: it lives in the
domain only, and the storage schema stays untouched.
- The `DiscountedOrder.DiscountPercentage` is **not** copied onto the order —
it can be derived from the two totals (and `TotalInCents` never changes).
So the flow is: order → `DiscountedOrder` (calculate) → order (correlate +
copy, dropping what is derivable) → persistence (which stores *less* than the
domain now knows).
Work through the three steps below — each is small enough to hand to a coding
agent as a single instruction. Run the tests after each step:
```sh
scripts/run-tests.sh
```
## Step 1 — teach the order its discounted total
Add a single property `DiscountedTotalInCents` (`int`, default 0) to the
**domain** `Order` in `src/After/Order.cs`. Do not touch `OrderDto`,
`OrderData`, or the mappers: the discounted total must exist in the domain
only. Acceptance: the solution builds and the full test suite still passes.
## Step 2 — correlate a discounted order onto its order
Create `tests/BeforeAfter.Tests/DiscountedOrderMappingTests.cs` from the stub
below and make the **first** test pass. The point is the correlation: the
`DiscountedOrder` knows the id of the order it was calculated from — use it
instead of assuming there is only one order around.
```csharp
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
namespace BeforeAfter.Tests;
/// <summary>
/// EXERCISE — the mapping from a DiscountedOrder onto an Order is NOT
/// one-on-one. Make these tests pass, following EXERCISE.md step by step.
/// </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);
// TODO (step 2): correlate the discounted order to the saved order and
// copy the discounted total onto the order.
// Hints: DiscountedOrder.OrderId identifies the order; the discount
// percentage is deliberately NOT copied — it is derivable.
// ... your code here ...
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);
// TODO (step 3): correlate + copy as in step 2, then save the updated
// order through the context. Afterwards find the order's persisted
// OrderData row (hint: db.Find<OrderData>(...)) and assert that the
// stored total is STILL the original 150_00: the database knows
// nothing about discounts — derived data lives in the domain only.
// ... your code here ...
OrderData row = null!; // TODO (step 3): find the OrderData row for this order
Assert.Equal(150_00, row.TotalInCents);
}
}
```
Acceptance: the first test is green, the second still red.
## Step 3 — save, and check what actually persisted
Make the **second** test pass: re-save the updated order through the context
(the context refreshes the stored row in place), then assert on the persisted
`OrderData` row. Note what you are *not* asserting: there is no discounted
column to check — the row simply keeps the original total.
Acceptance: the whole suite is green — including the pre-existing tests, which
guard exactly the invariants you must not break (the DTO and the persistence
records still map the same fields as before).
## Facilitator notes
- Expected end state per step: (1) one new domain-only property; (2) a mapping
fragment *outside* the `Mappers` class — correlation by id plus a field
copy, dropping the derivable percentage; (3) `db.Save(order)` after which
the `OrderData` row still shows `TotalInCents == 150_00`.
- Discussion points: why two mapping families out of the domain (transport vs
persistence) and why neither carries the discount; what an Active Record
"before" world would have done here (the discounted value would have been
a column, or nothing at all); what happens to `DiscountedTotalInCents` when
the order travels to the WebApp and back (the DTO round-trip silently drops
it — derived data must be recomputed, not transported).
- The unit tests in `DiscountCalculatorTests.cs` pin the calculation contract
(strict barrier, half-up rounding, validation); participants should not need
to touch them.
+47 -4
View File
@@ -2,10 +2,53 @@
This is a workspace for creating three exercises. This is a workspace for creating three exercises.
One exercise is about exploring a repository using prompts for a coding agent to create Mermaid and PlantUML diagrams. We're focusing on class diagrams and sequence diagrams to get an idea of what is going on. We use the starting point of the second exercise for this. ## Explore a repository with LLM diagrams
The second exercise is factoring out domain objects, domain entities, from a structure that is previously subclass-based. So say you use a structure like active records or Entity Framework where you have an active record class and your domain class subclasses from the active record class which gives you maybe some nice things like find by an attribute. And then you can quite quickly — so these are straight-offs — you can quite quickly structure an application while it's also persisted. So you can say, "I want to have a customer, I want to have an order. And oh yes, I'm doing this for a webshop." So you have objects, relations, attributes, and you can all easily save them in your database. And this is fine when your application is small and you're just starting out. It allows you to very quickly scaffold an application. But it can become painful when your data doesn't have the right shape for it. So performance is slow. So at some point as your application grows, you probably want to be more deliberate in how you start. Explore a repository using prompts for a coding agent to create Mermaid and PlantUML diagrams. We're focusing on class diagrams and sequence diagrams to get an idea of what is going on. We use the starting point of the second exercise for this.
The second exercise is factoring out domain classes from a database inheritance structure. So we start with a couple of domain classes and some relations. Then we pre-deliver some classes that we already created and we demo it. As we have some domain classes, we create a mapping for writing and reading a domain class. And we make sure that running the existing tests against the fake database that we have for the exercise is really slow. The sequence diagrams that we make in the first exercise will come in handy. Because in order to do this, you do start wondering how does this web application find a client? How can we see clients? How can we create an order? And what happens once the flow through the application as we do this? You sort of need to understand this to choose the kind of mapping that you use. So we are going to reuse the analysis we did in the first exercise in the second exercise. ## Factor out domain objects from ActiveRecord-like structures
The third part is a spike in creating an extract method refactoring using Roslyn, the C# static analysis tooling that comes as part of C# more or less. It's an additional NuGet download but yeah it is quite good. So this is a simple command line that allows you to specify the name of a file, the starting line and the end line and it will give you an analysis of dependencies inside the method that you need to decide which things could lend itself to extracting to a method, what the parameters could be and what the return value could be. This is work in progress so this is not something to share with participants but it is part of a flow where you first you iterate and you prompt, you create codes then maybe you extract things into a skill or you prompt for a diagram of some kind. You go, ah I often seem to make sequence diagrams. Okay, can we make something deterministic to create these sequence diagrams so it's faster and more reliable? Or in my case I don't like, well I like the extract method stuff in JetBrains IDEs but I need to do some steps before to find the parameters, extract variables, extract the methods with these variables as parameters then inline the variables again and I was wondering if I could do that more smoothly in the flow with a coding agent. That also means that going further I could do things like forbid extracted methods from having Async in all parts, but see if we can extract parts without side effects first. Factor out domain objects, domain entities, from a structure that is previously subclass-based.
Say you use a structure like Active Record or Entity Framework where you have an active record class and your domain class subclasses from the active record class.
This super class gives you some easy features like finding an object by attribute. You can quite quickly structure an application while it is also persisted.
For instance you can say, "I want to have a customer, I want to have an order. And oh yes, I'm doing this for a webshop." So you have objects, relations, attributes, and you can all easily save them in your database.
This is fine when your application is small and you're just starting out. It allows you to very quickly scaffold an application. But it can become painful when your data doesn't have the right shape for it. So performance is slow. At some point as your application grows, you probably want to be more deliberate in how you deal with data.
### How do we factor out domain classes?
To get away from the inheritance structure with an 'abstract' class tied to the database, we need two or three database-bound concrete classes and some relations.
We pre-deliver some classes that we already migrated to the new structure and we demo it. We create a mapping for writing and reading a domain class. And we make sure that running the existing tests against the fake database that we have for the exercise is **really slow.**
### Sequence diagrams come in handy
The sequence diagrams that we made in the first exercise will come in handy. Because in order to do this, you do start wondering how does this web application find a client? How can we see clients? How can we create an order? And what happens to the flow through the application as we do this?
You need to understand the application flow to choose the kind of mapping that you use. Therefore we are going to reuse the analysis we did in the first exercise. Instructions for participants will be pre-structured, so they can focus on doing the exercise instead of devising exacp prompts.
## Spike: Static analysis and Extract Method refactoring with Roslyn
The third part is a spike in creating an extract method refactoring using Roslyn, the C# static analysis tooling that comes as part of C# more or less.
It's an additional NuGet download but it is quite powerful.
This is a simple command line app that allows you to specify the name of a file, the starting line and the end line and it will give you an analysis of dependencies inside the method that you need to decide which things could lend itself to extracting to a method, what the parameters could be and what the return value could be.
This is work in progress so this is not something to share with participants yet, unless it is in the form of a demo after participants have prompted diagrams.
But it is part of a flow where:
1. you iterate and you prompt,
2. create visualisations
3. extract things into a skill
4. extract moldable tools to make the skill faster and more reliable
Skills and tools together create a flywheel where you can do things that happen again and again more reliably and faster. Speed through quality.
## aside on why skills and tools in the line of moldable development
You go, ah I often seem to make sequence diagrams. Okay, can we make something deterministic to create these sequence diagrams so it's faster and more reliable? Or in my case I don't like, well I like the extract method stuff in JetBrains IDEs but I need to do some steps before to find the parameters, extract variables, extract the methods with these variables as parameters then inline the variables again and I was wondering if I could do that more smoothly in the flow with a coding agent. That also means that going further I could do things like forbid extracted methods from having Async in all parts, but see if we can extract parts without side effects first.
+6 -5
View File
@@ -4,9 +4,10 @@ namespace After;
/// The DB base class still exists in the "after" situation — it is part of the /// The DB base class still exists in the "after" situation — it is part of the
/// *persistence* layer — but the direction of the dependency is flipped: /// *persistence* layer — but the direction of the dependency is flipped:
/// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive /// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive
/// from it. Only the <see cref="DbContext"/>'s own internal shadow entities do, /// from it. Only the framework's own data-only persistence records do
/// so nothing outside the persistence layer can ever reach a /// (<see cref="ClientData"/>, <see cref="OrderData"/>): storage wants data
/// <see cref="DbContext"/> through a domain object. /// without behaviour, so the domain keeps the behaviour and the framework
/// keeps the data — the mappers bridge the two shapes.
/// </summary> /// </summary>
public class DbBase public class DbBase
{ {
@@ -19,8 +20,8 @@ public class DbBase
/// <summary> /// <summary>
/// Back-reference to the owning <see cref="DbContext"/>. In the after /// Back-reference to the owning <see cref="DbContext"/>. In the after
/// situation this lives only on the context's internal shadow entities — /// situation this lives only on the persistence records — the plain
/// the plain domain objects that used to inherit it no longer do. /// domain objects that used to inherit it no longer do.
/// </summary> /// </summary>
public DbContext? DbContext { get; internal set; } public DbContext? DbContext { get; internal set; }
} }
+121 -52
View File
@@ -1,95 +1,164 @@
namespace After; namespace After;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Internal shadow wrappers — live *inside* the persistence layer so the // The persistence side of the "after" situation. The (fake) framework stores
// domain never sees a DbContext back-reference. Subclassed per-entity-type // its own data-only records — <see cref="ClientData"/> / <see cref="OrderData"/>,
// solely to make `Set<T>` strongly-typed. // both <see cref="DbBase"/> subclasses — never the domain objects themselves.
// Domain state reaches storage only through explicit mapping; the context keeps
// a reference-identity map so a second <see cref="Save"/> of the same domain
// object refreshes its row instead of duplicating it.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// <summary>Lightweight shadow holder kept alive inside the context.</summary> /// <summary>
public abstract class Shadow /// Persisted shape of a <see cref="Client"/>: data only — the orders are
/// separate <see cref="OrderData"/> rows, so nothing more is needed here.
/// </summary>
public sealed class ClientData : DbBase
{ {
/// <summary>ID assigned when first persisted.</summary> /// <summary>Display name of the client.</summary>
public Guid Id { get; set; } public string Name { get; set; } = string.Empty;
/// <summary>Back-reference to the owning <see cref="DbContext"/> (internal only).</summary>
public DbContext? DbContext { get; internal set; }
} }
/// <summary>Shadow holder for a persisted <see cref="Client"/>.</summary> /// <summary>
public sealed class ClientShadow : Shadow { /* no extra data */ } /// Persisted shape of an <see cref="Order"/>: data only. The object graph is
/// flattened for storage — the back-reference to the client becomes a foreign
/// key, because storage has ids, not object references.
/// </summary>
public sealed class OrderData : DbBase
{
/// <summary>Free-text description of what this order is for.</summary>
public string Description { get; set; } = string.Empty;
/// <summary>Shadow holder for a persisted <see cref="Order"/>.</summary> /// <summary>Order total in cents of the stored order.</summary>
public sealed class OrderShadow : Shadow { /* no extra data */ } public int TotalInCents { get; set; }
/// <summary>
/// Foreign key to the owning client: <see cref="Guid.Empty"/> when the
/// order has no client, or when its client had no id yet at save time —
/// save clients before their orders.
/// </summary>
public Guid ClientId { get; set; }
}
/// <summary> /// <summary>
/// The fake <c>DbContext</c>, refactored to use per-type shadow collections /// The fake <c>DbContext</c>: per-type tables of data records, mirroring EF
/// (mirroring EF Core's <c>Set&lt;T&gt;</c>) plus an instance-level /// Core's <c>Set&lt;T&gt;</c>. <see cref="Save(Client)"/> / <see cref="Save(Order)"/>
/// <c>Find&lt;T&gt;</c> query method. /// map the domain object's *current* state into its record — inserting on the
/// first save, refreshing in place on later ones.
/// </summary> /// </summary>
public class DbContext public class DbContext
{ {
// ── per-type shadow tables ─────────────────────────────────────── // ── per-type data tables ─────────────────────────────────────────
private readonly List<ClientShadow> _clients = new(); private readonly List<ClientData> _clients = new();
private readonly List<OrderShadow> _orders = new(); private readonly List<OrderData> _orders = new();
// ── fast lookup: domain object reference → its shadow ──────────── // ── fast lookup: domain object reference → its persisted record ──
private readonly Dictionary<object, Shadow> _byRef = new(); private readonly Dictionary<object, DbBase> _byRef = new();
// ── exposed per-type tables (read-only view) ──────────────────── // ── exposed per-type tables (read-only view) ─────────────────────
public IReadOnlyList<ClientShadow> Clients => _clients.AsReadOnly(); public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
public IReadOnlyList<OrderShadow> Orders => _orders.AsReadOnly(); public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary> /// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
public IReadOnlyList<T> Set<T>() where T : Shadow public IReadOnlyList<T> Set<T>() where T : DbBase
=> typeof(T) switch => typeof(T) switch
{ {
var t when t == typeof(ClientShadow) => (IReadOnlyList<T>)_clients.AsReadOnly(), var t when t == typeof(ClientData) => (IReadOnlyList<T>)_clients.AsReadOnly(),
var t when t == typeof(OrderShadow) => (IReadOnlyList<T>)_orders.AsReadOnly(), var t when t == typeof(OrderData) => (IReadOnlyList<T>)_orders.AsReadOnly(),
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"), _ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
}; };
// ── explicit persistence ──────────────────────────────────────── // ── explicit persistence: map + insert, or refresh ───────────────
/// <summary>Persist a <see cref="Client">:</summary> /// <summary>
/// Persist a <see cref="Client"/>: the first save maps it to a fresh
/// <see cref="ClientData"/> row and writes the generated id back into the
/// domain object; a later save refreshes that row's data in place.
/// </summary>
public void Save(Client client) public void Save(Client client)
{ {
ArgumentNullException.ThrowIfNull(client); ArgumentNullException.ThrowIfNull(client);
if (!_byRef.TryAdd(client, new ClientShadow { DbContext = this })) var fresh = Mappers.ToClientData(client);
return; // already saved → id unchanged if (_byRef.TryGetValue(client, out var existing))
var shadow = (ClientShadow)_byRef[client]; {
((ClientData)existing).Name = fresh.Name; // refresh, keep row identity
return;
}
var id = Guid.NewGuid(); var id = Guid.NewGuid();
shadow.Id = client.Id = id; // write-back identity fresh.Id = client.Id = id; // write-back identity
fresh.DbContext = this;
_clients.Add(fresh);
_byRef[client] = fresh;
} }
/// <summary>Persist an <see cref="Order">.</summary> /// <summary>
/// Persist an <see cref="Order"/> (same insert-or-refresh rule as
/// <see cref="Save(Client)"/>; save its client first so the foreign key
/// can be filled).
/// </summary>
public void Save(Order order) public void Save(Order order)
{ {
ArgumentNullException.ThrowIfNull(order); ArgumentNullException.ThrowIfNull(order);
if (!_byRef.TryAdd(order, new OrderShadow { DbContext = this })) var fresh = Mappers.ToOrderData(order);
return; // already saved → id unchanged if (_byRef.TryGetValue(order, out var existing))
var shadow = (OrderShadow)_byRef[order]; {
var row = (OrderData)existing; // refresh, keep row identity
row.Description = fresh.Description;
row.TotalInCents = fresh.TotalInCents;
row.ClientId = fresh.ClientId;
return;
}
var id = Guid.NewGuid(); var id = Guid.NewGuid();
shadow.Id = order.Id = id; // write-back identity fresh.Id = order.Id = id; // write-back identity
fresh.DbContext = this;
_orders.Add(fresh);
_byRef[order] = fresh;
} }
/// <summary>Persist every tracked entity (mirrors EF's <c>SaveChanges</c>).</summary> // ── loading: reassemble domain objects from the stored rows ──────
public int SaveChanges()
/// <summary>
/// Materialize the <see cref="Client"/> stored under <paramref name="id"/>,
/// including all of its orders (navigation fix-up wires
/// <see cref="Order.Client"/> back). Null when nothing is stored under it.
/// </summary>
public Client? LoadClient(Guid id)
{ {
var count = 0; var row = Find<ClientData>(r => r.Id == id);
foreach (var shadow in _byRef.Values) if (row is null) return null;
var client = Mappers.FromClientData(row);
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
client.Orders.Add(Mappers.FromOrderData(orderRow)); // fix-up wires Order.Client
return client;
}
/// <summary>
/// Materialize the <see cref="Order"/> stored under <paramref name="id"/>,
/// including its client (holding just this one order), or null when
/// nothing is stored under it. Use <see cref="LoadClient"/> to load a
/// client with all of its orders.
/// </summary>
public Order? LoadOrder(Guid id)
{
var row = Find<OrderData>(r => r.Id == id);
if (row is null) return null;
var order = Mappers.FromOrderData(row);
if (row.ClientId != Guid.Empty)
{ {
if (shadow.Id != Guid.Empty) continue; // already had an id var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
shadow.Id = Guid.NewGuid(); if (clientRow is not null)
count++; {
var client = Mappers.FromClientData(clientRow);
client.Orders.Add(order); // fix-up wires Order.Client
}
} }
return count; return order;
} }
// ── query helpers ─────────────────────────────────────────────── // ── query helpers ───────────────────────────────────────────────
/// <summary>Search the per-type table for a shadow whose predicate matches.</summary> /// <summary>Search the per-type table for a record whose predicate matches.</summary>
public T? Find<T>(Func<T, bool> predicate) where T : Shadow public T? Find<T>(Func<T, bool> predicate) where T : DbBase
=> Set<T>().FirstOrDefault(predicate); => Set<T>().FirstOrDefault(predicate);
// ── tracking / status ─────────────────────────────────────────── // ── tracking / status ───────────────────────────────────────────
@@ -97,6 +166,6 @@ public class DbContext
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary> /// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
public bool IsTracked(object entity) => _byRef.ContainsKey(entity); public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
/// <summary>All shadow entities this context currently manages.</summary> /// <summary>All data records this context currently manages.</summary>
public IReadOnlyList<Shadow> Tracked => _byRef.Values.ToList(); public IReadOnlyList<DbBase> Tracked => _byRef.Values.ToList();
} }
+64
View File
@@ -0,0 +1,64 @@
namespace After;
/// <summary>
/// Domain service computing a discount for an <see cref="Order"/>. Pure
/// calculation: it reads the order and returns a <see cref="DiscountedOrder"/>;
/// it never mutates the order. Copying the result onto the order — and what
/// that does or does not persist — is the exercise (see EXERCISE.md).
/// </summary>
public static class DiscountCalculator
{
/// <summary>
/// Calculate the discount for <paramref name="order"/>. The discount
/// applies only when the order's total is <em>strictly greater</em> than
/// the barrier — an order at exactly the barrier gets none.
/// </summary>
/// <param name="order">The order to discount; its <see cref="Order.TotalInCents"/> is the input.</param>
/// <param name="discountBarrierCents">Minimum total (in cents) required to qualify; the total must exceed it.</param>
/// <param name="discountPercentage">The discount as an integer percent (10 means 10 %), 0–100.</param>
/// <returns>
/// A <see cref="DiscountedOrder"/> correlated to the order by id.
/// <see cref="DiscountedOrder.DiscountPercentage"/> is 0 when the barrier
/// was not passed, and <see cref="DiscountedOrder.DiscountedTotalInCents"/>
/// equals the total in that case.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="order"/> is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">
/// The total or barrier is negative, or the percentage is outside 0–100.
/// </exception>
/// <remarks>
/// The discount amount is rounded <b>half-up</b> with pure integer math —
/// <c>(total × percentage + 50) / 100</c> — so 10 % of 155 cents is a
/// 16-cent discount (15.5 rounds up), and no floating point is involved.
/// </remarks>
public static DiscountedOrder CalculateDiscount(Order order, int discountBarrierCents, int discountPercentage)
{
ArgumentNullException.ThrowIfNull(order);
ArgumentOutOfRangeException.ThrowIfNegative(order.TotalInCents);
ArgumentOutOfRangeException.ThrowIfNegative(discountBarrierCents);
ArgumentOutOfRangeException.ThrowIfLessThan(discountPercentage, 0);
ArgumentOutOfRangeException.ThrowIfGreaterThan(discountPercentage, 100);
if (order.TotalInCents <= discountBarrierCents)
return new DiscountedOrder
{
OrderId = order.Id,
TotalInCents = order.TotalInCents,
DiscountPercentage = 0,
DiscountedTotalInCents = order.TotalInCents,
};
// Half-up rounding in integers: add half of the divisor before the
// integer division. 64-bit intermediate: cents × percent can overflow
// 32 bits for large orders.
var discount = (int)(((long)order.TotalInCents * discountPercentage + 50) / 100);
return new DiscountedOrder
{
OrderId = order.Id,
TotalInCents = order.TotalInCents,
DiscountPercentage = discountPercentage,
DiscountedTotalInCents = order.TotalInCents - discount,
};
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace After;
/// <summary>
/// The result of discounting an <see cref="Order"/>: a separate domain type,
/// not a modified order. The discount is derived data — computed on demand,
/// correlated to its order by <see cref="OrderId"/>. What a caller does with
/// it (and what of it ever reaches <see cref="OrderDto"/> or
/// <see cref="OrderData"/>) is deliberately left to the caller.
/// </summary>
public sealed record DiscountedOrder
{
/// <summary>Primary key of the discounted <see cref="Order"/>.</summary>
public Guid OrderId { get; init; }
/// <summary>The order's total in cents, as read from it (the untouched input).</summary>
public int TotalInCents { get; init; }
/// <summary>
/// The percentage actually applied, as an integer percent. 0 when the
/// order did not pass the discount barrier.
/// </summary>
public int DiscountPercentage { get; init; }
/// <summary>The total after the discount, in cents.</summary>
public int DiscountedTotalInCents { get; init; }
}
+74 -4
View File
@@ -1,13 +1,21 @@
namespace After; namespace After;
/// <summary> /// <summary>
/// Hand-written mappers between the plain domain and the DTOs (no AutoMapper — /// Hand-written mappers out of the plain domain (no AutoMapper — the mapping
/// the mapping code is part of the illustration). Each public method maps in /// code is part of the illustration). Each public method maps in exactly one
/// exactly one direction and never mutates its input. /// direction and never mutates its input. Two families, two destinations:
/// <list type="bullet">
/// <item><see cref="ClientDto"/>/<see cref="OrderDto"/> — transport to the
/// WebApp;</item>
/// <item><see cref="ClientData"/>/<see cref="OrderData"/> — the data-only
/// records the persistence framework stores.</item>
/// </list>
/// ///
/// The <c>Client.Orders</c> / <c>Order.Client</c> back-reference cycle is /// The <c>Client.Orders</c> / <c>Order.Client</c> back-reference cycle is
/// resolved with a reference-identity cache: the same instance maps to the /// resolved with a reference-identity cache: the same instance maps to the
/// same DTO (and back), so the graph terminates and stays consistent. /// same DTO (and back), so the graph terminates and stays consistent. The
/// persistence mapping needs no cache: records reference each other by id,
/// not by object, so the graph is flattened and cannot cycle.
/// </summary> /// </summary>
public static class Mappers public static class Mappers
{ {
@@ -64,6 +72,7 @@ public static class Mappers
{ {
Id = order.Id, Id = order.Id,
Description = order.Description, Description = order.Description,
TotalInCents = order.TotalInCents,
ClientDto = order.Client is null ? null : MapClient(order.Client, seen), ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
}; };
seen[order] = dto; seen[order] = dto;
@@ -96,7 +105,68 @@ public static class Mappers
Description = dto.Description, Description = dto.Description,
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;
} }
// ------------------------------------------------------------------
// Domain -> persistence (data-only records; graph flattened to ids)
// ------------------------------------------------------------------
/// <summary>Map a <see cref="Client"/> to its persistence record <see cref="ClientData"/>.</summary>
public static ClientData ToClientData(Client client)
{
ArgumentNullException.ThrowIfNull(client);
return new ClientData { Id = client.Id, Name = client.Name };
}
/// <summary>
/// Map an <see cref="Order"/> to its persistence record <see cref="OrderData"/>:
/// the object graph is flattened — <see cref="Order.Client"/> becomes the
/// <see cref="OrderData.ClientId"/> foreign key (empty when the order has
/// no client, or when that client has not been saved yet).
/// </summary>
public static OrderData ToOrderData(Order order)
{
ArgumentNullException.ThrowIfNull(order);
return new OrderData
{
Id = order.Id,
Description = order.Description,
TotalInCents = order.TotalInCents,
ClientId = order.Client?.Id ?? Guid.Empty,
};
}
// ------------------------------------------------------------------
// Persistence -> domain (read-back). A plain field copy for now; when
// the domain grows OrderLines, FromOrderData becomes the one-line
// reconstruction (row description + total become a single line) —
// see EXERCISE2.md.
// ------------------------------------------------------------------
/// <summary>Map a <see cref="ClientData"/> record back to a <see cref="Client"/>.</summary>
public static Client FromClientData(ClientData row)
{
ArgumentNullException.ThrowIfNull(row);
return new Client { Id = row.Id, Name = row.Name };
}
/// <summary>
/// Map an <see cref="OrderData"/> record back to an <see cref="Order"/>.
/// Reassembling the graph (wiring <see cref="Order.Client"/>) is the
/// context's job, not the mapper's.
/// </summary>
public static Order FromOrderData(OrderData row)
{
ArgumentNullException.ThrowIfNull(row);
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;
}
} }
+22
View File
@@ -17,6 +17,28 @@ public class Order
/// <summary>Free-text description of what this order is for.</summary> /// <summary>Free-text description of what this order is for.</summary>
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
/// <summary>
/// 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 => 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
/// <see cref="ClientOrders"/> when the order is added to /// <see cref="ClientOrders"/> when the order is added to
+3
View File
@@ -13,6 +13,9 @@ public sealed record OrderDto
/// <summary>Free-text description of what this order is for.</summary> /// <summary>Free-text description of what this order is for.</summary>
public string Description { get; init; } = string.Empty; public string Description { get; init; } = string.Empty;
/// <summary>Order total in cents of the underlying order.</summary>
public int TotalInCents { get; init; }
/// <summary> /// <summary>
/// Back-reference to the owning client, or <c>null</c> for a bare order /// Back-reference to the owning client, or <c>null</c> for a bare order
/// that has not been added to any client. /// that has not been added to any client.
+15
View File
@@ -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;
}
+4 -4
View File
@@ -18,10 +18,10 @@ DbBase.Context = ctx;
var jane = new Client { Name = "Jane Doe" }; var jane = new Client { Name = "Jane Doe" };
var bob = new Client { Name = "Bob Smith" }; var bob = new Client { Name = "Bob Smith" };
jane.Orders.Add(new Order { Description = "Design consultation" }); jane.Orders.Add(new Order { Description = "Design consultation", TotalInCents = 120_00 });
jane.Orders.Add(new Order { Description = "Website redesign" }); jane.Orders.Add(new Order { Description = "Website redesign", TotalInCents = 750_00 });
bob.Orders.Add(new Order { Description = "Monthly retainer" }); bob.Orders.Add(new Order { Description = "Monthly retainer", TotalInCents = 85_00 });
// Orders need to be attached too so they get IDs: // Orders need to be attached too so they get IDs:
ctx.Attach(jane.Orders[0]); ctx.Attach(jane.Orders[0]);
@@ -38,7 +38,7 @@ foreach (var client in DbBase.Context!.Tracked.OfType<Client>())
{ {
Console.WriteLine($"{client.Name} [{client.Id}]"); Console.WriteLine($"{client.Name} [{client.Id}]");
foreach (var order in client.Orders) foreach (var order in client.Orders)
Console.WriteLine($" - order [{order.Id}]: {order.Description}"); Console.WriteLine($" - order [{order.Id}]: {order.Description} ({order.TotalInCents} cents)");
} }
// ----- find by name ------------------------------------------------------- // ----- find by name -------------------------------------------------------
+6
View File
@@ -11,6 +11,12 @@ public class Order : DbBase
/// <summary>Free-text description of what this order is for.</summary> /// <summary>Free-text description of what this order is for.</summary>
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
/// <summary>
/// Order total in cents (integer — no floating-point money). Part of the
/// persisted state, like every other scalar.
/// </summary>
public int TotalInCents { get; set; }
/// <summary> /// <summary>
/// The client that owns this order. Set automatically by /// The client that owns this order. Set automatically by
/// <see cref="ClientOrders"/> when the order is added to /// <see cref="ClientOrders"/> when the order is added to
@@ -0,0 +1,146 @@
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
namespace BeforeAfter.Tests;
/// <summary>
/// The after <see cref="DbContext"/> stores data-only persistence records
/// (<see cref="ClientData"/> / <see cref="OrderData"/>), never domain objects:
/// <see cref="DbContext"/>'s <c>Save</c> maps domain state into a row —
/// inserting on the first save, refreshing in place on later ones — and the
/// stored graph is flattened to ids.
/// </summary>
public class AfterPersistenceTests
{
[Fact]
public void Save_maps_domain_state_into_data_records()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
var clientRow = Assert.Single(db.Clients);
Assert.Equal(client.Id, clientRow.Id);
Assert.Equal("Acme", clientRow.Name);
var orderRow = Assert.Single(db.Orders);
Assert.Equal(order.Id, orderRow.Id);
Assert.Equal("one", orderRow.Description);
Assert.Equal(150_00, orderRow.TotalInCents);
Assert.Equal(client.Id, orderRow.ClientId); // the graph is flattened to a key
}
[Fact]
public void An_order_without_client_stores_an_empty_client_id()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("orphan").Build();
db.Save(order);
Assert.Equal(Guid.Empty, Assert.Single(db.Orders).ClientId);
}
[Fact]
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
order.Description = "one (edited)";
order.Lines[0].AmountInCents = 99_00;
client.Name = "Acme GmbH";
db.Save(order);
db.Save(client);
// One row per saved object, refreshed with the *current* state:
var orderRow = Assert.Single(db.Orders);
Assert.Equal(order.Id, orderRow.Id);
Assert.Equal("one (edited)", orderRow.Description);
Assert.Equal(99_00, orderRow.TotalInCents);
Assert.Equal(client.Id, orderRow.ClientId);
var clientRow = Assert.Single(db.Clients);
Assert.Equal("Acme GmbH", clientRow.Name);
}
[Fact]
public void Rows_are_reachable_through_Set_and_Find()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
Assert.Single(db.Set<ClientData>());
Assert.Single(db.Set<OrderData>());
var found = db.Find<OrderData>(row => row.Description == "one");
Assert.Equal(order.Id, found!.Id);
Assert.Null(db.Find<OrderData>(row => row.Description == "no such order"));
}
// ---------------------------------------------------------------------
// Read-back: LoadOrder / LoadClient reassemble domain objects from the
// stored rows (identity round-trip for now — OrderLines come later).
// ---------------------------------------------------------------------
[Fact]
public void Loading_an_order_restores_its_state_and_its_client()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
db.Save(client);
db.Save(order);
var loaded = db.LoadOrder(order.Id);
Assert.NotNull(loaded);
Assert.Equal(order.Id, loaded!.Id);
Assert.Equal("one", loaded.Description);
Assert.Equal(150_00, loaded.TotalInCents);
// The graph is reassembled: the loaded order knows its client again,
// and the client's collection holds the loaded order.
Assert.NotNull(loaded.Client);
Assert.Equal("Acme", loaded.Client!.Name);
Assert.Same(loaded, Assert.Single(loaded.Client.Orders));
}
[Fact]
public void Loading_a_client_restores_it_with_all_of_its_orders()
{
var db = new DbContext();
var first = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
var second = ValidOrder().WithDescription("two").WithTotal(250_00).Build();
var client = ValidClient().WithName("Acme").WithOrder(first).WithOrder(second).Build();
db.Save(client);
db.Save(first);
db.Save(second);
var loaded = db.LoadClient(client.Id);
Assert.NotNull(loaded);
Assert.Equal("Acme", loaded!.Name);
Assert.Equal(2, loaded.Orders.Count);
Assert.All(loaded.Orders, o => Assert.Same(loaded, o.Client));
}
[Fact]
public void Loading_an_unknown_id_returns_null()
{
var db = new DbContext();
Assert.Null(db.LoadOrder(Guid.NewGuid()));
Assert.Null(db.LoadClient(Guid.NewGuid()));
}
}
+28 -23
View File
@@ -1,4 +1,6 @@
using After; using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
using System.Reflection; using System.Reflection;
namespace BeforeAfter.Tests; namespace BeforeAfter.Tests;
@@ -7,6 +9,11 @@ namespace BeforeAfter.Tests;
/// Tests for the AFTER situation (Yak 02): plain domain objects (no /// Tests for the AFTER situation (Yak 02): plain domain objects (no
/// <see cref="DbBase"/>), DTOs (records) carrying the data including the /// <see cref="DbBase"/>), DTOs (records) carrying the data including the
/// relations, hand-written mappers, and a WebApp that consumes only DTOs. /// relations, hand-written mappers, and a WebApp that consumes only DTOs.
///
/// Domain objects in these tests are constructed through the TestDataBuilders
/// (<see cref="OrderBuilder.ValidOrder"/> / <see cref="ClientBuilder.ValidClient"/>)
/// so the tests survive domain evolution; DTO initializers stay literal —
/// DTOs are the transport shapes under test.
/// </summary> /// </summary>
public class AfterTests public class AfterTests
{ {
@@ -42,7 +49,7 @@ public class AfterTests
public void ToClientDto_maps_all_scalar_fields() public void ToClientDto_maps_all_scalar_fields()
{ {
var id = Guid.NewGuid(); var id = Guid.NewGuid();
var client = new Client { Id = id, Name = "Acme" }; var client = ValidClient().WithId(id).WithName("Acme").Build();
var dto = Mappers.ToClientDto(client); var dto = Mappers.ToClientDto(client);
@@ -54,12 +61,13 @@ public class AfterTests
public void ToOrderDto_maps_all_scalar_fields() public void ToOrderDto_maps_all_scalar_fields()
{ {
var id = Guid.NewGuid(); var id = Guid.NewGuid();
var order = new Order { Id = id, Description = "one" }; var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
var dto = Mappers.ToOrderDto(order); var dto = Mappers.ToOrderDto(order);
Assert.Equal(id, dto.Id); Assert.Equal(id, dto.Id);
Assert.Equal("one", dto.Description); Assert.Equal("one", dto.Description);
Assert.Equal(150_00, dto.TotalInCents);
} }
[Fact] [Fact]
@@ -78,12 +86,13 @@ public class AfterTests
public void FromOrderDto_maps_all_scalar_fields() public void FromOrderDto_maps_all_scalar_fields()
{ {
var id = Guid.NewGuid(); var id = Guid.NewGuid();
var dto = new OrderDto { Id = id, Description = "one" }; var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
var order = Mappers.FromOrderDto(dto); var order = Mappers.FromOrderDto(dto);
Assert.Equal(id, order.Id); Assert.Equal(id, order.Id);
Assert.Equal("one", order.Description); Assert.Equal("one", order.Description);
Assert.Equal(150_00, order.TotalInCents);
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -93,9 +102,8 @@ public class AfterTests
[Fact] [Fact]
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent() public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
{ {
var client = new Client { Name = "Acme" }; var order = ValidOrder().WithDescription("one").Build();
var order = new Order { Description = "one" }; var client = ValidClient().WithName("Acme").WithOrder(order).Build();
client.Orders.Add(order);
var dto = Mappers.ToClientDto(client); var dto = Mappers.ToClientDto(client);
var orderDto = Assert.Single(dto.Orders); var orderDto = Assert.Single(dto.Orders);
@@ -128,9 +136,8 @@ public class AfterTests
[Fact] [Fact]
public void Domain_round_trip_preserves_all_state() public void Domain_round_trip_preserves_all_state()
{ {
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" }; var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
var order = new Order { Id = Guid.NewGuid(), Description = "one" }; var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
client.Orders.Add(order);
var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client)); var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
@@ -139,6 +146,7 @@ public class AfterTests
var roundTripOrder = Assert.Single(roundTrip.Orders); var roundTripOrder = Assert.Single(roundTrip.Orders);
Assert.Equal(order.Id, roundTripOrder.Id); Assert.Equal(order.Id, roundTripOrder.Id);
Assert.Equal(order.Description, roundTripOrder.Description); Assert.Equal(order.Description, roundTripOrder.Description);
Assert.Equal(order.TotalInCents, roundTripOrder.TotalInCents);
// The back-reference is still consistent on the reconstructed graph: // The back-reference is still consistent on the reconstructed graph:
Assert.Same(roundTrip, roundTripOrder.Client); Assert.Same(roundTrip, roundTripOrder.Client);
} }
@@ -146,9 +154,8 @@ public class AfterTests
[Fact] [Fact]
public void Dto_round_trip_preserves_all_state() public void Dto_round_trip_preserves_all_state()
{ {
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" }; var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
var order = new Order { Id = Guid.NewGuid(), Description = "one" }; var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
client.Orders.Add(order);
var dto = Mappers.ToClientDto(client); var dto = Mappers.ToClientDto(client);
var dtoOrder = dto.Orders[0]; var dtoOrder = dto.Orders[0];
@@ -159,6 +166,7 @@ public class AfterTests
var roundTripOrder = Assert.Single(roundTrip.Orders); var roundTripOrder = Assert.Single(roundTrip.Orders);
Assert.Equal(dtoOrder.Id, roundTripOrder.Id); Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
Assert.Equal(dtoOrder.Description, roundTripOrder.Description); Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
Assert.Equal(dtoOrder.TotalInCents, roundTripOrder.TotalInCents);
Assert.Same(roundTrip, roundTripOrder.ClientDto); Assert.Same(roundTrip, roundTripOrder.ClientDto);
} }
@@ -168,9 +176,8 @@ public class AfterTests
[Fact] [Fact]
public void ToClientDto_does_not_mutate_the_domain() public void ToClientDto_does_not_mutate_the_domain()
{ {
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" }; var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").Build();
var order = new Order { Id = Guid.NewGuid(), Description = "one" }; var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
client.Orders.Add(order);
var ordersCount = client.Orders.Count; var ordersCount = client.Orders.Count;
Mappers.ToClientDto(client); Mappers.ToClientDto(client);
@@ -243,9 +250,8 @@ public class AfterTests
public void After_WebApp_consumes_dtos() public void After_WebApp_consumes_dtos()
{ {
var app = new WebApp(); var app = new WebApp();
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" }; var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("order one").WithTotal(150_00).Build();
var order = new Order { Id = Guid.NewGuid(), Description = "order one" }; var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
client.Orders.Add(order);
var view = app.ShowClient(Mappers.ToClientDto(client)); var view = app.ShowClient(Mappers.ToClientDto(client));
@@ -262,9 +268,8 @@ public class AfterTests
public void DbContext_saves_plain_domain_objects_and_tracks_them() public void DbContext_saves_plain_domain_objects_and_tracks_them()
{ {
var db = new DbContext(); var db = new DbContext();
var client = new Client { Name = "Acme" }; var order = ValidOrder().WithDescription("one").Build();
var order = new Order { Description = "one" }; var client = ValidClient().WithName("Acme").WithOrder(order).Build();
client.Orders.Add(order);
Assert.Equal(Guid.Empty, client.Id); Assert.Equal(Guid.Empty, client.Id);
db.Save(client); db.Save(client);
@@ -282,7 +287,7 @@ public class AfterTests
public void Saving_twice_does_not_reassign_the_id() public void Saving_twice_does_not_reassign_the_id()
{ {
var db = new DbContext(); var db = new DbContext();
var client = new Client { Name = "Acme" }; var client = ValidClient().WithName("Acme").Build();
db.Save(client); db.Save(client);
var id = client.Id; var id = client.Id;
@@ -296,7 +301,7 @@ public class AfterTests
public void A_saved_domain_object_keeps_no_reference_to_the_context() public void A_saved_domain_object_keeps_no_reference_to_the_context()
{ {
var db = new DbContext(); var db = new DbContext();
var client = new Client { Name = "Acme" }; var client = ValidClient().WithName("Acme").Build();
db.Save(client); db.Save(client);
// Saved (the id was assigned by the context) ... // Saved (the id was assigned by the context) ...
@@ -0,0 +1,143 @@
using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;
namespace BeforeAfter.Tests;
/// <summary>
/// Unit tests for <see cref="DiscountCalculator.CalculateDiscount"/>:
/// barrier semantics (strictly greater), integer-percent units, half-up
/// rounding of the discount amount, input validation, and the guarantee that
/// the calculation never mutates the order.
/// </summary>
public class DiscountCalculatorTests
{
// ---------------------------------------------------------------------
// The barrier: only a total strictly greater than it is discounted.
// ---------------------------------------------------------------------
[Fact]
public void A_total_below_the_barrier_gets_no_discount()
{
var order = ValidOrder().WithTotal(50_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
Assert.Equal(0, discounted.DiscountPercentage);
Assert.Equal(50_00, discounted.DiscountedTotalInCents);
Assert.Equal(50_00, discounted.TotalInCents);
}
[Fact]
public void A_total_exactly_at_the_barrier_gets_no_discount()
{
var order = ValidOrder().WithTotal(100_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
Assert.Equal(0, discounted.DiscountPercentage);
Assert.Equal(100_00, discounted.DiscountedTotalInCents);
}
// ---------------------------------------------------------------------
// Above the barrier: percentage applied, total reduced.
// ---------------------------------------------------------------------
[Fact]
public void A_total_above_the_barrier_gets_the_percentage_discount()
{
var order = ValidOrder().WithTotal(150_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
Assert.Equal(10, discounted.DiscountPercentage);
Assert.Equal(135_00, discounted.DiscountedTotalInCents); // 10 % of 150 = 15 off
Assert.Equal(150_00, discounted.TotalInCents);
}
[Fact]
public void A_zero_percentage_discounts_nothing_even_above_the_barrier()
{
var order = ValidOrder().WithTotal(150_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 0);
Assert.Equal(0, discounted.DiscountPercentage);
Assert.Equal(150_00, discounted.DiscountedTotalInCents);
}
[Fact]
public void A_hundred_percent_discount_leaves_nothing()
{
var order = ValidOrder().WithTotal(150_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 100);
Assert.Equal(100, discounted.DiscountPercentage);
Assert.Equal(0, discounted.DiscountedTotalInCents);
}
// ---------------------------------------------------------------------
// Half-up rounding of the discount amount (integer math, no float).
// ---------------------------------------------------------------------
[Theory]
[InlineData(155, 16, 139)] // 15.5 rounds UP to 16
[InlineData(145, 15, 130)] // 14.5 rounds UP to 15
[InlineData(154, 15, 139)] // 15.4 rounds down to 15
[InlineData(151, 15, 136)] // 15.1 rounds down to 15
public void The_discount_amount_is_rounded_half_up(int totalInCents, int expectedDiscountCents, int expectedDiscountedTotal)
{
var order = ValidOrder().WithTotal(totalInCents).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100, discountPercentage: 10);
Assert.Equal(totalInCents - expectedDiscountCents, discounted.DiscountedTotalInCents);
Assert.Equal(expectedDiscountedTotal, discounted.DiscountedTotalInCents);
}
// ---------------------------------------------------------------------
// Correlation and purity.
// ---------------------------------------------------------------------
[Fact]
public void The_result_is_correlated_to_the_order_by_id()
{
var id = Guid.NewGuid();
var order = ValidOrder().WithId(id).WithTotal(150_00).Build();
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
Assert.Equal(id, discounted.OrderId);
}
[Fact]
public void Calculating_does_not_mutate_the_order()
{
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
Assert.Equal("one", order.Description);
Assert.Equal(150_00, order.TotalInCents);
}
// ---------------------------------------------------------------------
// Input validation: invalid arguments throw (loudly, not silently).
// ---------------------------------------------------------------------
[Theory]
[InlineData(-1, 100_00, 10)] // negative total
[InlineData(150_00, -1, 10)] // negative barrier
[InlineData(150_00, 100_00, -1)] // percentage below 0
[InlineData(150_00, 100_00, 101)]// percentage above 100
public void Invalid_arguments_throw(int totalInCents, int discountBarrierCents, int discountPercentage)
{
var order = ValidOrder().WithTotal(totalInCents).Build();
Assert.Throws<ArgumentOutOfRangeException>(
() => DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage));
}
[Fact]
public void A_null_order_throws()
{
Assert.Throws<ArgumentNullException>(
() => DiscountCalculator.CalculateDiscount(null!, discountBarrierCents: 100_00, discountPercentage: 10));
}
}
@@ -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);
}
}
@@ -0,0 +1,40 @@
using After;
namespace BeforeAfter.Tests.TestData;
/// <summary>
/// Test data builder for a <see cref="Client"/> with its orders
/// (TestDataBuilder pattern). <see cref="ValidClient"/> starts from a
/// <em>fresh</em> client, exactly what <c>new Client()</c> gives.
/// </summary>
public sealed class ClientBuilder
{
private readonly Client _client = new();
/// <summary>A valid, fresh client — the neutral starting point for tests.</summary>
public static ClientBuilder ValidClient() => new();
public ClientBuilder WithId(Guid id)
{
_client.Id = id;
return this;
}
public ClientBuilder WithName(string name)
{
_client.Name = name;
return this;
}
/// <summary>Adds an order to the client (navigation fix-up wires the back-reference).</summary>
public ClientBuilder WithOrder(Order order)
{
_client.Orders.Add(order);
return this;
}
/// <summary>Builds the given builder and adds its order — chaining sugar for <c>.WithOrder(builder.Build())</c>.</summary>
public ClientBuilder WithOrder(OrderBuilder order) => WithOrder(order.Build());
public Client Build() => _client;
}
@@ -0,0 +1,56 @@
using After;
namespace BeforeAfter.Tests.TestData;
/// <summary>
/// Test data builder for an <see cref="Order"/> (TestDataBuilder pattern).
/// <see cref="ValidOrder"/> starts from a <em>fresh</em> order —
/// <see cref="Guid.Empty"/> id, empty description, zero total, exactly what
/// <c>new Order()</c> gives — and every <c>With...</c> returns the builder for
/// chaining; <see cref="Build"/> hands back the order.
///
/// This class is the only place tests touch an order's *construction*: when
/// the domain evolves (e.g. OrderLines making the total computed), only these
/// internals change and the tests keep reading the same.
/// </summary>
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();
public OrderBuilder WithId(Guid id)
{
_order.Id = id;
return this;
}
public OrderBuilder WithDescription(string description)
{
_order.Description = description;
return this;
}
/// <summary>
/// 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)
{
_totalInCents = totalInCents;
return this;
}
public Order Build()
{
if (_totalInCents is int total)
_order.Lines.Add(new OrderLine(_order.Description, total));
return _order;
}
}