diff --git a/db-subclass-to-dto.sln b/db-subclass-to-dto.sln new file mode 100644 index 0000000..ed19c24 --- /dev/null +++ b/db-subclass-to-dto.sln @@ -0,0 +1,71 @@ + +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}") = "Before", "src\Before\Before.csproj", "{2FF256B4-B085-449E-95AD-B9E6202DA94A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "After", "src\After\After.csproj", "{E65C0410-A863-44B0-88A4-6C74EF929419}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeforeAfter.Tests", "tests\BeforeAfter.Tests\BeforeAfter.Tests.csproj", "{F5E0C182-CBC3-4440-AF8F-32887F9B589C}" +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 + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x64.ActiveCfg = Debug|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x64.Build.0 = Debug|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x86.ActiveCfg = Debug|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x86.Build.0 = Debug|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|Any CPU.Build.0 = Release|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x64.ActiveCfg = Release|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x64.Build.0 = Release|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x86.ActiveCfg = Release|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x86.Build.0 = Release|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x64.ActiveCfg = Debug|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x64.Build.0 = Debug|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x86.ActiveCfg = Debug|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x86.Build.0 = Debug|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Release|Any CPU.Build.0 = Release|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x64.ActiveCfg = Release|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x64.Build.0 = Release|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x86.ActiveCfg = Release|Any CPU + {E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x86.Build.0 = Release|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x64.Build.0 = Debug|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x86.Build.0 = Debug|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|Any CPU.Build.0 = Release|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x64.ActiveCfg = Release|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x64.Build.0 = Release|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.ActiveCfg = Release|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2FF256B4-B085-449E-95AD-B9E6202DA94A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E65C0410-A863-44B0-88A4-6C74EF929419} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F5E0C182-CBC3-4440-AF8F-32887F9B589C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/src/After/After.csproj b/src/After/After.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/After/After.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Before/Before.csproj b/src/Before/Before.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/Before/Before.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Before/Client.cs b/src/Before/Client.cs new file mode 100644 index 0000000..48c9e90 --- /dev/null +++ b/src/Before/Client.cs @@ -0,0 +1,50 @@ +using System.Collections.ObjectModel; + +namespace Before; + +/// +/// A client, backed by the database: it derives from and +/// holds its orders as a navigation collection. In the "after" situation this +/// becomes a plain POCO (no ) with a ClientDto +/// carrying its data to the WebApp. +/// +public class Client : DbBase +{ + /// Display name of the client. + public string Name { get; set; } = string.Empty; + + /// + /// Navigation collection of this client's orders. Adding to it performs + /// 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); + } +} diff --git a/src/Before/DbBase.cs b/src/Before/DbBase.cs new file mode 100644 index 0000000..d6b97b0 --- /dev/null +++ b/src/Before/DbBase.cs @@ -0,0 +1,29 @@ +namespace Before; + +/// +/// Base class for every DB-backed domain object. Fakes the active-record part +/// of EF: each entity carries its own and, once it has been +/// created/saved through a context, a back-reference to that context. +/// +/// 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. +/// +public class DbBase +{ + /// + /// Primary key. Fresh (unsaved) entities have ; + /// assigns a real id, faking EF's identity + /// generation. + /// + public Guid Id { get; set; } + + /// + /// 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. internal set because + /// only the owning may (re)assign it; consumers + /// (including the WebApp, and the tests) can only read it. + /// + public DbContext? DbContext { get; internal set; } +} \ No newline at end of file diff --git a/src/Before/DbContext.cs b/src/Before/DbContext.cs new file mode 100644 index 0000000..e11a3a3 --- /dev/null +++ b/src/Before/DbContext.cs @@ -0,0 +1,56 @@ +namespace Before; + +/// +/// A tiny hand-rolled fake of EF's DbContext: it keeps a change-tracker +/// (a registration collection of the entities it knows +/// about) and a that fakes SaveChangesAsync. +/// +/// 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. +/// +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 _tracked = new(); + + /// + /// Attach an entity to this context (EF's Add). Sets the + /// active-record back-reference so the entity knows its owner. + /// + public void Attach(DbBase entity) + { + ArgumentNullException.ThrowIfNull(entity); + entity.DbContext = this; + if (!_tracked.Contains(entity)) + _tracked.Add(entity); + } + + /// + /// Fake SaveChangesAsync: walk the tracked entities and assign a + /// fresh to any whose id is still . + /// Returns the number of entities that were (re)saved — i.e. newly + /// identified — mirroring EF's "rows written" return value. + /// + public int Save() + { + var saved = 0; + foreach (var entity in _tracked) + { + if (entity.Id == Guid.Empty) + { + entity.Id = Guid.NewGuid(); + saved++; + } + } + return saved; + } + + /// True if is in this context's tracker. + public bool IsTracked(DbBase entity) => _tracked.Contains(entity); + + /// Read-only view of the entities this context currently tracks. + public IReadOnlyList Tracked => _tracked; +} diff --git a/src/Before/Order.cs b/src/Before/Order.cs new file mode 100644 index 0000000..721d994 --- /dev/null +++ b/src/Before/Order.cs @@ -0,0 +1,20 @@ +namespace Before; + +/// +/// An order, backed by the database: it derives from and +/// holds a back-reference to its (the other end of the +/// relation). In the "after" situation this becomes a plain POCO with an +/// OrderDto carrying its data to the WebApp. +/// +public class Order : DbBase +{ + /// 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; } +} diff --git a/src/Before/WebApp.cs b/src/Before/WebApp.cs new file mode 100644 index 0000000..a325724 --- /dev/null +++ b/src/Before/WebApp.cs @@ -0,0 +1,40 @@ +namespace Before; + +/// +/// The consuming application. In the "before" situation it talks to the +/// DB-backed domain model directly: its methods take and +/// (which inherit ) as parameters. +/// +/// One of its methods reaches the *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. +/// +public class WebApp +{ + /// + /// Render a client together with all of its orders, consuming the + /// / domain objects directly. + /// + public string ShowClient(Client 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); + } + + /// + /// The leak, made concrete: from a plain domain object the WebApp can reach + /// the () and thus + /// touch the persistence layer — here just to ask whether the client has + /// been saved. The "after" situation removes client.DbContext + /// entirely, so no DTO-consumer can do this. + /// + public bool IsPersisted(Client client) + { + ArgumentNullException.ThrowIfNull(client); + return client.DbContext is not null; + } +} diff --git a/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj b/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj new file mode 100644 index 0000000..c4ab542 --- /dev/null +++ b/tests/BeforeAfter.Tests/BeforeAfter.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/BeforeAfter.Tests/BeforeTests.cs b/tests/BeforeAfter.Tests/BeforeTests.cs new file mode 100644 index 0000000..0aace98 --- /dev/null +++ b/tests/BeforeAfter.Tests/BeforeTests.cs @@ -0,0 +1,145 @@ +using Before; + +namespace BeforeAfter.Tests; + +/// +/// Tests for the BEFORE situation (Yak 01): DB-backed domain classes that +/// inherit and leak their . +/// +public class BeforeTests +{ + // --------------------------------------------------------------------- + // (1) Inheritance: the domain classes ARE DB-backed (they derive from + // DbBase). This is the "before" shape Yak 02 will undo. + // --------------------------------------------------------------------- + [Fact] + public void Client_and_Order_derive_from_DbBase() + { + Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Client))); + Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Order))); + } + + // --------------------------------------------------------------------- + // (2) Fresh entities are unsaved: their Id is still Guid.Empty. + // --------------------------------------------------------------------- + [Theory] + [InlineData(typeof(Client))] + [InlineData(typeof(Order))] + public void New_entities_have_an_empty_id(Type type) + { + var entity = (DbBase)Activator.CreateInstance(type)!; + Assert.Equal(Guid.Empty, entity.Id); + } + + // --------------------------------------------------------------------- + // (3) Save() fakes EF's SaveChanges: it assigns fresh, distinct Guids to + // entities that do not have one yet. + // --------------------------------------------------------------------- + [Fact] + public void Save_assigns_fresh_distinct_ids() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme" }; + var globex = new Client { Name = "Globex" }; + db.Attach(acme); + db.Attach(globex); + + Assert.Equal(2, db.Save()); + + Assert.NotEqual(Guid.Empty, acme.Id); + Assert.NotEqual(Guid.Empty, globex.Id); + Assert.NotEqual(acme.Id, globex.Id); + } + + // --------------------------------------------------------------------- + // (4) A saved entity is registered with its context, reachable via the + // active-record back-reference. + // --------------------------------------------------------------------- + [Fact] + public void Saved_entity_is_registered_with_its_context() + { + var db = new DbContext(); + var client = new Client { Name = "Acme" }; + db.Attach(client); + db.Save(); + + Assert.Same(db, client.DbContext); + Assert.True(db.IsTracked(client)); + } + + // --------------------------------------------------------------------- + // (5) Navigation fix-up: adding an order to client.Orders wires the + // order.Client back-reference. + // --------------------------------------------------------------------- + [Fact] + public void Adding_an_order_sets_the_client_back_reference() + { + var client = new Client { Name = "Acme" }; + var order = new Order { Description = "first order" }; + + client.Orders.Add(order); + + Assert.Same(client, order.Client); + Assert.Same(order, client.Orders[0]); + Assert.Single(client.Orders); + } + + // --------------------------------------------------------------------- + // (6) THE LEAK: from a plain Client you can reach its DbContext. + // This is exactly what the "after" situation removes: a ClientDto has + // no DbContext to reach, so a consumer can never touch the persistence + // layer through it. + // --------------------------------------------------------------------- + [Fact] + public void A_client_can_reach_its_DbContext__the_leak() + { + var db = new DbContext(); + var client = new Client { Name = "Acme" }; + db.Attach(client); + db.Save(); + + // Reaching the persistence layer *through* the domain object: + Assert.NotNull(client.DbContext); + Assert.Same(db, client.DbContext); + Assert.True(client.DbContext!.IsTracked(client)); + } + + // --------------------------------------------------------------------- + // (7) The WebApp consumes the domain classes directly — including a method + // that rides the leak. + // --------------------------------------------------------------------- + [Fact] + public void WebApp_works_on_the_domain_classes_directly() + { + var app = new WebApp(); + var db = new DbContext(); + + var client = new Client { Name = "Acme" }; + var order = new Order { Description = "order one" }; + client.Orders.Add(order); + db.Attach(client); + db.Attach(order); + db.Save(); + + // ShowClient consumes Client and its Orders directly: + var view = app.ShowClient(client); + Assert.Contains("Acme", view); + Assert.Contains("order one", view); + + // IsPersisted rides the leak (client.DbContext) — and it is true here + // because the client was saved through the context: + Assert.True(app.IsPersisted(client)); + } + + // A companion check: a client that was never attached/saved exposes no + // context yet, so the leak is dormant until the entity meets a context. + [Fact] + public void A_bare_client_has_no_context_until_saved() + { + var app = new WebApp(); + var client = new Client { Name = "Nobody" }; + + Assert.Null(client.DbContext); + Assert.False(app.IsPersisted(client)); + } +} \ No newline at end of file