Skip to content

Repository & UoW Debate

Key Points

  • EF Core's DbContext IS a Unit of Work. Adding IUnitOfWork over it is usually redundant.
  • EF Core's DbSet<T> IS a Repository. Wrapping it in IRepository<T> for "abstraction" often adds noise without value.
  • Concrete repositories with intent-revealing methods still earn their place: IOrderRepository.FindOverdue() is more communicative than reusable LINQ.
  • Reasons to add a repo abstraction: testability without EF, isolating EF version upgrades, hiding query specifics from domain consumers.
  • Reasons NOT to add it: bypassing IQueryable kills composition; thin wrappers add noise; mocking EF directly is fine for testing.

Concepts (deep dive)

The case against generic repository

// ❌ The usual generic repository
public interface IRepository<T>
{
    Task<T?> GetByIdAsync(int id);
    Task<IReadOnlyList<T>> GetAllAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(T entity);
}

What does this give you that DbSet<T> doesn't? - ❌ Hides IQueryable → loses composition (filtering, projection, paging). - ❌ Reduces queries to simple key-value lookups. - ❌ Adds friction to writing efficient queries.

If your repo is just db.Set<T>().Find(...) wrapped, you've added a layer with negative value.

When concrete repositories DO help

public interface IOrderRepository
{
    Task<Order?> FindAsync(OrderId id, CancellationToken ct);
    Task<List<Order>> FindOverdueAsync(DateTimeOffset asOf, int limit, CancellationToken ct);
    Task<List<Order>> FindByCustomerAsync(CustomerId cid, CancellationToken ct);
    Task SaveAsync(Order order, CancellationToken ct);
}

This is intent-revealing. Each method represents a domain query: "find overdue orders". The implementation can use EF, Dapper, or both — the consumer doesn't care.

The signature is the contract; the implementation is hidden.

The Unit of Work case

// ❌ The usual UoW abstraction
public interface IUnitOfWork
{
    IRepository<Order> Orders { get; }
    IRepository<Customer> Customers { get; }
    Task<int> SaveChangesAsync();
}

If you're using EF Core, DbContext is already this. Wrapping it adds nothing — you just call _uow.SaveChangesAsync() instead of db.SaveChangesAsync().

When UoW is genuine

UoW interface over different ORMs:

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync();
}

// EF implementation
public class EfUnitOfWork(AppDb db) : IUnitOfWork
{
    public Task<int> SaveChangesAsync() => db.SaveChangesAsync();
}

// Hypothetical Dapper-based implementation
public class DapperUnitOfWork(IDbTransaction tx) : IUnitOfWork
{
    public Task<int> SaveChangesAsync() { tx.Commit(); return Task.FromResult(0); }
}

If you genuinely need to swap ORMs (rare), abstracting the unit of work makes sense. Most projects don't.

Specifications as middle ground

public class FindOverdueOrdersSpec : Specification<Order>
{
    public FindOverdueOrdersSpec(DateTimeOffset asOf)
    {
        Query.Where(o => o.DueDate < asOf && o.Status != OrderStatus.Paid);
    }
}

// In the repo:
var overdue = await _repo.QueryAsync(new FindOverdueOrdersSpec(asOf));

Specifications make queries first-class objects without losing composition. Combined with a generic repository: IRepository<T> { Task<List<T>> QueryAsync(Specification<T> spec); }. Some teams love this; others find it heavyweight.

Testing without an abstraction

[Fact]
public async Task PlaceOrder_saves_with_status_pending()
{
    var options = new DbContextOptionsBuilder<AppDb>().UseInMemoryDatabase("test").Options;
    using var db = new AppDb(options);

    var handler = new PlaceOrderHandler(db);
    var id = await handler.HandleAsync(new(...));

    var saved = await db.Orders.FindAsync(id);
    Assert.Equal(OrderStatus.Pending, saved!.Status);
}

EF's in-memory provider lets you test handlers without a repo abstraction. Or use Testcontainers for a real DB. Most senior teams test against real (or in-memory) DBs rather than mocked repos — the test catches actual SQL bugs.

Pragmatic recommendation

For a typical .NET app:

  1. Use EF Core directly in handlers for queries.
  2. Wrap aggregates behind I*Repository only when domain operations need encapsulation (add/save/find specific behavior).
  3. Skip generic repository unless multiple ORMs in play.
  4. Skip generic UoW unless multiple persistence mechanisms.

Code: correct vs wrong

❌ Wrong: layer of abstraction adding zero value

public interface IOrderRepository
{
    IQueryable<Order> Query();   // exposes IQueryable; thin wrapper
}

If you're going to expose IQueryable, why have the wrapper at all? Just use db.Orders.

✅ Correct: intent-revealing methods

public interface IOrderRepository
{
    Task<Order?> FindAsync(OrderId id, CancellationToken ct);
    Task<List<Order>> FindOverdueAsync(DateTimeOffset asOf, CancellationToken ct);
}

❌ Wrong: UoW over EF

public class UnitOfWork(AppDb db) : IUnitOfWork
{
    public IOrderRepository Orders { get; } = new EfOrderRepository(db);
    public Task SaveChangesAsync() => db.SaveChangesAsync();
}

You've recreated DbContext with extra layers.

✅ Correct: use DbContext directly

// Just inject AppDb and use it
public class PlaceOrderHandler(AppDb db) { /* ... */ }

Design patterns for this topic

Pattern 1 — "DbContext as UoW; DbSet as repository"

  • Intent: don't recreate what EF already provides.

Pattern 2 — "Concrete repo with intent-revealing methods"

  • Intent: expose domain queries clearly.

Pattern 3 — "Specifications for composable queries"

  • Intent: middle ground between fully-typed methods and raw IQueryable.

Pattern 4 — "Skip the abstraction in command handlers"

  • Intent: less ceremony for simple cases.

Pattern 5 — "Repo for aggregates, IQueryable for read DTOs"

  • Intent: writes through repo (encapsulation); reads via IQueryable (composition).

Pros & cons / trade-offs

Approach Pros Cons
Generic repo Familiar pattern Hides composition; adds noise
Concrete repo Intent-revealing One per aggregate
UoW abstraction Multi-ORM Usually redundant over EF
No abstraction Simple Couples handler to EF
Specifications Composable Verbose

When to use / when to avoid

  • Use concrete repos for aggregate boundaries (write side).
  • Use IQueryable directly for read DTOs.
  • Avoid generic IRepository<T> unless you really need it.
  • Avoid IUnitOfWork over EF Core — DbContext is one already.

Interview Q&A

Q1. Why do some say "EF Core is already a UoW + repository"? DbContext tracks changes and commits transactionally (UoW). DbSet<T> is a queryable collection per entity (repository). Wrapping adds no behavior.

Q2. When does a generic repository make sense? When you genuinely need to swap persistence (EF → Dapper → Cosmos) and want a single abstraction across.

Q3. When does a concrete repository help? When the interface has intent-revealing methods that hide query specifics (e.g., FindOverdue, FindByCustomer).

Q4. What does the spec pattern give you? Reusable, composable query criteria as objects. Combine via .And / .Or. Cleaner than passing predicates everywhere.

Q5. How do you test handlers without a repo abstraction? EF Core's in-memory provider, or Testcontainers with real Postgres/SQL Server. The test catches actual ORM bugs.

Q6. Should commands go through repos and queries hit IQueryable directly? A common compromise — write side encapsulates invariants via repos; read side projects via IQueryable.

Q7. What's the issue with exposing IQueryable from a repo? The repo's purpose was to hide details. Returning IQueryable means consumers can compose queries — defeating the abstraction.

Q8. When is "no repository at all" the right answer? Simple CRUD apps where the domain doesn't have invariants worth encapsulating.

Q9. Why might you NOT want the in-memory EF provider for tests? Some queries behave differently than against the real provider. For high-fidelity tests, use Testcontainers.

Q10. What's the senior view on generic repos in .NET? Mostly avoided. Concrete intent-revealing repos for aggregates; direct DbSet usage elsewhere.


Gotchas / common mistakes

  • ⚠️ Generic repo wrapping DbSet — pure overhead.
  • ⚠️ IRepository<T>.Query() returning IQueryable — defeats abstraction.
  • ⚠️ UoW interface over EF — redundant.
  • ⚠️ Mocking EF in tests — brittle; use in-memory or Testcontainers.

Further reading