No Repository Pattern — Using EF Core as Your Abstraction
Why the Clean Architecture template skips the repository pattern, how EF Core's DbSet and IQueryable already provide a sufficient abstraction, and the production problems the extra layer introduces.
The Traditional Repository Pattern
The repository pattern was popularized by DDD as a way to decouple the domain from persistence. The idea: the domain defines IPatientRepository, and infrastructure implements it.
The Clean Architecture template does NOT use it. Here is why.
The Problem With Generic Repositories
// The classic generic repository — looks clean, is often a problem
public interface IRepository<T> where T : Entity
{
Task<T?> GetByIdAsync(Guid id, CancellationToken ct);
Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct);
Task AddAsync(T entity, CancellationToken ct);
Task UpdateAsync(T entity, CancellationToken ct);
Task DeleteAsync(T entity, CancellationToken ct);
Task<int> SaveChangesAsync(CancellationToken ct);
}Production issue I've seen: A team built a
GetAllAsync()method on a generic repository. Developers queriedGetAllAsync()and filtered in memory. A query that retrieved 80,000 medication records to find 12 active ones caused an OOM crash in production. EF Core'sIQueryablewould have composed the filter into SQL. The generic repository'sIEnumerable<T>return type prevented it.
Problems with generic repositories:
1. IQueryable leaks: you either expose IQueryable (which is EF Core) or IEnumerable (which loads everything)
2. They wrap EF Core's already-repository-like DbSet with another thin layer
3. GetAll() tempts developers to filter in memory
4. SaveChanges() on a repository breaks Unit of Work semantics
5. Testing with fake repositories drifts from EF Core behavior (IN-MEMORY vs SQL)EF Core Is Already the Abstraction
EF Core's DbContext already provides:
DbSet<T>— the repositoryIQueryable<T>— composable, translates to SQLSaveChangesAsync()— the unit of workAsNoTracking()— optimized readsInclude()— eager loading
// What a "repository" adds on top of DbContext:
public class PatientRepository : IPatientRepository
{
private readonly AppDbContext _context;
public async Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct)
=> await _context.Patients.FirstOrDefaultAsync(p => p.Id == id, ct);
// This is 1 line wrapping 1 EF Core call. The abstraction value is minimal.
}
// What the handler uses instead — direct IQueryable composition:
public sealed class GetActiveHighRiskPatientsQueryHandler
{
private readonly AppDbContext _context;
public async Task<Result<IReadOnlyList<PatientSummary>>> Handle(
GetActiveHighRiskPatientsQuery query,
CancellationToken ct)
{
// Filter, project, and paginate — all translated to SQL
var patients = await _context.Patients
.Where(p => p.IsActive && p.Prescriptions.Any(rx => rx.IsHighRisk))
.OrderBy(p => p.Name)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(p => new PatientSummary(p.Id.Value, p.Name, p.MRN))
.AsNoTracking()
.ToListAsync(ct);
return Result.Success<IReadOnlyList<PatientSummary>>(patients);
}
}
// No generic repository can express this composably without returning IQueryableWhen to Keep a Typed Repository Interface
The template does use typed interfaces — just not generic ones — when the interface adds real value:
// Application/Abstractions/IPatientRepository.cs
// Keeps complex, reusable queries in one place
public interface IPatientRepository
{
Task<Patient?> GetByIdWithPrescriptionsAsync(PatientId id, CancellationToken ct);
Task<bool> ExistsByMRNAsync(string mrn, CancellationToken ct);
Task AddAsync(Patient patient, CancellationToken ct);
}Use a typed repository interface when:
- The query is complex and reused across multiple handlers
- You want to unit test a handler without a real DB
- The data access logic has meaningful business semantics (not just
GetById)
Use the DbContext directly when:
- The query is unique to one handler (a specialized projection)
- You need full
IQueryablecomposability (filtering, pagination, projection) - The query is read-only and returns a DTO (not a domain entity)
The Read-Side Pattern
// For queries, consider injecting the DbContext directly
// The query handler returns a DTO, not a domain entity — no domain logic involved
public sealed class GetPatientListQueryHandler
{
private readonly AppDbContext _context; // read-only access
public GetPatientListQueryHandler(AppDbContext context) => _context = context;
public async Task<Result<PagedResult<PatientListItem>>> Handle(
GetPatientListQuery query,
CancellationToken ct)
{
var baseQuery = _context.Patients
.AsNoTracking()
.Where(p => p.IsActive);
if (!string.IsNullOrWhiteSpace(query.SearchTerm))
baseQuery = baseQuery.Where(p =>
p.Name.Contains(query.SearchTerm) ||
p.MRN.Contains(query.SearchTerm));
var total = await baseQuery.CountAsync(ct);
var items = await baseQuery
.OrderBy(p => p.Name)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(p => new PatientListItem(p.Id.Value, p.Name, p.MRN, p.IsActive))
.ToListAsync(ct);
return Result.Success(new PagedResult<PatientListItem>(items, total, query.Page, query.PageSize));
}
}Testing Without a Repository
The common argument for the repository pattern is testability. But EF Core's in-memory test database and Testcontainers both provide better test fidelity than a hand-written fake:
// tests/Application.UnitTests/Patients/GetPatientListQueryHandlerTests.cs
public class GetPatientListQueryHandlerTests : IDisposable
{
private readonly AppDbContext _context;
private readonly GetPatientListQueryHandler _handler;
public GetPatientListQueryHandlerTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options, new NoOpDomainEventPublisher());
_handler = new GetPatientListQueryHandler(_context);
}
[Fact]
public async Task Returns_only_active_patients()
{
// Arrange
_context.Patients.Add(Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001").Value);
var inactive = Patient.Create("Jane Doe", new DateOnly(1990, 7, 22), "MRN-002").Value;
inactive.Deactivate();
_context.Patients.Add(inactive);
await _context.SaveChangesAsync();
// Act
var result = await _handler.Handle(new GetPatientListQuery(Page: 1, PageSize: 10), CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Single(result.Value.Items);
Assert.Equal("MRN-001", result.Value.Items[0].MRN);
}
public void Dispose() => _context.Dispose();
}Red Flag Answers
Red flag: "I have a generic
IRepository<T>withGetAll(),GetById(),Add(),Update(),Delete(), andSave(). Everything goes through it."
That is a leaky abstraction that either returns IEnumerable<T> (loading the entire table) or exposes IQueryable<T> (which is EF Core leaking through the interface). You added a layer without adding testability.
Green answer: "EF Core's
DbSet<T>is already a repository. For complex, reusable queries I define a typed interface. For read-only projections I query the DbContext directly in the handler usingAsNoTracking()andSelect()to project to DTOs — all composed in SQL, none loaded in memory."
Key Takeaway
The Repository pattern was invented before ORM frameworks existed. EF Core already implements Unit of Work (
SaveChangesAsync) and Repository (DbSet). Adding another repository on top is a layer that costs time to maintain, introduces in-memory vs SQL divergence in tests, and often constrains what queries you can express. Use EF Core directly for reads; use typed interface abstractions for writes where testability demands it.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.