Yak: csharp after situation (Yak 02) — plain domain + DTOs + mappers

After/: plain Client/Order (no DbBase), ClientDto/OrderDto records
carrying the relations both ways, hand-written Mappers with a
reference-identity cache for the back-reference cycle, DbContext with
explicit Save(domain) via shadow entities (flipped dependency), and a
WebApp that consumes only DTOs. AfterTests cover the 7 required
behaviours; full solution builds with 0 warnings, 26/26 tests pass.
This commit is contained in:
2026-09-11 15:02:35 +01:00
parent 463245288a
commit c22a1e2ffa
10 changed files with 644 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.ObjectModel;
namespace After;
/// <summary>
/// A plain domain object — the "after" shape of the exercise: no
/// <see cref="DbBase"/> inheritance, no knowledge of <see cref="DbContext"/>.
/// Its data travels to the WebApp as a <see cref="ClientDto"/>; persisting it
/// is an explicit act of the context, not an inherited capability.
/// </summary>
public class Client
{
/// <summary>
/// Primary key as a plain value: fresh objects have
/// <see cref="Guid.Empty"/> until a <see cref="DbContext"/> explicitly
/// saves them (which copies a generated id in). A plain field — it is not
/// a persistence concept leaking into the domain.
/// </summary>
public Guid Id { get; set; }
/// <summary>Display name of the client.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Navigation collection of this client's orders, with EF-style
/// navigation fix-up (see <see cref="ClientOrders"/>).
/// </summary>
public ClientOrders Orders { get; }
public Client()
{
Orders = new ClientOrders(this);
}
}
/// <summary>
/// An <see cref="Order"/> collection that fakes EF's navigation fix-up: when an
/// order is added, its <see cref="Order.Client"/> back-reference is set to the
/// owning client, exactly as EF would wire up the two ends of the relation.
/// </summary>
public sealed class ClientOrders : Collection<Order>
{
private readonly Client _owner;
public ClientOrders(Client owner)
{
_owner = owner;
}
// Both Add(...) and Insert(...) funnel through InsertItem, so overriding
// it covers every way an order can be added to the collection.
protected override void InsertItem(int index, Order item)
{
ArgumentNullException.ThrowIfNull(item);
item.Client = _owner; // navigation fix-up: order now knows its client
base.InsertItem(index, item);
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace After;
/// <summary>
/// DTO carrying a <see cref="Client"/> — including its relation — to the
/// WebApp. A 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 (the
/// relation, mapped both ways). Settable — not just <c>init</c> — so
/// <see cref="Mappers"/> can fill it in a second pass once the
/// client/order cycle has been resolved by the reference cache.
/// </summary>
public IReadOnlyList<OrderDto> Orders { get; set; } = Array.Empty<OrderDto>();
}
+26
View File
@@ -0,0 +1,26 @@
namespace After;
/// <summary>
/// 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:
/// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive
/// from it. Only the <see cref="DbContext"/>'s own internal shadow entities do,
/// so nothing outside the persistence layer can ever reach a
/// <see cref="DbContext"/> through a domain object.
/// </summary>
public class DbBase
{
/// <summary>
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
/// <see cref="DbContext"/> assigns a real id when it saves them, faking
/// EF's identity generation.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Back-reference to the owning <see cref="DbContext"/>. In the after
/// situation this lives only on the context's internal shadow entities —
/// the plain domain objects that used to inherit it no longer do.
/// </summary>
public DbContext? DbContext { get; internal set; }
}
+51
View File
@@ -0,0 +1,51 @@
namespace After;
/// <summary>
/// The fake <c>DbContext</c> of the after situation. The persistence layer
/// still exists — but the direction of the dependency is flipped: domain
/// objects are plain POCOs that know nothing about a context, and persisting
/// one requires an *explicit* call into this context.
///
/// Saving a domain object registers it with a private shadow entity
/// (a <see cref="DbBase"/> owned by this context only), fakes identity
/// generation, and copies the new id back into the domain object. The domain
/// object therefore ends up with a saved id, but with no back-reference to
/// the context — the leak from the before situation is gone.
/// </summary>
public class DbContext
{
// Change tracker, keyed by the domain object's reference identity.
private readonly Dictionary<object, DbBase> _tracked = new();
/// <summary>
/// Explicitly persist a <see cref="Client"/>: register it with a shadow
/// entity, assign it a fresh <see cref="Guid"/> id, and copy that id into
/// the client. Saving an already-saved client is a no-op for its id.
/// </summary>
public void Save(Client client)
{
ArgumentNullException.ThrowIfNull(client);
if (!_tracked.TryAdd(client, new DbBase { DbContext = this }))
return; // already saved
client.Id = _tracked[client].Id = Guid.NewGuid();
}
/// <summary>
/// Explicitly persist an <see cref="Order"/>: register it with a shadow
/// entity, assign it a fresh <see cref="Guid"/> id, and copy that id into
/// the order. Saving an already-saved order is a no-op for its id.
/// </summary>
public void Save(Order order)
{
ArgumentNullException.ThrowIfNull(order);
if (!_tracked.TryAdd(order, new DbBase { DbContext = this }))
return; // already saved
order.Id = _tracked[order].Id = Guid.NewGuid();
}
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
public bool IsTracked(object entity) => _tracked.ContainsKey(entity);
/// <summary>Read-only view of the shadow entities this context currently tracks.</summary>
public IReadOnlyList<DbBase> Tracked => _tracked.Values.ToList();
}
+102
View File
@@ -0,0 +1,102 @@
namespace After;
/// <summary>
/// Hand-written mappers between the plain domain and the DTOs (no AutoMapper —
/// the mapping code is part of the illustration). Each public method maps in
/// exactly one direction and never mutates its input.
///
/// 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
/// same DTO (and back), so the graph terminates and stays consistent.
/// </summary>
public static class Mappers
{
/// <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>());
}
// ------------------------------------------------------------------
// Domain -> DTO
// ------------------------------------------------------------------
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,
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
};
seen[order] = dto;
return dto;
}
// ------------------------------------------------------------------
// DTO -> domain
// ------------------------------------------------------------------
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,
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
};
seen[dto] = order;
return order;
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace After;
/// <summary>
/// An order as a plain domain object: no <see cref="DbBase"/> inheritance, no
/// knowledge of <see cref="DbContext"/>. Its data travels to the WebApp as an
/// <see cref="OrderDto"/>.
/// </summary>
public class Order
{
/// <summary>
/// Primary key as a plain value: <see cref="Guid.Empty"/> until a
/// <see cref="DbContext"/> explicitly saves this order (which copies a
/// generated id in).
/// </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>
/// The client that owns this order. Set automatically by
/// <see cref="ClientOrders"/> when the order is added to
/// <see cref="Client.Orders"/> (navigation fix-up).
/// </summary>
public Client? Client { get; set; }
}
+21
View File
@@ -0,0 +1,21 @@
namespace After;
/// <summary>
/// DTO carrying an <see cref="Order"/> — including its back-reference to the
/// owning <see cref="ClientDto"/> — to the WebApp. A record: value equality
/// keeps round-trip comparisons trivial.
/// </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>
/// Back-reference to the owning client, or <c>null</c> for a bare order
/// that has not been added to any client.
/// </summary>
public ClientDto? ClientDto { get; init; }
}
+25
View File
@@ -0,0 +1,25 @@
namespace After;
/// <summary>
/// The consuming application, after situation: it consumes only DTOs
/// (<see cref="ClientDto"/>/<see cref="OrderDto"/>). Its public API references
/// no domain type (Client/Order) and no persistence type
/// (DbContext/DbBase), so the WebApp can never reach the persistence layer
/// through them — the leak the before situation exposed is gone.
/// </summary>
public class WebApp
{
/// <summary>
/// Render a client together with all of its orders, consuming the
/// <see cref="ClientDto"/> (and its nested <see cref="OrderDto"/>)s 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);
}
}
+312
View File
@@ -0,0 +1,312 @@
using After;
using System.Reflection;
namespace BeforeAfter.Tests;
/// <summary>
/// Tests for the AFTER situation (Yak 02): plain domain objects (no
/// <see cref="DbBase"/>), DTOs (records) carrying the data including the
/// relations, hand-written mappers, and a WebApp that consumes only DTOs.
/// </summary>
public class AfterTests
{
private const BindingFlags AllMembers =
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
// ---------------------------------------------------------------------
// (1) The leak is gone: Client/Order do NOT derive from DbBase and expose
// no DbContext-typed property or field (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");
}
// ---------------------------------------------------------------------
// (2) Mappers map all scalar fields, in both directions.
// ---------------------------------------------------------------------
[Fact]
public void ToClientDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var client = new Client { Id = id, Name = "Acme" };
var dto = Mappers.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 = new Order { Id = id, Description = "one" };
var dto = Mappers.ToOrderDto(order);
Assert.Equal(id, dto.Id);
Assert.Equal("one", dto.Description);
}
[Fact]
public void FromClientDto_maps_all_scalar_fields()
{
var id = Guid.NewGuid();
var dto = new ClientDto { Id = id, Name = "Acme" };
var client = Mappers.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" };
var order = Mappers.FromOrderDto(dto);
Assert.Equal(id, order.Id);
Assert.Equal("one", order.Description);
}
// ---------------------------------------------------------------------
// (3) Mappers map the relations: Client.Orders -> ClientDto.Orders, and the
// Order.Client / OrderDto.ClientDto back-references stay consistent.
// ---------------------------------------------------------------------
[Fact]
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
{
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one" };
client.Orders.Add(order);
var dto = Mappers.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 = Mappers.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);
}
// ---------------------------------------------------------------------
// (4) Round-trips preserve all state (compared by values, not reference).
// ---------------------------------------------------------------------
[Fact]
public void Domain_round_trip_preserves_all_state()
{
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
client.Orders.Add(order);
var roundTrip = Mappers.FromClientDto(Mappers.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);
// 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 client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
client.Orders.Add(order);
var dto = Mappers.ToClientDto(client);
var dtoOrder = dto.Orders[0];
var roundTrip = Mappers.ToClientDto(Mappers.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.Same(roundTrip, roundTripOrder.ClientDto);
}
// ---------------------------------------------------------------------
// (5) Mappers do not mutate their inputs (snapshot fields, map, compare).
// ---------------------------------------------------------------------
[Fact]
public void ToClientDto_does_not_mutate_the_domain()
{
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
client.Orders.Add(order);
var ordersCount = client.Orders.Count;
Mappers.ToClientDto(client);
Mappers.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 } };
Mappers.FromClientDto(dto);
Mappers.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);
}
// ---------------------------------------------------------------------
// (6) The after WebApp consumes only DTOs: its public API references no
// domain (Client/Order) or persistence (DbContext/DbBase) type.
// ---------------------------------------------------------------------
private static readonly Type[] BannedTypes =
[typeof(Client), typeof(Order), typeof(DbContext), typeof(DbBase)];
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 After_WebApp_public_api_references_no_domain_or_db_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 After_WebApp_consumes_dtos()
{
var app = new WebApp();
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
var order = new Order { Id = Guid.NewGuid(), Description = "order one" };
client.Orders.Add(order);
var view = app.ShowClient(Mappers.ToClientDto(client));
Assert.Contains("Acme", view);
Assert.Contains("order one", view);
}
// ---------------------------------------------------------------------
// (7) Persistence still works through the fake DbContext in the after
// world: explicit Save, distinct ids, tracking — and even a saved
// domain object keeps no back-reference to the context.
// ---------------------------------------------------------------------
[Fact]
public void DbContext_saves_plain_domain_objects_and_tracks_them()
{
var db = new DbContext();
var client = new Client { Name = "Acme" };
var order = new Order { Description = "one" };
client.Orders.Add(order);
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 = new Client { Name = "Acme" };
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 = new Client { Name = "Acme" };
db.Save(client);
// Saved (the id was assigned by the context) ...
Assert.NotEqual(Guid.Empty, client.Id);
Assert.True(db.IsTracked(client));
// ... but there is no way back to the context through the domain
// object: no DbContext-typed member exists on it (test 1), so the
// only "receipt" is the plain Id value.
var dto = Mappers.ToClientDto(client);
Assert.Equal(client.Id, dto.Id); // the id flows on into the DTO
}
}
@@ -20,6 +20,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Before\Before.csproj" /> <ProjectReference Include="..\..\src\Before\Before.csproj" />
<ProjectReference Include="..\..\src\After\After.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>