Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETRepository PatternEF CoreAbstraction
Share:𝕏

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

C#
// 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 queried GetAllAsync() 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's IQueryable would have composed the filter into SQL. The generic repository's IEnumerable<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 repository
  • IQueryable<T> — composable, translates to SQL
  • SaveChangesAsync() — the unit of work
  • AsNoTracking() — optimized reads
  • Include() — eager loading
C#
// 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 IQueryable

When to Keep a Typed Repository Interface

The template does use typed interfaces — just not generic ones — when the interface adds real value:

C#
// 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 IQueryable composability (filtering, pagination, projection)
  • The query is read-only and returns a DTO (not a domain entity)

The Read-Side Pattern

C#
// 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:

C#
// 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> with GetAll(), GetById(), Add(), Update(), Delete(), and Save(). 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 using AsNoTracking() and Select() 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.