Compare commits

...
14 Commits
Author SHA1 Message Date
mostalive 51c17984f1 point facilitator notes at the two solution branches 2026-09-17 08:56:26 +01:00
mostalive ff6dbfb269 EXERCISE2.md: OrderLines evolution in three steps, sequenced after Exercise 1
Steps: (1) OrderLine + computed total — the compiler enumerates the writers,
(2) one-line reconstruction in the two inbound mappers, (3) discount on a
reloaded order + the accepted-loss test. Dry-run verified on a scratch
branch: 98 tests green, diff confined to domain/builder/two mappers.

yak: exercise2md-orderlines-evolution-sequenced-accepted-loss-test-efap
2026-09-16 22:35:13 +01:00
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
mostalive 01ba13f94d Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/learning-hours/db-subclass-to-dto
# Conflicts:
#	after-dto.mmd.svg
#	before-after-dto.mmd.svg
2026-09-14 22:48:48 +01:00
mostalive 5270eabcf3 Add third exercise description (Roslyn extract method spike) to README.md 2026-09-14 14:52:03 +01:00
mostalive 804695f12d Add exercise descriptions to README.md 2026-09-14 14:48:11 +01:00
mostalive 1188ce4e57 Add screenshots.md with Flameshot and Knip notes 2026-09-14 14:42:17 +01:00
mostalive e15962d86a Add MIT license (Living Software LTD) 2026-09-14 14:41:16 +01:00
24 changed files with 1212 additions and 91 deletions
+1
View File
@@ -6,3 +6,4 @@ obj/
*.user *.user
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout) # NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
.local/ .local/
.idea
+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.
+191
View File
@@ -0,0 +1,191 @@
# 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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Living Software LTD (UK)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
# db-subclass-to-dto
This is a workspace for creating three exercises.
## Explore a repository with LLM diagrams
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.
## Factor out domain objects from ActiveRecord-like structures
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.
+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"executablePath": "/home/willem/.cache/puppeteer/chrome/linux-152.0.7977.75/chrome-linux64/chrome",
"headless": true,
"args": [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-gpu",
"--disable-gpu-compositing",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
"--disable-features=VizDisplayCompositor",
"--use-gl=swiftshader",
"--disable-gpu-driver-bug-workaround"
]
}
+31
View File
@@ -0,0 +1,31 @@
# Screenshots
## Flameshot
Flameshot v12.1.0 is installed at `/usr/bin/flameshot`.
| Command | Description |
|---|---|
| `flameshot launcher` | Open the capture launcher (recommended) |
| `flameshot gui` | Start a manual (rectangular) capture |
| `flameshot screen` | Capture a single screen |
| `flameshot full` | Capture the entire desktop |
| `flameshot config` | Open the configuration dialog |
Launches a tray icon by default for quick access.
## Knip
Knip is **not installed** on this machine. Install it per-project:
```sh
npx knip # dry-run report
npx knip --fix # auto-remove unused exports
npx knip --config knip.config.ts # custom config
```
Fix npm cache ownership first if you hit `EACCES`:
```sh
sudo chown -R 1000:1000 /home/willem/.npm
```
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Render a mermaid diagram to SVG using mmdc with sandbox/GPU workarounds
# for Ubuntu 24.04+ (AppArmor restrictions).
#
# Usage: scripts/mermaid.sh input.mmd [output.svg]
# scripts/mermaid.sh input.mmd - # output to stdout
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG="$REPO_ROOT/puppeteer.config.json"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <input.mmd> [output.svg|-]" >&2
exit 1
fi
INPUT="$1"
shift
ARGS=("-i" "$INPUT" "-p" "$CONFIG")
if [[ $# -gt 0 ]]; then
OUTPUT="$1"
shift
ARGS+=("-o" "$OUTPUT")
fi
ARGS+=("$@")
exec mmdc "${ARGS[@]}"
+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; }
}
+68 -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;
@@ -94,9 +103,64 @@ 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),
}; };
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);
return new Order { Id = row.Id, Description = row.Description, TotalInCents = row.TotalInCents };
}
} }
+7
View File
@@ -17,6 +17,13 @@ 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). A normal
/// attribute: it is persisted in <see cref="OrderData"/> and mapped
/// one-on-one into <see cref="OrderDto.TotalInCents"/>.
/// </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
+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.
+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.TotalInCents = 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()));
}
}
+29 -24
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) ...
@@ -309,4 +314,4 @@ public class AfterTests
var dto = Mappers.ToClientDto(client); var dto = Mappers.ToClientDto(client);
Assert.Equal(client.Id, dto.Id); // the id flows on into the DTO Assert.Equal(client.Id, dto.Id); // the id flows on into the DTO
} }
} }
@@ -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,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,47 @@
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();
/// <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). How the requested
/// total is realised inside the order is an implementation detail that may
/// change as the domain evolves; this API stays.
/// </summary>
public OrderBuilder WithTotal(int totalInCents)
{
_order.TotalInCents = totalInCents;
return this;
}
public Order Build() => _order;
}