Chapter 25 · Production Architecture, DDD/CQRS Integration, Reliability, and Capstone

DbContext vs Repository/Unit-of-Work Abstractions: Avoid Redundant Layers and Preserve Testable Boundaries

Choose direct DbContext, repositories, specifications, and query services deliberately in ServiceHub, preserving transaction/query visibility and testable business boundaries without mechanically wrapping EF Core.

Advanced210–300 minutesarchitecture-boundary labEF Core 10.0.11 · Microsoft.EntityFrameworkCore.Sqlite 10.0.11 · dotnet-ef 10.0.11 · .NET 10.0.11 · SDK 10.0.400Mandatory free local SQLite path · Dapper 2.1.79 optional · production-like server provider optionalArchitecture/package/platform status reviewed: August 27, 2026

Learning outcomes

01

Explain why DbContext already provides unit-of-work and repository-like behavior.

02

Choose direct context, repository, specification, or query-service boundaries by responsibility rather than fashion.

03

Keep IQueryable exposure explicit so callers cannot accidentally bypass tenant, cardinality, or tracking policy.

04

Preserve ServiceHub aggregate invariants while keeping generated SQL and transactions observable.

05

Test domain decisions without mocking EF translation and test database behavior against a relational provider.

06

Diagnose a redundant generic repository that destroys EF capabilities without adding policy.

1. The architecture problem: abstraction can either clarify ownership or hide the database

ServiceHub has reached production scale. The team proposes an IRepository<T> with methods mirroring Add, Find, Update, Remove and Save because “repositories are cleaner.” That can be useful only if the abstraction owns a meaningful boundary. If it merely renames EF methods, it adds code while hiding query shape, tracking, transactions, provider features and generated SQL.

A unit of work coordinates a set of changes and commits them together. EF’s DbContext already tracks entity changes and SaveChanges persists them as one unit; a DbSet<T> already offers repository-like access to an entity set. A repository should therefore represent a domain capability or policy, not an EF-shaped forwarding layer.

Architecture rule

Use the smallest boundary that makes ownership clearer. Direct DbContext is acceptable application infrastructure; a repository is justified when it protects aggregate/query policy, supports a meaningful alternate implementation, or centralizes behavior that callers must not re-invent.

2. Direct DbContext keeps the real mechanism visible

C# · application command handler with direct DbContext
public sealed class ReviseWorkOrderHandler(ServiceHubContext db){    public async Task HandleAsync(int id, string summary, CancellationToken ct)    {        var order = await db.WorkOrders.SingleAsync(w => w.Id == id, ct);        order.ReviseSummary(summary);       // aggregate validates + advances Revision        await db.SaveChangesAsync(ct);      // one EF unit of work    }}
Representative concurrency-sensitive SQL
UPDATE "work_orders"SET "summary" = @p0, "revision" = @p1WHERE "work_order_id" = @p2  AND "revision" = @p3RETURNING 1;

The handler is not “coupled to SQL” merely because it knows EF. It is deliberately coupled to a persistence technology at the application/infrastructure boundary, while the aggregate method owns the business invariant. Generated SQL, the Revision predicate and tenant filters remain inspectable.

3. A useful repository speaks domain language and owns loading policy

C# · narrow aggregate repository
public interface IWorkOrderRepository{    Task<WorkOrder?> LoadForRevisionAsync(int id, CancellationToken ct);    void Add(WorkOrder order);}public sealed class EfWorkOrderRepository(ServiceHubContext db)    : IWorkOrderRepository{    public Task<WorkOrder?> LoadForRevisionAsync(int id, CancellationToken ct)        => db.WorkOrders            .Include(w => w.Notes)            .SingleOrDefaultAsync(w => w.Id == id, ct);    public void Add(WorkOrder order) => db.WorkOrders.Add(order);}

This repository earns its existence if “load for revision” is a stable aggregate policy. Notice that it does not expose SaveAsync: the application transaction owner controls SaveChanges across all participating aggregates/outbox rows.

4. Deliberate failure: mechanical generic repository

C# · WRONG: EF renamed, not abstracted
public interface IRepository<T> where T : class{    IQueryable<T> Query();    Task<T?> FindAsync(object id);    void Add(T item);    void Update(T item);    void Remove(T item);    Task SaveAsync();}

This interface leaks IQueryable while simultaneously hiding EF’s transaction owner. Callers can compose unbounded includes, disable filters, change tracking behavior, or call SaveAsync halfway through a larger operation. It also makes provider-specific features awkward and tends to recreate EF’s API poorly.

Repair

Either inject ServiceHubContext where persistence is an application concern, or define a narrower domain/query interface whose methods encode the allowed operation and shape.

5. IQueryable is a boundary decision, not a free abstraction

Returning IQueryable<WorkOrder> hands the caller the power to change translation, cardinality, tracking and often security-sensitive shape. That can be appropriate inside a trusted application layer; it is usually inappropriate across module/API boundaries.

Boundary Good fit Risk
Direct DbContext Application handlers that deliberately own EF Technology-specific but transparent
Aggregate repository Stable aggregate load/save policy Over-abstraction if it just mirrors EF
Query service returning DTOs Public/read boundary with bounded shape Less composability by design
Specification Reusable predicate/include policy with disciplined execution Can become a second query language
IQueryable across module/API Rare, highly trusted internal composition Policy/cardinality/translation leakage

6. Testing the seam: fake business decisions, not database translation

C# · pure policy unit test
public sealed class EscalationPolicy{    public bool RequiresSupervisor(int priority, bool isAfterHours)        => priority >= 4 || (priority >= 3 && isAfterHours);}[Fact]public void Priority_four_requires_supervisor()    => Assert.True(new EscalationPolicy().RequiresSupervisor(4, false));
C# · relational integration assertion
await using var db = factory.CreateForTenant("tenant-a");var query = db.WorkOrders.Where(w => w.Priority >= 3).Take(20);var sql = query.ToQueryString();Assert.Contains("tenant_id", sql, StringComparison.OrdinalIgnoreCase);var rows = await query.ToListAsync();

The unit test needs no EF mock. The integration test verifies translation, tenant filter, constraints and provider behavior against SQLite or the production-like provider from Chapter 23.

7. Mandatory lab: delete the redundant layer, then justify one useful boundary

  1. Implement one ServiceHub command directly with ServiceHubContext and capture ChangeTracker.DebugView plus generated SQL.
  2. Wrap the same operation in a generic repository that mirrors EF; list which EF capabilities became hidden or duplicated.
  3. Replace it with either a domain-specific aggregate repository or bounded query service.
  4. Verify that tenant filters and Revision concurrency still appear in SQL.
  5. Write one pure business-policy unit test and one relational integration test.
  6. Document who owns SaveChanges and the transaction boundary.

8. Production judgment and bridge

There is no universal “EF must always/never be behind a repository” rule. Choose the boundary that makes invariants, transaction ownership, query shape and provider behavior reviewable. The next lesson uses that discipline to model the WorkOrder aggregate, complex values, domain events and the existing outbox without turning persistence hooks into a message broker.

Check your understanding

  1. Why is DbContext already unit-of-work-like?
  2. When does a repository add value?
  3. Why is IQueryable leakage important?
  4. Who should own SaveChanges in a multi-repository operation?
  5. What should be unit tested without EF?
  6. What needs a real relational provider?
Review the answers

1. It tracks entity changes and commits them together through SaveChanges/SaveChangesAsync.

2. When it represents a meaningful domain/query policy, reuse boundary or alternate implementation rather than forwarding EF methods.

3. It gives the caller control over translation, cardinality, tracking and potentially security-sensitive query shape.

4. The application transaction/unit-of-work owner, so all participating changes commit together.

5. Pure domain/business decisions that do not depend on relational translation or provider behavior.

6. Translation, constraints, concurrency, transactions, migrations and provider-specific semantics.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n