From c22a1e2ffa2544e7f9a6ad4d81d5e725b319613d Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Fri, 11 Sep 2026 15:02:35 +0100 Subject: [PATCH] =?UTF-8?q?Yak:=20csharp=20after=20situation=20(Yak=2002)?= =?UTF-8?q?=20=E2=80=94=20plain=20domain=20+=20DTOs=20+=20mappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/After/Client.cs | 58 ++++ src/After/ClientDto.cs | 22 ++ src/After/DbBase.cs | 26 ++ src/After/DbContext.cs | 51 +++ src/After/Mappers.cs | 102 ++++++ src/After/Order.cs | 26 ++ src/After/OrderDto.cs | 21 ++ src/After/WebApp.cs | 25 ++ tests/BeforeAfter.Tests/AfterTests.cs | 312 ++++++++++++++++++ .../BeforeAfter.Tests.csproj | 1 + 10 files changed, 644 insertions(+) create mode 100644 src/After/Client.cs create mode 100644 src/After/ClientDto.cs create mode 100644 src/After/DbBase.cs create mode 100644 src/After/DbContext.cs create mode 100644 src/After/Mappers.cs create mode 100644 src/After/Order.cs create mode 100644 src/After/OrderDto.cs create mode 100644 src/After/WebApp.cs create mode 100644 tests/BeforeAfter.Tests/AfterTests.cs diff --git a/src/After/Client.cs b/src/After/Client.cs new file mode 100644 index 0000000..331d7ff --- /dev/null +++ b/src/After/Client.cs @@ -0,0 +1,58 @@ +using System.Collections.ObjectModel; + +namespace After; + +/// +/// A plain domain object — the "after" shape of the exercise: no +/// inheritance, no knowledge of . +/// Its data travels to the WebApp as a ; persisting it +/// is an explicit act of the context, not an inherited capability. +/// +public class Client +{ + /// + /// Primary key as a plain value: fresh objects have + /// until a explicitly + /// saves them (which copies a generated id in). A plain field — it is not + /// a persistence concept leaking into the domain. + /// + public Guid Id { get; set; } + + /// Display name of the client. + public string Name { get; set; } = string.Empty; + + /// + /// Navigation collection of this client's orders, with EF-style + /// navigation fix-up (see ). + /// + public ClientOrders Orders { get; } + + public Client() + { + Orders = new ClientOrders(this); + } +} + +/// +/// An collection that fakes EF's navigation fix-up: when an +/// order is added, its back-reference is set to the +/// owning client, exactly as EF would wire up the two ends of the relation. +/// +public sealed class ClientOrders : Collection +{ + 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); + } +} \ No newline at end of file diff --git a/src/After/ClientDto.cs b/src/After/ClientDto.cs new file mode 100644 index 0000000..59ee677 --- /dev/null +++ b/src/After/ClientDto.cs @@ -0,0 +1,22 @@ +namespace After; + +/// +/// DTO carrying a — including its relation — to the +/// WebApp. A record: value equality keeps round-trip comparisons trivial. +/// +public sealed record ClientDto +{ + /// Primary key of the underlying client. + public Guid Id { get; init; } + + /// Display name of the client. + public string Name { get; init; } = string.Empty; + + /// + /// The client's orders, each carrying a back-reference to this DTO (the + /// relation, mapped both ways). Settable — not just init — so + /// can fill it in a second pass once the + /// client/order cycle has been resolved by the reference cache. + /// + public IReadOnlyList Orders { get; set; } = Array.Empty(); +} \ No newline at end of file diff --git a/src/After/DbBase.cs b/src/After/DbBase.cs new file mode 100644 index 0000000..a3ec7aa --- /dev/null +++ b/src/After/DbBase.cs @@ -0,0 +1,26 @@ +namespace After; + +/// +/// 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 (, ) no longer derive +/// from it. Only the 's own internal shadow entities do, +/// so nothing outside the persistence layer can ever reach a +/// through a domain object. +/// +public class DbBase +{ + /// + /// Primary key. Fresh (unsaved) entities have ; + /// assigns a real id when it saves them, faking + /// EF's identity generation. + /// + public Guid Id { get; set; } + + /// + /// Back-reference to the owning . 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. + /// + public DbContext? DbContext { get; internal set; } +} \ No newline at end of file diff --git a/src/After/DbContext.cs b/src/After/DbContext.cs new file mode 100644 index 0000000..cbba171 --- /dev/null +++ b/src/After/DbContext.cs @@ -0,0 +1,51 @@ +namespace After; + +/// +/// The fake DbContext 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 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. +/// +public class DbContext +{ + // Change tracker, keyed by the domain object's reference identity. + private readonly Dictionary _tracked = new(); + + /// + /// Explicitly persist a : register it with a shadow + /// entity, assign it a fresh id, and copy that id into + /// the client. Saving an already-saved client is a no-op for its id. + /// + 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(); + } + + /// + /// Explicitly persist an : register it with a shadow + /// entity, assign it a fresh id, and copy that id into + /// the order. Saving an already-saved order is a no-op for its id. + /// + 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(); + } + + /// True if has been saved through this context. + public bool IsTracked(object entity) => _tracked.ContainsKey(entity); + + /// Read-only view of the shadow entities this context currently tracks. + public IReadOnlyList Tracked => _tracked.Values.ToList(); +} \ No newline at end of file diff --git a/src/After/Mappers.cs b/src/After/Mappers.cs new file mode 100644 index 0000000..3da98d8 --- /dev/null +++ b/src/After/Mappers.cs @@ -0,0 +1,102 @@ +namespace After; + +/// +/// 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 Client.Orders / Order.Client 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. +/// +public static class Mappers +{ + /// Map a (with its orders) to a . + public static ClientDto ToClientDto(Client client) + { + ArgumentNullException.ThrowIfNull(client); + return MapClient(client, new Dictionary()); + } + + /// Map an (with its client back-reference) to an . + public static OrderDto ToOrderDto(Order order) + { + ArgumentNullException.ThrowIfNull(order); + return MapOrder(order, new Dictionary()); + } + + /// Map a (with its orders) back to a . + public static Client FromClientDto(ClientDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + return UnmapClient(dto, new Dictionary()); + } + + /// Map an (with its client back-reference) back to an . + public static Order FromOrderDto(OrderDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + return UnmapOrder(dto, new Dictionary()); + } + + // ------------------------------------------------------------------ + // Domain -> DTO + // ------------------------------------------------------------------ + private static ClientDto MapClient(Client client, Dictionary 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 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 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 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; + } +} \ No newline at end of file diff --git a/src/After/Order.cs b/src/After/Order.cs new file mode 100644 index 0000000..57c29c5 --- /dev/null +++ b/src/After/Order.cs @@ -0,0 +1,26 @@ +namespace After; + +/// +/// An order as a plain domain object: no inheritance, no +/// knowledge of . Its data travels to the WebApp as an +/// . +/// +public class Order +{ + /// + /// Primary key as a plain value: until a + /// explicitly saves this order (which copies a + /// generated id in). + /// + public Guid Id { get; set; } + + /// Free-text description of what this order is for. + public string Description { get; set; } = string.Empty; + + /// + /// The client that owns this order. Set automatically by + /// when the order is added to + /// (navigation fix-up). + /// + public Client? Client { get; set; } +} \ No newline at end of file diff --git a/src/After/OrderDto.cs b/src/After/OrderDto.cs new file mode 100644 index 0000000..1fd53c6 --- /dev/null +++ b/src/After/OrderDto.cs @@ -0,0 +1,21 @@ +namespace After; + +/// +/// DTO carrying an — including its back-reference to the +/// owning — to the WebApp. A record: value equality +/// keeps round-trip comparisons trivial. +/// +public sealed record OrderDto +{ + /// Primary key of the underlying order. + public Guid Id { get; init; } + + /// Free-text description of what this order is for. + public string Description { get; init; } = string.Empty; + + /// + /// Back-reference to the owning client, or null for a bare order + /// that has not been added to any client. + /// + public ClientDto? ClientDto { get; init; } +} \ No newline at end of file diff --git a/src/After/WebApp.cs b/src/After/WebApp.cs new file mode 100644 index 0000000..680eb7b --- /dev/null +++ b/src/After/WebApp.cs @@ -0,0 +1,25 @@ +namespace After; + +/// +/// The consuming application, after situation: it consumes only DTOs +/// (/). 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. +/// +public class WebApp +{ + /// + /// Render a client together with all of its orders, consuming the + /// (and its nested )s only. + /// + public string ShowClient(ClientDto client) + { + ArgumentNullException.ThrowIfNull(client); + + var lines = new List { $"{client.Name} [{client.Id}]" }; + foreach (var order in client.Orders) + lines.Add($" - order [{order.Id}]: {order.Description}"); + return string.Join(Environment.NewLine, lines); + } +} \ No newline at end of file diff --git a/tests/BeforeAfter.Tests/AfterTests.cs b/tests/BeforeAfter.Tests/AfterTests.cs new file mode 100644 index 0000000..5e6cfa5 --- /dev/null +++ b/tests/BeforeAfter.Tests/AfterTests.cs @@ -0,0 +1,312 @@ +using After; +using System.Reflection; + +namespace BeforeAfter.Tests; + +/// +/// Tests for the AFTER situation (Yak 02): plain domain objects (no +/// ), DTOs (records) carrying the data including the +/// relations, hand-written mappers, and a WebApp that consumes only DTOs. +/// +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 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 + } +} \ No newline at end of file diff --git a/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj b/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj index c4ab542..b33b17e 100644 --- a/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj +++ b/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj @@ -20,6 +20,7 @@ + \ No newline at end of file