Yak: csharp before situation (Yak 01) — DB-backed domain classes

Add the C# BEFORE situation: a fake-EF DbContext + DbBase that Client/Order
inherit, the active-record DbContext back-reference (the dependency leak),
navigation fix-up on Client.Orders, and a WebApp that consumes the domain
classes directly. Adds an empty After project so the solution shape is final,
and an xUnit BeforeTests suite covering the required assertions.

- db-subclass-to-dto.sln (classic .sln; SDK 10 defaults to .slnx which breaks run-tests.sh)
- src/Before: DbBase, DbContext, Client (+ClientOrders fix-up), Order, WebApp
- src/After: empty placeholder (filled in Yak 02)
- tests/BeforeAfter.Tests/BeforeTests.cs: 8 tests (7 required + 1 companion)
This commit is contained in:
2026-09-11 13:03:09 +01:00
parent 7e38f67e0b
commit 463245288a
10 changed files with 454 additions and 0 deletions
+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>
+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>
+50
View File
@@ -0,0 +1,50 @@
using System.Collections.ObjectModel;
namespace Before;
/// <summary>
/// A client, backed by the database: it derives from <see cref="DbBase"/> and
/// holds its orders as a navigation collection. In the "after" situation this
/// becomes a plain POCO (no <see cref="DbBase"/>) with a <c>ClientDto</c>
/// carrying its data to the WebApp.
/// </summary>
public class Client : DbBase
{
/// <summary>Display name of the client.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Navigation collection of this client's orders. Adding to it performs
/// 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);
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace Before;
/// <summary>
/// Base class for every DB-backed domain object. Fakes the active-record part
/// of EF: each entity carries its own <see cref="Id"/> and, once it has been
/// created/saved through a context, a back-reference to that context.
///
/// <see cref="DbContext"/> is the dependency this base class leaks. The
/// "after" situation (Yak 02) removes this inheritance entirely — domain
/// objects become plain POCOs that know nothing about a DbContext.
/// </summary>
public class DbBase
{
/// <summary>
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
/// <see cref="DbContext.Save"/> assigns a real id, faking EF's identity
/// generation.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Active-record back-reference: "I know which context created me". This
/// is the leak the exercise exposes — from a plain domain object you can
/// reach straight into the persistence layer. <c>internal set</c> because
/// only the owning <see cref="DbContext"/> may (re)assign it; consumers
/// (including the WebApp, and the tests) can only read it.
/// </summary>
public DbContext? DbContext { get; internal set; }
}
+56
View File
@@ -0,0 +1,56 @@
namespace Before;
/// <summary>
/// A tiny hand-rolled fake of EF's <c>DbContext</c>: it keeps a change-tracker
/// (a registration collection of the <see cref="DbBase"/> entities it knows
/// about) and a <see cref="Save"/> that fakes <c>SaveChangesAsync</c>.
///
/// This is deliberately NOT real EF Core — no providers, no SQLite, no NuGet
/// beyond xUnit. The point of the exercise is the *shape of the dependencies*
/// (domain object -> DbContext), not EF's behaviour.
/// </summary>
public class DbContext
{
// The change tracker: the set of entities this context is responsible for.
// (EF calls this its change tracker; a list stands in for the
// id -> entity registration dictionary.)
private readonly List<DbBase> _tracked = new();
/// <summary>
/// Attach an entity to this context (EF's <c>Add</c>). Sets the
/// active-record back-reference so the entity knows its owner.
/// </summary>
public void Attach(DbBase entity)
{
ArgumentNullException.ThrowIfNull(entity);
entity.DbContext = this;
if (!_tracked.Contains(entity))
_tracked.Add(entity);
}
/// <summary>
/// Fake <c>SaveChangesAsync</c>: walk the tracked entities and assign a
/// fresh <see cref="Guid"/> to any whose id is still <see cref="Guid.Empty"/>.
/// Returns the number of entities that were (re)saved — i.e. newly
/// identified — mirroring EF's "rows written" return value.
/// </summary>
public int Save()
{
var saved = 0;
foreach (var entity in _tracked)
{
if (entity.Id == Guid.Empty)
{
entity.Id = Guid.NewGuid();
saved++;
}
}
return saved;
}
/// <summary>True if <paramref name="entity"/> is in this context's tracker.</summary>
public bool IsTracked(DbBase entity) => _tracked.Contains(entity);
/// <summary>Read-only view of the entities this context currently tracks.</summary>
public IReadOnlyList<DbBase> Tracked => _tracked;
}
+20
View File
@@ -0,0 +1,20 @@
namespace Before;
/// <summary>
/// An order, backed by the database: it derives from <see cref="DbBase"/> and
/// holds a back-reference to its <see cref="Client"/> (the other end of the
/// relation). In the "after" situation this becomes a plain POCO with an
/// <c>OrderDto</c> carrying its data to the WebApp.
/// </summary>
public class Order : DbBase
{
/// <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; }
}
+40
View File
@@ -0,0 +1,40 @@
namespace Before;
/// <summary>
/// The consuming application. In the "before" situation it talks to the
/// DB-backed domain model directly: its methods take <see cref="Client"/> and
/// <see cref="Order"/> (which inherit <see cref="DbBase"/>) as parameters.
///
/// One of its methods reaches the <see cref="DbContext"/> *through* a domain
/// object — the leak. In the "after" situation (Yak 02) the WebApp consumes
/// DTOs instead, and the domain objects no longer expose a DbContext.
/// </summary>
public class WebApp
{
/// <summary>
/// Render a client together with all of its orders, consuming the
/// <see cref="Client"/>/ <see cref="Order"/> domain objects directly.
/// </summary>
public string ShowClient(Client 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);
}
/// <summary>
/// The leak, made concrete: from a plain domain object the WebApp can reach
/// the <see cref="DbContext"/> (<see cref="DbBase.DbContext"/>) and thus
/// touch the persistence layer — here just to ask whether the client has
/// been saved. The "after" situation removes <c>client.DbContext</c>
/// entirely, so no DTO-consumer can do this.
/// </summary>
public bool IsPersisted(Client client)
{
ArgumentNullException.ThrowIfNull(client);
return client.DbContext is not null;
}
}