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.
Learning outcomes
Explain why DbContext already provides unit-of-work and repository-like behavior.
Choose direct context, repository, specification, or query-service boundaries by responsibility rather than fashion.
Keep IQueryable exposure explicit so callers cannot accidentally bypass tenant, cardinality, or tracking policy.
Preserve ServiceHub aggregate invariants while keeping generated SQL and transactions observable.
Test domain decisions without mocking EF translation and test database behavior against a relational provider.
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.
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
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 }}
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
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
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.
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
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));
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
-
Implement one ServiceHub command directly with
ServiceHubContextand captureChangeTracker.DebugViewplus generated SQL. - Wrap the same operation in a generic repository that mirrors EF; list which EF capabilities became hidden or duplicated.
- Replace it with either a domain-specific aggregate repository or bounded query service.
-
Verify that tenant filters and
Revisionconcurrency still appear in SQL. - Write one pure business-policy unit test and one relational integration test.
-
Document who owns
SaveChangesand 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
- Why is DbContext already unit-of-work-like?
- When does a repository add value?
- Why is IQueryable leakage important?
- Who should own SaveChanges in a multi-repository operation?
- What should be unit tested without EF?
- 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
- DbContext lifetime/configuration — DbContext as a short-lived unit of work and thread-safety/lifetime rules
- EF Core testing strategy — why translation and relational behavior require real provider testing
- Efficient querying — query-shape and projection/cardinality guidance
- Global query filters — tenant/filter behavior that abstractions must not accidentally bypass
- Advanced performance topics — context pooling and EF/runtime overhead boundaries
- Transactions in EF Core — SaveChanges and explicit transaction ownership