Architecture & Boundaries · Lesson 6

Repository and unit-of-work tradeoffs

Model an explicit staged session without inventing EF Core behavior.

External .NET 10 SDK; five real projects with explicit ProjectReference items. Not executed by the single-file .NET 9 playground.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The architectural boundary

Repository and unit-of-work abstractions should express useful persistence semantics rather than blindly wrap every operation of a chosen ORM. The custom INoteSession in this example distinguishes staging from committed reads, and its memory implementation makes that local contract observable. It is not EF Core DbContext, a fake EF namespace, or a durable transaction. EF Core already provides tracking and unit-of-work behavior through DbContext, so an additional abstraction needs a concrete reason such as a domain-oriented boundary or a specific testing seam. The right tradeoff depends on application needs, not a rule that every service requires another generic repository.

Walk through the portable program

WriteNote validates that the supplied text is nonblank and stages one immutable Note containing a. The memory session holds staged notes separately from its committed list. Reading committed data before Commit therefore yields zero items. Commit appends the pending notes and clears that pending buffer; the next committed read has one item. Output is before:0 and after:1. ReadCommitted returns an array snapshot of immutable records rather than exposing the writable internal List. These observations describe the custom session's explicit local semantics and must not be generalized to database isolation or rollback.

Build real physical dependencies

In the complete external solution, Note belongs to Domain, the session contract and write operation belong to Application, and MemoryNoteSession belongs to Infrastructure. A production adapter could fulfill a deliberately compatible contract, but it would need real integration checks for persistence behavior. The external reference checker confirms that application code depends on the port rather than the concrete memory class. It does not verify SQL translation, database transactions, concurrency, or EF change tracking. Adding a genuine EF adapter would require its actual package, configuration, provider, and database setup rather than a class with a misleading framework-like name.

Pitfalls and unproved guarantees

Do not call committed.AddRange a durable transaction. An in-process failure, restart, or concurrent access has not been modeled, and there is no rollback operation. EF Core DbContext has a documented lifetime and is not thread-safe; sharing one across unrelated concurrent operations is not solved by hiding it behind an interface. A generic repository can also obscure query capabilities or duplicate DbContext without adding useful policy. Conversely, a purposeful aggregate-oriented repository can reduce inappropriate persistence coupling. Evaluate those costs and guarantees explicitly rather than deciding from a pattern name alone.

Practice one: isolate behavior

First stage two distinct notes before one Commit and predict zero committed items before it and two afterward. Call Commit again without staging anything and predict that the count remains two. Hint: clearing pending data is part of this session's stated behavior, so a repeated commit should not duplicate the same staged records. Then stage whitespace-only text through WriteNote and predict rejection with no new committed data. Keep a valid baseline fixture separate so a failed input check cannot hide a broken commit implementation.

Practice two: test a boundary

For another exercise, add an explicit discard-pending operation to this custom interface and define what it does to already committed notes. Hint: do not label it database rollback unless it actually provides the relevant transactional guarantee. Write an adapter contract plan for a real EF-backed implementation: identify which queries, save boundaries, concurrency conflicts, and disposal behavior need integration evidence. Decide whether a specialized repository adds clarity beyond using a scoped DbContext directly in the chosen application design. Preserve the distinction between an in-memory teaching contract and real provider semantics.

Review the evidence

Review a persistence abstraction by naming the operations it exposes and the guarantees each actually provides. Check ownership of returned values, save boundaries, lifetime, concurrency, and failure handling before adding generic methods for hypothetical future databases. This lesson's program proves two small staging observations, and its external solution proves a dependency arrangement. Neither establishes production persistence reliability. A useful repository or unit of work makes meaningful domain and transaction boundaries clearer; an unnecessary wrapper can make them harder to inspect while leaving every difficult database decision unresolved beneath another interface.

External .NET 10 SDK; five real projects with explicit ProjectReference items. Not executed by the single-file .NET 9 playground.

Repository and unit-of-work tradeoffs — physical dependency lab

Set up the SDK project

  1. Save every file at its exact relative path under a fresh directory.
  2. Use a .NET 10 SDK. Run dotnet build ArchitectureLab.slnx.
  3. Run dotnet run --project Host/Host.csproj --no-build.
  4. From the solution directory run dotnet run --project ArchitectureCheck/ArchitectureCheck.csproj --no-build -- .

Download complete SDK project ↗

Project files

ArchitectureLab.slnxDownload file ↗
<Solution>
  <Project Path="Domain/Domain.csproj" />
  <Project Path="Application/Application.csproj" />
  <Project Path="Infrastructure/Infrastructure.csproj" />
  <Project Path="Host/Host.csproj" />
  <Project Path="ArchitectureCheck/ArchitectureCheck.csproj" />
</Solution>
Domain/Domain.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Application/Application.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
  </ItemGroup>
</Project>
Infrastructure/Infrastructure.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
    <ProjectReference Include="../Application/Application.csproj" />
  </ItemGroup>
</Project>
Host/Host.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
    <ProjectReference Include="../Application/Application.csproj" />
    <ProjectReference Include="../Infrastructure/Infrastructure.csproj" />
  </ItemGroup>
</Project>
ArchitectureCheck/ArchitectureCheck.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Domain/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;

namespace BoundaryLab.Domain;

public record Note(string Text);
Application/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;

namespace BoundaryLab.Application;

public interface INoteSession
{
    void Stage(Note note);
    void Commit();
    IReadOnlyList<Note> ReadCommitted();
}
public sealed class WriteNote(INoteSession session)
{
    public void Stage(string text)
    {
        if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Text required", nameof(text));
        session.Stage(new Note(text));
    }
}
Infrastructure/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;

namespace BoundaryLab.Infrastructure;

public sealed class MemoryNoteSession : INoteSession
{
    private readonly List<Note> pending = new();
    private readonly List<Note> committed = new();
    public void Stage(Note note) => pending.Add(note);
    public void Commit() { committed.AddRange(pending); pending.Clear(); }
    public IReadOnlyList<Note> ReadCommitted() => committed.ToArray();
}
Host/Program.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;

var session = new MemoryNoteSession();
new WriteNote(session).Stage("a");
Console.WriteLine($"before:{session.ReadCommitted().Count}");
session.Commit();
Console.WriteLine($"after:{session.ReadCommitted().Count}");
ArchitectureCheck/Program.csDownload file ↗
using System.Xml.Linq;
string root = Path.GetFullPath(args.Length == 0 ? "." : args[0]);
var allowed = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal)
{
    ["Domain"] = new(),
    ["Application"] = new() { "Domain" },
    ["Infrastructure"] = new() { "Domain", "Application" },
    ["Host"] = new() { "Domain", "Application", "Infrastructure" }
};
int violations = 0;
foreach (var item in allowed)
{
    string path = Path.Combine(root, item.Key, item.Key + ".csproj");
    var document = XDocument.Load(path);
    foreach (var reference in document.Descendants().Where(element => element.Name.LocalName == "ProjectReference"))
    {
        string include = (string?)reference.Attribute("Include") ?? throw new InvalidOperationException("Missing Include");
        string targetPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path)!, include));
        string target = Path.GetFileNameWithoutExtension(targetPath);
        string expectedPath = Path.Combine(root, target, target + ".csproj");
        if (!item.Value.Contains(target) || targetPath != expectedPath)
        {
            violations++;
            Console.WriteLine($"violation:{item.Key}->{target}");
        }
    }
}
Console.WriteLine($"projects:{allowed.Count}");
Console.WriteLine($"violations:{violations}");
return violations == 0 ? 0 : 1;

Expected result

Host output: before:0 after:1 Physical ProjectReference policy check output: projects:4 violations:0 Both commands exit zero. The checker inspects the four business-project files; its own tooling project is outside that policy. It checks declared project-reference direction, not all runtime behavior or package dependencies.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Collections.Generic;
using System.Linq;

var session = new MemoryNoteSession();
new WriteNote(session).Stage("a");
Console.WriteLine($"before:{session.ReadCommitted().Count}");
session.Commit();
Console.WriteLine($"after:{session.ReadCommitted().Count}");

public record Note(string Text);

public interface INoteSession
{
    void Stage(Note note);
    void Commit();
    IReadOnlyList<Note> ReadCommitted();
}
public sealed class WriteNote(INoteSession session)
{
    public void Stage(string text)
    {
        if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Text required", nameof(text));
        session.Stage(new Note(text));
    }
}

public sealed class MemoryNoteSession : INoteSession
{
    private readonly List<Note> pending = new();
    private readonly List<Note> committed = new();
    public void Stage(Note note) => pending.Add(note);
    public void Commit() { committed.AddRange(pending); pending.Clear(); }
    public IReadOnlyList<Note> ReadCommitted() => committed.ToArray();
}
Open C# practice

C# practice output

before:0
after:1
Try this
Stage two notes and commit once. This custom in-memory contract is not EF DbContext, durable storage, rollback, or a transaction guarantee.

Further reading