Articles › Software Architecture

Repository and Unit of Work with EF Core: Do You Still Need Them?

An honest look at the Repository and Unit of Work patterns on top of EF Core — when they add value, when they hurt, and a pragmatic implementation for real projects.

Few topics in the .NET community are debated as passionately as this one: does the Repository pattern still make sense with Entity Framework Core? The short answer is "it depends". The long answer — with the trade-offs, the anti-patterns and a pragmatic implementation — is this article.

What the patterns promise

  • Repository — a collection-like abstraction over data access. Business code asks for "the published articles of this author" without knowing about SQL or EF Core.
  • Unit of Work — tracks changes across several repositories and commits them in one transaction.

The case against: EF Core already implements both

Look closely at DbContext:

  • DbSet<T> behaves like a repository: add, remove, query.
  • DbContext tracks changes across all sets and SaveChanges() commits them atomically — a unit of work.

Wrapping them in another generic layer often looks like this:

// ❌ A thin generic wrapper that adds nothing
public interface IRepository<T>
{
    Task<T?> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync();
    Task<List<T>> FindAsync(Expression<Func<T, bool>> predicate);
    void Add(T entity);
    void Remove(T entity);
}

Problems with this design:

  1. It hides EF Core's power — no projections, no Include, no AsNoTracking, no pagination unless you add more and more parameters.
  2. GetAllAsync() invites disasters — loading entire tables into memory.
  3. Leaky abstraction — Expression<Func<T,bool>> still depends on what EF Core can translate.
  4. Extra code to maintain for very little benefit.

The case for: what repositories are really good at

Repositories add real value when they're specific, not generic:

public interface IArticleRepository
{
    Task<Article?> GetBySlugAsync(string slug, CancellationToken ct);
    Task<IReadOnlyList<ArticleSummary>> GetPublishedAsync(int page, int pageSize, CancellationToken ct);
    Task<bool> SlugExistsAsync(string slug, CancellationToken ct);
    void Add(Article article);
}

Benefits:

  • Business language — GetPublishedAsync says what it means; the query details live in one place.
  • No duplicated queries — "published articles" is defined once, not copied into ten services where one bug must be fixed ten times.
  • Testability — application services can be unit-tested with a simple fake repository.
  • Clean Architecture boundaries — the Application layer defines the interface; the Infrastructure layer implements it with EF Core.

A pragmatic implementation

The repository

public sealed class ArticleRepository(AppDbContext db) : IArticleRepository
{
    public Task<Article?> GetBySlugAsync(string slug, CancellationToken ct) =>
        db.Articles.Include(a => a.Tags).FirstOrDefaultAsync(a => a.Slug == slug, ct);

    public async Task<IReadOnlyList<ArticleSummary>> GetPublishedAsync(int page, int pageSize, CancellationToken ct) =>
        await db.Articles
            .AsNoTracking()
            .Where(a => a.IsPublished)
            .OrderByDescending(a => a.PublishedAt)
            .Skip((page - 1) * pageSize).Take(pageSize)
            .Select(a => new ArticleSummary(a.Id, a.Slug, a.Title, a.PublishedAt))
            .ToListAsync(ct);

    public Task<bool> SlugExistsAsync(string slug, CancellationToken ct) =>
        db.Articles.AnyAsync(a => a.Slug == slug, ct);

    public void Add(Article article) => db.Articles.Add(article);
}

Note that the repository doesn't call SaveChanges. Saving is the unit of work's job.

The unit of work

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options), IUnitOfWork
{
    public DbSet<Article> Articles => Set<Article>();
    public DbSet<Author> Authors => Set<Author>();
}

The DbContext itself implements IUnitOfWork — no extra class needed. Because the context is scoped, every repository in the same request shares it, so one SaveChangesAsync commits all their changes atomically.

Registration

builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());
builder.Services.AddScoped<IArticleRepository, ArticleRepository>();
builder.Services.AddScoped<IAuthorRepository, AuthorRepository>();

Using it in an application service

public sealed class PublishArticleHandler(
    IArticleRepository articles,
    IAuthorRepository authors,
    IUnitOfWork unitOfWork)
{
    public async Task<Result<int>> HandleAsync(PublishArticle cmd, CancellationToken ct)
    {
        if (await articles.SlugExistsAsync(cmd.Slug, ct))
            return Result<int>.Failure("Slug is already taken.");

        var author = await authors.GetAsync(cmd.AuthorId, ct);
        if (author is null) return Result<int>.Failure("Author not found.");

        var article = Article.Create(cmd.Title, cmd.Slug, cmd.Content, author);
        articles.Add(article);
        author.IncrementArticleCount();

        await unitOfWork.SaveChangesAsync(ct);   // one transaction for both changes
        return Result<int>.Success(article.Id);
    }
}

Explicit transactions

When one operation must span multiple SaveChanges calls (or raw SQL), use an explicit transaction:

await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
    // ... several SaveChangesAsync / ExecuteUpdateAsync calls
    await tx.CommitAsync(ct);
}
catch
{
    await tx.RollbackAsync(ct);
    throw;
}

With retrying execution strategies (EnableRetryOnFailure), wrap this in db.Database.CreateExecutionStrategy().ExecuteAsync(...).

Reusable query logic without generic repositories

If several repositories need the same filters, use extension methods or the Specification pattern instead of a mega-generic repository:

public static class ArticleQueries
{
    public static IQueryable<Article> Published(this IQueryable<Article> q) =>
        q.Where(a => a.IsPublished && a.PublishedAt <= DateTime.UtcNow);

    public static IQueryable<Article> ByTag(this IQueryable<Article> q, string tag) =>
        q.Where(a => a.Tags.Any(t => t.Name == tag));
}

var list = await db.Articles.Published().ByTag("csharp").ToListAsync(ct);

So, should you use them?

Project Recommendation
Small API, few entities, one developer Use DbContext directly in services or handlers
Medium/large app with Clean Architecture Specific repositories + DbContext as unit of work
Complex domain (DDD) Repositories per aggregate root only
Any project Avoid a generic IRepository<T> that just wraps DbSet<T>

Testing considerations

  • Unit-test application logic with fake repositories.
  • Test repositories themselves with integration tests against a real database (SQL Server in Docker or Testcontainers). The EF Core in-memory provider behaves differently from relational databases and can hide bugs.

Summary

EF Core's DbContext is already a unit of work and DbSet<T> is already a generic repository — wrapping them in another generic layer usually hurts. But specific repositories with intention-revealing methods remain valuable: they centralize queries, speak the business language and keep the application layer independent of EF Core. Use the DbContext as your unit of work, keep repositories focused, and let the size of your project decide how much abstraction it deserves.

FAQ

Should repositories return IQueryable?

Generally no — it leaks query construction to callers and blurs the boundary. Return materialized results or DTOs.

Where does SaveChanges belong?

In the application layer (handler/service) through IUnitOfWork, so one use case commits once.

Can I mix repositories and direct DbContext usage?

Yes. Many teams use repositories for writes and complex domain logic, and query the DbContext directly (or with Dapper) for read-only screens — a light form of CQRS.