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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user