This commit is contained in:
2026-09-17 18:54:53 +01:00
commit eac5fbdbf5
38 changed files with 3974 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.yaks
artifacts/
bin/
obj/
.dotnet-cli/
.npm-cache/
node_modules/
*.user
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
.local/
.idea
+81
View File
@@ -0,0 +1,81 @@
# AGENTS.md
Exercise repo: illustrating the transition from DB-backed domain classes
(`DbBase` inheritance) to plain domain objects + DTOs + mappers.
**Working Set (for now): the split projects — `src/Domain`,
`src/Db`, `src/Web`** — focus changes there; leave other
projects untouched unless a yak explicitly says otherwise.
## Rendering diagrams (mermaid → svg)
Mermaid diagrams are rendered to SVG with mermaid-cli (`mmdc`), pinned as a
devDependency in `package.json`. Install it once per clone:
```sh
npm install # installs @mermaid-js/mermaid-cli into node_modules/
```
Then render with `npx mmdc`, which resolves the local install — the same
command on Linux, macOS and **Windows** (PowerShell/cmd):
```sh
npx mmdc -p puppeteer.config.json -i <diagram>.mmd # -> <diagram>.mmd.svg
```
`-p puppeteer.config.json` passes the headless-Chrome flags (`--no-sandbox`
et al.) — without it mmdc fails to launch the browser in agent/sandbox
shells. On desktop shells it is not needed: plain `npx mmdc -i <input>`
works. On first run puppeteer downloads its own Chrome. `node_modules/` is
gitignored.
## Running dotnet
dotnet SDK 10 is installed **via mise** (see `mise.toml`). It is NOT on PATH in
agent/sandbox shells, and SDK 10 additionally fails without
`DOTNET_CLI_HOME` in some shells. Always use the wrappers:
```sh
scripts/dotnet.sh build db-subclass-to-dto.sln
scripts/dotnet.sh test db-subclass-to-dto.sln
scripts/run-tests.sh # full test suite, from repo root
```
The wrappers cd to the repo root (the test runner must run from the parent
directory of the test project), export `DOTNET_CLI_HOME`, and invoke
`mise exec -- dotnet ...`. Do not call bare `dotnet`.
If the first-time-use setup fails with `The file ...
'.dotnet-cli/.dotnet' already exists` or `Operation not permitted` (some
agent/sandbox shells block `$HOME/.dotnet-cli`), point the wrapper at a
repo-local home: `DOTNET_CLI_HOME="$PWD/.dotnet-cli" scripts/dotnet.sh ...`
(add `.dotnet-cli/` to `.gitignore`).
## Yaks (task management with yx)
Tasks are managed with the `yx` CLI; yaks live in git (event store), snapshot
in `.yaks/` (gitignored). Work one yak at a time:
```sh
yx list # all yaks and states
yx show "<yak name>" # full yak context (the task instructions)
yx start "<yak name>" # set state to wip
yx done "<yak name>" # set state to done (only after acceptance
# criteria pass and changes are committed)
```
- The yak context in `yx show` is the authoritative task description; follow
its acceptance criteria before `yx done`.
- Children block their parent: do prerequisites (nested under a yak) first.
- Add new tasks: `yx add "<name>"` (nest with `--under "<parent>"`).
- If yx complains `.yaks is not gitignored`, keep `.yaks` in `.gitignore`.
## Conventions
- Target framework: net10.0 (SDK pinned by mise).
- Tests: xUnit, mirroring the src split: `tests/Domain.Tests`,
`tests/Db.Tests`, `tests/Web.Tests`, with the shared test-data builders in
`tests/TestData`.
- No EF Core / SQLite / AutoMapper — the DB layer is a small hand-rolled fake
(pure managed code, cross-platform).
- Commit per yak with a message referencing the yak.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+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.
+131
View File
@@ -0,0 +1,131 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{A103FFE5-4F20-4DE1-A086-38012E701F9E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Db", "src\Db\Db.csproj", "{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "src\Web\Web.csproj", "{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestData", "tests\TestData\TestData.csproj", "{A9EA20AD-2B2D-456B-858A-1575C4B99E11}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain.Tests", "tests\Domain.Tests\Domain.Tests.csproj", "{824867CB-A83B-4968-A29D-D39FC17BCB85}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Db.Tests", "tests\Db.Tests\Db.Tests.csproj", "{7DA9971D-306A-4997-A585-D956F41C7A08}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Tests", "tests\Web.Tests\Web.Tests.csproj", "{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x64.ActiveCfg = Debug|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x64.Build.0 = Debug|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x86.ActiveCfg = Debug|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x86.Build.0 = Debug|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|Any CPU.Build.0 = Release|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x64.ActiveCfg = Release|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x64.Build.0 = Release|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x86.ActiveCfg = Release|Any CPU
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x86.Build.0 = Release|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x64.ActiveCfg = Debug|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x64.Build.0 = Debug|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x86.ActiveCfg = Debug|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x86.Build.0 = Debug|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|Any CPU.Build.0 = Release|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x64.ActiveCfg = Release|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x64.Build.0 = Release|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x86.ActiveCfg = Release|Any CPU
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x86.Build.0 = Release|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x64.ActiveCfg = Debug|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x64.Build.0 = Debug|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x86.ActiveCfg = Debug|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x86.Build.0 = Debug|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|Any CPU.Build.0 = Release|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x64.ActiveCfg = Release|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x64.Build.0 = Release|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x86.ActiveCfg = Release|Any CPU
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x86.Build.0 = Release|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x64.ActiveCfg = Debug|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x64.Build.0 = Debug|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x86.ActiveCfg = Debug|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x86.Build.0 = Debug|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|Any CPU.Build.0 = Release|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x64.ActiveCfg = Release|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x64.Build.0 = Release|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x86.ActiveCfg = Release|Any CPU
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x86.Build.0 = Release|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|Any CPU.Build.0 = Debug|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x64.ActiveCfg = Debug|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x64.Build.0 = Debug|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x86.ActiveCfg = Debug|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x86.Build.0 = Debug|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|Any CPU.ActiveCfg = Release|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|Any CPU.Build.0 = Release|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x64.ActiveCfg = Release|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x64.Build.0 = Release|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x86.ActiveCfg = Release|Any CPU
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x86.Build.0 = Release|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x64.ActiveCfg = Debug|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x64.Build.0 = Debug|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x86.ActiveCfg = Debug|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x86.Build.0 = Debug|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|Any CPU.Build.0 = Release|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x64.ActiveCfg = Release|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x64.Build.0 = Release|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x86.ActiveCfg = Release|Any CPU
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x86.Build.0 = Release|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x64.ActiveCfg = Debug|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x64.Build.0 = Debug|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x86.ActiveCfg = Debug|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x86.Build.0 = Debug|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|Any CPU.Build.0 = Release|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x64.ActiveCfg = Release|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x64.Build.0 = Release|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x86.ActiveCfg = Release|Any CPU
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{A103FFE5-4F20-4DE1-A086-38012E701F9E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A9EA20AD-2B2D-456B-858A-1575C4B99E11} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{824867CB-A83B-4968-A29D-D39FC17BCB85} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{7DA9971D-306A-4997-A585-D956F41C7A08} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+2
View File
@@ -0,0 +1,2 @@
[tools]
dotnet = "10"
+2521
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"@mermaid-js/mermaid-cli": "^11.17.0"
}
}
+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"
]
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Run any dotnet command for this repo.
#
# Why a wrapper:
# - dotnet is installed via mise (see mise.toml, dotnet = "10"); it is NOT on
# PATH in agent/sandbox shells, so plain `dotnet` fails with 127.
# - dotnet SDK 10 fails with "The user's home directory could not be
# determined" unless DOTNET_CLI_HOME is set.
# - The test runner must be invoked from the repo root (the parent directory
# of the test project), so we always cd here first.
#
# Usage: scripts/dotnet.sh <dotnet command and args>
# scripts/dotnet.sh test db-subclass-to-dto.sln
# scripts/dotnet.sh build
set -euo pipefail
cd "$(dirname "$0")/.."
export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$HOME/.dotnet-cli}"
export DOTNET_NOLOGO=1
export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
if ! command -v mise >/dev/null 2>&1; then
echo "error: mise not found on PATH; install mise or put dotnet on PATH" >&2
exit 1
fi
if [ "$#" -eq 0 ]; then
exec mise exec -- dotnet --version
fi
exec mise exec -- dotnet "$@"
+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[@]}"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Run the full test suite from the repo root (see scripts/dotnet.sh for why).
# Usage: scripts/run-tests.sh [extra dotnet test args]
set -euo pipefail
exec "$(cd "$(dirname "$0")" && pwd)/dotnet.sh" test db-subclass-to-dto.sln "$@"
+10
View File
@@ -0,0 +1,10 @@
using Domain;
namespace Db;
/// <summary>Persisted shape of a <see cref="Client"/>: data only.</summary>
public sealed class ClientData : DbBase
{
/// <summary>Display name of the client.</summary>
public string Name { get; set; } = string.Empty;
}
+46
View File
@@ -0,0 +1,46 @@
using Domain;
namespace Db;
/// <summary>Hand-written mappers between domain objects and persistence records; each method maps one direction and never mutates its input.</summary>
public static class DataMappers
{
/// <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 client back-reference becomes a foreign key.</summary>
public static OrderData ToOrderData(Order order)
{
ArgumentNullException.ThrowIfNull(order);
return new OrderData
{
Id = order.Id,
Description = order.Description,
TotalInCents = order.TotalInCents.Cents, // value object → plain int for the row
ClientId = order.Client?.Id ?? Guid.Empty,
};
}
/// <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"/>; the context reassembles the graph.</summary>
public static Order FromOrderData(OrderData row)
{
ArgumentNullException.ThrowIfNull(row);
return new Order
{
Id = row.Id,
Description = row.Description,
TotalInCents = OrderMoney.From(row.TotalInCents), // plain int from the row → value object
};
}
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
using Domain;
namespace Db;
/// <summary>Persistence framework base class: only the data records derive from it, never the domain objects.</summary>
public class DbBase
{
/// <summary>Primary key; <see cref="Guid.Empty"/> until the context assigns one on save.</summary>
public Guid Id { get; set; }
/// <summary>Back-reference to the owning <see cref="DbContext"/>; lives on persistence records only.</summary>
public DbContext? DbContext { get; internal set; }
}
+102
View File
@@ -0,0 +1,102 @@
using Domain;
namespace Db;
/// <summary>The fake DbContext: per-type tables of data-only records (never domain objects); Save inserts-or-refreshes rows, Load reassembles domain objects.</summary>
public class DbContext
{
private readonly List<ClientData> _clients = new();
private readonly List<OrderData> _orders = new();
// domain object reference → its persisted record
private readonly Dictionary<object, DbBase> _byRef = new();
public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
/// <summary>The strongly-typed "table" for <typeparamref name="T"/>.</summary>
public IReadOnlyList<T> Set<T>() where T : DbBase
=> typeof(T) switch
{
var t when t == typeof(ClientData) => (IReadOnlyList<T>)_clients.AsReadOnly(),
var t when t == typeof(OrderData) => (IReadOnlyList<T>)_orders.AsReadOnly(),
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
};
/// <summary>Persist a <see cref="Client"/>: insert on first save (assigning its id), refresh in place on later ones.</summary>
public void Save(Client client)
{
ArgumentNullException.ThrowIfNull(client);
var fresh = DataMappers.ToClientData(client);
if (_byRef.TryGetValue(client, out var existing))
{
var row = (ClientData)existing; // refresh, keep row identity
row.Name = fresh.Name;
return;
}
var id = Guid.NewGuid();
fresh.Id = client.Id = id; // write-back identity
fresh.DbContext = this;
_clients.Add(fresh);
_byRef[client] = fresh;
}
/// <summary>Persist an <see cref="Order"/> (same insert-or-refresh rule; save its client first so the foreign key can be filled).</summary>
public void Save(Order order)
{
ArgumentNullException.ThrowIfNull(order);
var fresh = DataMappers.ToOrderData(order);
if (_byRef.TryGetValue(order, out var existing))
{
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();
fresh.Id = order.Id = id; // write-back identity
fresh.DbContext = this;
_orders.Add(fresh);
_byRef[order] = fresh;
}
/// <summary>Load the <see cref="Client"/> stored under <paramref name="id"/> with all of its orders, or null.</summary>
public Client? LoadClient(Guid id)
{
var row = Find<ClientData>(r => r.Id == id);
if (row is null) return null;
var client = DataMappers.FromClientData(row);
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
client.Orders.Add(DataMappers.FromOrderData(orderRow)); // fix-up wires Order.Client
return client;
}
/// <summary>Load the <see cref="Order"/> stored under <paramref name="id"/> with its client, or null; use <see cref="LoadClient"/> for a client with all its orders.</summary>
public Order? LoadOrder(Guid id)
{
var row = Find<OrderData>(r => r.Id == id);
if (row is null) return null;
var order = DataMappers.FromOrderData(row);
if (row.ClientId != Guid.Empty)
{
var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
if (clientRow is not null)
{
var client = DataMappers.FromClientData(clientRow);
client.Orders.Add(order); // fix-up wires Order.Client
}
}
return order;
}
/// <summary>Search the per-type table for a record whose predicate matches.</summary>
public T? Find<T>(Func<T, bool> predicate) where T : DbBase
=> Set<T>().FirstOrDefault(predicate);
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
/// <summary>All data records this context currently manages.</summary>
public IReadOnlyList<DbBase> Tracked => _byRef.Values.ToList();
}
+16
View File
@@ -0,0 +1,16 @@
using Domain;
namespace Db;
/// <summary>Persisted shape of an <see cref="Order"/>: data only, object graph flattened to ids.</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>Order total in cents of the stored order.</summary>
public int TotalInCents { get; set; }
/// <summary>Foreign key to the owning client; empty when the order has no client.</summary>
public Guid ClientId { get; set; }
}
+40
View File
@@ -0,0 +1,40 @@
using System.Collections.ObjectModel;
namespace Domain;
/// <summary>A plain domain object: no persistence base class, no persistence context knowledge.</summary>
public class Client
{
/// <summary>Identity: <see cref="Guid.Empty"/> until the persistence layer assigns one on save.</summary>
public Guid Id { get; set; }
/// <summary>Display name of the client.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>The client's orders; adding one wires its <see cref="Order.Client"/> back-reference.</summary>
public ClientOrders Orders { get; }
public Client()
{
Orders = new ClientOrders(this);
}
}
/// <summary>An <see cref="Order"/> collection that fakes EF's navigation fix-up.</summary>
public sealed class ClientOrders : Collection<Order>
{
private readonly Client _owner;
public ClientOrders(Client owner)
{
_owner = owner;
}
// Add(...) and Insert(...) both funnel through InsertItem.
protected override void InsertItem(int index, Order item)
{
ArgumentNullException.ThrowIfNull(item);
item.Client = _owner; // navigation fix-up
base.InsertItem(index, item);
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+17
View File
@@ -0,0 +1,17 @@
namespace Domain;
/// <summary>A plain domain order: no persistence base class, no persistence context knowledge.</summary>
public class Order
{
/// <summary>Identity: <see cref="Guid.Empty"/> until the persistence layer assigns one on save.</summary>
public Guid Id { get; set; }
/// <summary>Free-text description of what this order is for.</summary>
public string Description { get; set; } = string.Empty;
/// <summary>Order total as a value object; the DTO and storage row stay plain ints — the mappers convert.</summary>
public OrderMoney TotalInCents { get; set; } = OrderMoney.Default;
/// <summary>The owning client; wired by navigation fix-up when the order is added to <see cref="Client.Orders"/>.</summary>
public Client? Client { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
namespace Domain;
/// <summary>Money value object: non-negative whole cents; invalid amounts are unrepresentable. Record: value equality.</summary>
public sealed record OrderMoney
{
private OrderMoney(int cents) => Cents = cents;
/// <summary>The only validating construction path.</summary>
public static OrderMoney From(int cents)
{
ArgumentOutOfRangeException.ThrowIfNegative(cents);
return new OrderMoney(cents);
}
/// <summary>Zero sentinel for member initializers (like <see cref="Guid.Empty"/>); internal to this assembly.</summary>
internal static OrderMoney Default { get; } = new(0);
/// <summary>The raw amount in cents.</summary>
public int Cents { get; }
/// <summary>Explicit conversions only; int → OrderMoney validates exactly like <see cref="From"/>.</summary>
public static explicit operator OrderMoney(int cents) => From(cents);
public static explicit operator int(OrderMoney money)
{
ArgumentNullException.ThrowIfNull(money);
return money.Cents;
}
public override string ToString() => Cents.ToString();
}
+16
View File
@@ -0,0 +1,16 @@
using Domain;
namespace Web;
/// <summary>DTO carrying a <see cref="Client"/> — with its orders — to the WebApp; record: value equality keeps round-trip comparisons trivial.</summary>
public sealed record ClientDto
{
/// <summary>Primary key of the underlying client.</summary>
public Guid Id { get; init; }
/// <summary>Display name of the client.</summary>
public string Name { get; init; } = string.Empty;
/// <summary>The client's orders, each carrying a back-reference to this DTO; settable so the mappers can fill it in a second pass.</summary>
public IReadOnlyList<OrderDto> Orders { get; set; } = Array.Empty<OrderDto>();
}
+92
View File
@@ -0,0 +1,92 @@
using Domain;
namespace Web;
/// <summary>Hand-written mappers between domain objects and DTOs; each method maps one direction and never mutates its input. The client/order cycle is resolved with a reference-identity cache.</summary>
public static class DtoMappers
{
/// <summary>Map a <see cref="Client"/> (with its orders) to a <see cref="ClientDto"/>.</summary>
public static ClientDto ToClientDto(Client client)
{
ArgumentNullException.ThrowIfNull(client);
return MapClient(client, new Dictionary<object, object>());
}
/// <summary>Map an <see cref="Order"/> (with its client back-reference) to an <see cref="OrderDto"/>.</summary>
public static OrderDto ToOrderDto(Order order)
{
ArgumentNullException.ThrowIfNull(order);
return MapOrder(order, new Dictionary<object, object>());
}
/// <summary>Map a <see cref="ClientDto"/> (with its orders) back to a <see cref="Client"/>.</summary>
public static Client FromClientDto(ClientDto dto)
{
ArgumentNullException.ThrowIfNull(dto);
return UnmapClient(dto, new Dictionary<object, object>());
}
/// <summary>Map an <see cref="OrderDto"/> (with its client back-reference) back to an <see cref="Order"/>.</summary>
public static Order FromOrderDto(OrderDto dto)
{
ArgumentNullException.ThrowIfNull(dto);
return UnmapOrder(dto, new Dictionary<object, object>());
}
private static ClientDto MapClient(Client client, Dictionary<object, object> seen)
{
if (seen.TryGetValue(client, out var cached))
return (ClientDto)cached;
var dto = new ClientDto { Id = client.Id, Name = client.Name };
seen[client] = dto; // cache first: breaks the cycle
dto.Orders = client.Orders
.Select(o => MapOrder(o, seen))
.ToList(); // fill second, through the cache
return dto;
}
private static OrderDto MapOrder(Order order, Dictionary<object, object> seen)
{
if (seen.TryGetValue(order, out var cached))
return (OrderDto)cached;
var dto = new OrderDto
{
Id = order.Id,
Description = order.Description,
TotalInCents = order.TotalInCents.Cents, // value object → plain int for the DTO
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
};
seen[order] = dto;
return dto;
}
private static Client UnmapClient(ClientDto dto, Dictionary<object, object> seen)
{
if (seen.TryGetValue(dto, out var cached))
return (Client)cached;
var client = new Client { Id = dto.Id, Name = dto.Name };
seen[dto] = client; // cache first: breaks the cycle
foreach (var orderDto in dto.Orders)
client.Orders.Add(UnmapOrder(orderDto, seen)); // fix-up wires order.Client
return client;
}
private static Order UnmapOrder(OrderDto dto, Dictionary<object, object> seen)
{
if (seen.TryGetValue(dto, out var cached))
return (Order)cached;
var order = new Order
{
Id = dto.Id,
Description = dto.Description,
TotalInCents = OrderMoney.From(dto.TotalInCents), // plain int from the DTO → value object
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
};
seen[dto] = order;
return order;
}
}
+19
View File
@@ -0,0 +1,19 @@
using Domain;
namespace Web;
/// <summary>DTO carrying an <see cref="Order"/> — including its client back-reference — to the WebApp; record: value equality.</summary>
public sealed record OrderDto
{
/// <summary>Primary key of the underlying order.</summary>
public Guid Id { get; init; }
/// <summary>Free-text description of what this order is for.</summary>
public string Description { get; init; } = string.Empty;
/// <summary>Order total in cents of the underlying order.</summary>
public int TotalInCents { get; init; }
/// <summary>Back-reference to the owning client, or null for a bare order.</summary>
public ClientDto? ClientDto { get; init; }
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- Deliberately NO reference to Db: the Web layer consumes DTOs
only, so it cannot reach the persistence layer — the leak the
exercise illustrates is now enforced by the compiler. -->
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
</Project>
+16
View File
@@ -0,0 +1,16 @@
namespace Web;
/// <summary>The consuming application: it sees only DTOs, never domain or persistence types.</summary>
public class WebApp
{
/// <summary>Render a client together with all of its orders, consuming DTOs only.</summary>
public string ShowClient(ClientDto client)
{
ArgumentNullException.ThrowIfNull(client);
var lines = new List<string> { $"{client.Name} [{client.Id}]" };
foreach (var order in client.Orders)
lines.Add($" - order [{order.Id}]: {order.Description}");
return string.Join(Environment.NewLine, lines);
}
}
+186
View File
@@ -0,0 +1,186 @@
using Domain;
using static TestData.ClientBuilder;
using static TestData.OrderBuilder;
namespace Db.Tests;
/// <summary>The fake <see cref="DbContext"/> stores data-only records, never domain objects; Save maps state into rows (insert or refresh), the graph 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 = OrderMoney.From(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.
[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.Cents);
// 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()));
}
// Tracking: explicit Save, distinct ids; a saved object keeps no reference to the context.
[Fact]
public void DbContext_saves_plain_domain_objects_and_tracks_them()
{
var db = new DbContext();
var order = ValidOrder().WithDescription("one").Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
Assert.Equal(Guid.Empty, client.Id);
db.Save(client);
db.Save(order);
Assert.NotEqual(Guid.Empty, client.Id);
Assert.NotEqual(Guid.Empty, order.Id);
Assert.NotEqual(client.Id, order.Id);
Assert.True(db.IsTracked(client));
Assert.True(db.IsTracked(order));
Assert.Equal(2, db.Tracked.Count);
}
[Fact]
public void Saving_twice_does_not_reassign_the_id()
{
var db = new DbContext();
var client = ValidClient().WithName("Acme").Build();
db.Save(client);
var id = client.Id;
db.Save(client);
Assert.Equal(id, client.Id);
Assert.Single(db.Tracked);
}
[Fact]
public void A_saved_domain_object_keeps_no_reference_to_the_context()
{
var db = new DbContext();
var client = ValidClient().WithName("Acme").Build();
db.Save(client);
// Saved (the id was assigned by the context) ...
Assert.NotEqual(Guid.Empty, client.Id);
Assert.True(db.IsTracked(client));
// No way back to the context through the domain object (see DomainStaysPlainTests): the only "receipt" is the plain Id value.
Assert.Equal(db.Tracked.Single(t => t.Id == client.Id).Id, client.Id);
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Domain is referenced explicitly because these tests use domain
types directly (it also flows in transitively through Db). -->
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
<ProjectReference Include="..\..\src\Db\Db.csproj" />
<ProjectReference Include="..\TestData\TestData.csproj" />
</ItemGroup>
</Project>
+30
View File
@@ -0,0 +1,30 @@
using Domain;
using System.Reflection;
namespace Db.Tests;
/// <summary>The domain stayed clean: Client/Order do NOT derive from <see cref="DbBase"/> and expose no <see cref="DbContext"/>-typed member; asserted from the persistence side, where both are visible.</summary>
public class DomainStaysPlainTests
{
private const BindingFlags AllMembers =
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
// The leak is gone: no DbBase in the base chain, no DbContext-typed member (reflection).
[Theory]
[InlineData(typeof(Client))]
[InlineData(typeof(Order))]
public void Domain_objects_are_plain__no_DbBase_no_DbContext_member(Type type)
{
// Not DbBase, and not a derived type either (walk the base chain):
for (var t = type; t is not null && t != typeof(object); t = t.BaseType)
Assert.NotSame(typeof(DbBase), t);
// No DbContext-typed member, public or private:
foreach (var p in type.GetProperties(AllMembers))
Assert.True(p.PropertyType != typeof(DbContext),
$"{type.Name}.{p.Name} leaks a DbContext-typed property");
foreach (var f in type.GetFields(AllMembers))
Assert.True(f.FieldType != typeof(DbContext),
$"{type.Name}.{f.Name} leaks a DbContext-typed field");
}
}
+26
View File
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
<ProjectReference Include="..\TestData\TestData.csproj" />
</ItemGroup>
</Project>
+46
View File
@@ -0,0 +1,46 @@
namespace Domain.Tests;
/// <summary>Unit tests for the <see cref="OrderMoney"/> value object.</summary>
public class OrderMoneyTests
{
[Fact]
public void From_keeps_the_cents()
{
Assert.Equal(150_00, OrderMoney.From(150_00).Cents);
}
[Fact]
public void Zero_is_valid()
{
Assert.Equal(0, OrderMoney.From(0).Cents);
}
[Fact]
public void Negative_cents_are_rejected()
{
Assert.Throws<ArgumentOutOfRangeException>(() => OrderMoney.From(-1));
Assert.Throws<ArgumentOutOfRangeException>(() => _ = (OrderMoney)(-1)); // conversion validates too
}
[Fact]
public void Money_compares_by_value_not_reference()
{
Assert.Equal(OrderMoney.From(150_00), OrderMoney.From(150_00));
Assert.NotEqual(OrderMoney.From(150_00), OrderMoney.From(151_00));
}
[Fact]
public void Converting_back_to_cents_is_explicit()
{
var money = OrderMoney.From(150_00);
Assert.Equal(150_00, (int)money);
Assert.Equal(150_00, money.Cents);
}
[Fact]
public void ToString_shows_the_cents()
{
Assert.Equal("15000", OrderMoney.From(150_00).ToString());
}
}
+36
View File
@@ -0,0 +1,36 @@
using Domain;
namespace TestData;
/// <summary>Test data builder for a <see cref="Client"/> (TestDataBuilder pattern); <see cref="ValidClient"/> starts fresh — 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;
}
+33
View File
@@ -0,0 +1,33 @@
using Domain;
namespace TestData;
/// <summary>Test data builder for an <see cref="Order"/> (TestDataBuilder pattern); the only place tests touch an order's construction, so tests survive domain evolution.</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), wrapped in <see cref="OrderMoney"/>.</summary>
public OrderBuilder WithTotal(int totalInCents)
{
_order.TotalInCents = OrderMoney.From(totalInCents);
return this;
}
public Order Build() => _order;
}
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
</ItemGroup>
</Project>
+163
View File
@@ -0,0 +1,163 @@
using Domain;
using static TestData.ClientBuilder;
using static TestData.OrderBuilder;
namespace Web.Tests;
/// <summary>The hand-written <see cref="DtoMappers"/> map both ways, never mutate their input, and round-trip losslessly; domain objects come from the test-data builders, DTO initializers stay literal.</summary>
public class DtoMapperTests
{
// (1) Mappers map all scalar fields, in both directions.
[Fact]
public void ToClientDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var client = ValidClient().WithId(id).WithName("Acme").Build();
var dto = DtoMappers.ToClientDto(client);
Assert.Equal(id, dto.Id);
Assert.Equal("Acme", dto.Name);
}
[Fact]
public void ToOrderDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
var dto = DtoMappers.ToOrderDto(order);
Assert.Equal(id, dto.Id);
Assert.Equal("one", dto.Description);
Assert.Equal(150_00, dto.TotalInCents);
}
[Fact]
public void FromClientDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var dto = new ClientDto { Id = id, Name = "Acme" };
var client = DtoMappers.FromClientDto(dto);
Assert.Equal(id, client.Id);
Assert.Equal("Acme", client.Name);
}
[Fact]
public void FromOrderDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
var order = DtoMappers.FromOrderDto(dto);
Assert.Equal(id, order.Id);
Assert.Equal("one", order.Description);
Assert.Equal(150_00, order.TotalInCents.Cents);
}
// (2) Mappers map the relations, and the back-references stay consistent.
[Fact]
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
{
var order = ValidOrder().WithDescription("one").Build();
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
var dto = DtoMappers.ToClientDto(client);
var orderDto = Assert.Single(dto.Orders);
// The order is mapped with the client's data:
Assert.Equal(order.Id, orderDto.Id);
Assert.Equal(order.Description, orderDto.Description);
// The back-reference cycle is resolved to the SAME dto reference:
Assert.Same(dto, orderDto.ClientDto);
}
[Fact]
public void FromClientDto_wires_the_order_client_back_reference()
{
var orderDto = new OrderDto { Description = "one" };
var dto = new ClientDto { Name = "Acme", Orders = new[] { orderDto } };
var client = DtoMappers.FromClientDto(dto);
var order = Assert.Single(client.Orders);
Assert.Equal(orderDto.Description, order.Description);
// Navigation fix-up: the reconstructed order points at the client:
Assert.Same(client, order.Client);
}
// (3) Round-trips preserve all state (compared by values, not reference).
[Fact]
public void Domain_round_trip_preserves_all_state()
{
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
var roundTrip = DtoMappers.FromClientDto(DtoMappers.ToClientDto(client));
Assert.Equal(client.Id, roundTrip.Id);
Assert.Equal(client.Name, roundTrip.Name);
var roundTripOrder = Assert.Single(roundTrip.Orders);
Assert.Equal(order.Id, roundTripOrder.Id);
Assert.Equal(order.Description, roundTripOrder.Description);
Assert.Equal(order.TotalInCents, roundTripOrder.TotalInCents);
// The back-reference is still consistent on the reconstructed graph:
Assert.Same(roundTrip, roundTripOrder.Client);
}
[Fact]
public void Dto_round_trip_preserves_all_state()
{
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
var dto = DtoMappers.ToClientDto(client);
var dtoOrder = dto.Orders[0];
var roundTrip = DtoMappers.ToClientDto(DtoMappers.FromClientDto(dto));
Assert.Equal(dto.Id, roundTrip.Id);
Assert.Equal(dto.Name, roundTrip.Name);
var roundTripOrder = Assert.Single(roundTrip.Orders);
Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
Assert.Equal(dtoOrder.TotalInCents, roundTripOrder.TotalInCents);
Assert.Same(roundTrip, roundTripOrder.ClientDto);
}
// (4) Mappers do not mutate their inputs (snapshot fields, map, compare).
[Fact]
public void ToClientDto_does_not_mutate_the_domain()
{
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").Build();
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
var ordersCount = client.Orders.Count;
DtoMappers.ToClientDto(client);
DtoMappers.ToOrderDto(order);
Assert.Equal("Acme", client.Name);
Assert.Equal(ordersCount, client.Orders.Count);
Assert.Same(client, order.Client); // back-reference untouched
Assert.Equal("one", order.Description);
}
[Fact]
public void FromClientDto_does_not_mutate_the_dto()
{
var orderDto = new OrderDto { Description = "one" };
var dto = new ClientDto { Id = Guid.NewGuid(), Name = "Acme", Orders = new[] { orderDto } };
DtoMappers.FromClientDto(dto);
DtoMappers.FromOrderDto(orderDto);
Assert.Equal("Acme", dto.Name);
Assert.Same(orderDto, Assert.Single(dto.Orders)); // collection untouched
Assert.Null(orderDto.ClientDto); // back-reference untouched
Assert.Equal("one", orderDto.Description);
}
}
+30
View File
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Domain is referenced explicitly because these tests use domain
types directly (it also flows in transitively through Web).
Deliberately NO reference to Db — mirroring the Web layer. -->
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
<ProjectReference Include="..\..\src\Web\Web.csproj" />
<ProjectReference Include="..\TestData\TestData.csproj" />
</ItemGroup>
</Project>
+61
View File
@@ -0,0 +1,61 @@
using Domain;
using System.Reflection;
using static TestData.ClientBuilder;
using static TestData.OrderBuilder;
namespace Web.Tests;
/// <summary>The WebApp consumes only DTOs; persistence types are excluded at compile time (no Web → Db project reference).</summary>
public class WebAppTests
{
// The WebApp's public API references no domain type (reflection).
private static readonly Type[] BannedTypes = [typeof(Client), typeof(Order)];
private static IEnumerable<Type> TypeAndArguments(Type t)
{
yield return t;
if (t.IsGenericType)
foreach (var arg in t.GetGenericArguments())
foreach (var nested in TypeAndArguments(arg))
yield return nested;
if (t.IsArray && t.GetElementType() is { } elem)
foreach (var nested in TypeAndArguments(elem))
yield return nested;
}
[Fact]
public void WebApp_public_api_references_no_domain_types()
{
var app = typeof(WebApp);
var banned = BannedTypes.ToList();
foreach (var m in app.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
{
var signature = m.GetParameters()
.Select(p => p.ParameterType)
.Append(m.ReturnType)
.ToList();
foreach (var t in signature.SelectMany(TypeAndArguments))
Assert.False(banned.Contains(t),
$"WebApp.{m.Name}({string.Join(", ", signature)}) references banned type {t}");
}
foreach (var p in app.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
foreach (var t in TypeAndArguments(p.PropertyType))
Assert.False(banned.Contains(t),
$"WebApp.{p.Name} references banned type {t}");
}
[Fact]
public void WebApp_consumes_dtos()
{
var app = new WebApp();
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("order one").WithTotal(150_00).Build();
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
var view = app.ShowClient(DtoMappers.ToClientDto(client));
Assert.Contains("Acme", view);
Assert.Contains("order one", view);
}
}