Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53586d33a4 | ||
|
|
37ebb21182 | ||
|
|
d39426d328 | ||
|
|
803580786b | ||
|
|
49f4ca395f | ||
|
|
9895ef87e4 |
+151
@@ -0,0 +1,151 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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;
|
||||||
|
|
||||||
|
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 client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "big order", TotalInCents = 150_00 };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
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 client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "big order", TotalInCents = 150_00 };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
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.
|
||||||
@@ -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
@@ -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
@@ -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>
|
/// <summary>
|
||||||
/// The fake <c>DbContext</c>, refactored to use per-type shadow collections
|
/// Foreign key to the owning client: <see cref="Guid.Empty"/> when the
|
||||||
/// (mirroring EF Core's <c>Set<T></c>) plus an instance-level
|
/// order has no client, or when its client had no id yet at save time —
|
||||||
/// <c>Find<T></c> query method.
|
/// save clients before their orders.
|
||||||
|
/// </summary>
|
||||||
|
public Guid ClientId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fake <c>DbContext</c>: per-type tables of data records, mirroring EF
|
||||||
|
/// Core's <c>Set<T></c>. <see cref="Save(Client)"/> / <see cref="Save(Order)"/>
|
||||||
|
/// 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);
|
||||||
if (shadow.Id != Guid.Empty) continue; // already had an id
|
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
|
||||||
shadow.Id = Guid.NewGuid();
|
client.Orders.Add(Mappers.FromOrderData(orderRow)); // fix-up wires Order.Client
|
||||||
count++;
|
return client;
|
||||||
}
|
}
|
||||||
return count;
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
|
||||||
|
if (clientRow is not null)
|
||||||
|
{
|
||||||
|
var client = Mappers.FromClientData(clientRow);
|
||||||
|
client.Orders.Add(order); // fix-up wires Order.Client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 -------------------------------------------------------
|
||||||
|
|||||||
@@ -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,148 @@
|
|||||||
|
using After;
|
||||||
|
|
||||||
|
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 client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "one", TotalInCents = 15000 };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
|
||||||
|
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(15000, 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 = new Order { Description = "orphan" };
|
||||||
|
|
||||||
|
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 client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "one" };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
order.Description = "one (edited)";
|
||||||
|
order.TotalInCents = 9900;
|
||||||
|
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(9900, 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 client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "one" };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
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 client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "one", TotalInCents = 150_00 };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
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 client = new Client { Name = "Acme" };
|
||||||
|
client.Orders.Add(new Order { Description = "one", TotalInCents = 150_00 });
|
||||||
|
client.Orders.Add(new Order { Description = "two", TotalInCents = 250_00 });
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(client.Orders[0]);
|
||||||
|
db.Save(client.Orders[1]);
|
||||||
|
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,12 +54,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 = new Order { Id = id, Description = "one", TotalInCents = 15000 };
|
||||||
|
|
||||||
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(15000, dto.TotalInCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -78,12 +79,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 = 15000 };
|
||||||
|
|
||||||
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(15000, order.TotalInCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -129,7 +131,7 @@ public class AfterTests
|
|||||||
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 client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
var order = new Order { Id = Guid.NewGuid(), Description = "one", TotalInCents = 15000 };
|
||||||
client.Orders.Add(order);
|
client.Orders.Add(order);
|
||||||
|
|
||||||
var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
|
var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
|
||||||
@@ -139,6 +141,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);
|
||||||
}
|
}
|
||||||
@@ -159,6 +162,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +248,7 @@ public class AfterTests
|
|||||||
{
|
{
|
||||||
var app = new WebApp();
|
var app = new WebApp();
|
||||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||||
var order = new Order { Id = Guid.NewGuid(), Description = "order one" };
|
var order = new Order { Id = Guid.NewGuid(), Description = "order one", TotalInCents = 15000 };
|
||||||
client.Orders.Add(order);
|
client.Orders.Add(order);
|
||||||
|
|
||||||
var view = app.ShowClient(Mappers.ToClientDto(client));
|
var view = app.ShowClient(Mappers.ToClientDto(client));
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using After;
|
||||||
|
|
||||||
|
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 = new Order { Id = Guid.NewGuid(), Description = "small", TotalInCents = 50_00 };
|
||||||
|
|
||||||
|
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 = new Order { Id = Guid.NewGuid(), TotalInCents = 100_00 };
|
||||||
|
|
||||||
|
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 = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
|
||||||
|
|
||||||
|
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 = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
|
||||||
|
|
||||||
|
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 = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
|
||||||
|
|
||||||
|
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 = new Order { Id = Guid.NewGuid(), TotalInCents = totalInCents };
|
||||||
|
|
||||||
|
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 = new Order { Id = id, TotalInCents = 150_00 };
|
||||||
|
|
||||||
|
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 = new Order { Id = Guid.NewGuid(), Description = "one", TotalInCents = 150_00 };
|
||||||
|
|
||||||
|
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 = new Order { Id = Guid.NewGuid(), TotalInCents = totalInCents };
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user