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.DbContexttracks changes across all sets andSaveChanges()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:
- It hides EF Core's power — no projections, no
Include, noAsNoTracking, no pagination unless you add more and more parameters. GetAllAsync()invites disasters — loading entire tables into memory.- Leaky abstraction —
Expression<Func<T,bool>>still depends on what EF Core can translate. - 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 —
GetPublishedAsyncsays 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.