Repository Pattern vs DbContext in Entity Framework Core
Tired of arguing about repositories in EF Core? This practical guide compares direct DbContext, thin repositories, and specification style queries with small, runnable C# examples. Learn when each option shines, what to avoid, and how to keep your code clean without hiding EF Core features. We also cover testing pitfalls and safer alternatives. Leave with a simple decision map you can apply to your next .NET project.
If I had a credit for every standup that turned into a heated debate about repositories, I would have enough Azure credits to train a Wookee. Some folks wrap Entity Framework Core in custom repositories. Others inject DbContext straight into services and call it a day. Both camps have good reasons. The trick is knowing when each fit.
In this post we will build a tiny example, compare options, and leave you with a practical checklist so your next data access decision feels like a refactor, not a lightsaber duel.
The baseline: direct DbContext
EF Core already gives you a repository like set and a unit of work in DbContext. That means you can keep things simple and use it directly.
Let’s start with a tiny domain. No midi-chlorians required.
public class Bounty{ public int Id { get; set; } public string Hunter { get; set; } = ""; public bool IsCaptured { get; set; }}We will store bounties in a DbContext.
public class CantinaContext : DbContext{ public DbSet<Bounty> Bounties => Set<Bounty>(); public CantinaContext(DbContextOptions<CantinaContext> options) : base(options) { }}Using DbContext directly in a service keeps things lean and easy to read.
public class BountyService{ private readonly CantinaContext _db; public BountyService(CantinaContext db) => _db = db;
public Task<List<Bounty>> GetOpenAsync() => _db.Bounties.AsNoTracking().Where(b => !b.IsCaptured).ToListAsync();}Why this works well
- Fewer layers to maintain
- Access to EF Core features like tracking, compiled queries, transactions
- Less cognitive overhead for a small or medium app
Where it can bite
- Data access logic can spread across services and controllers
- Harder to enforce consistent query rules
- Mocking DbContext in unit tests is awkward
A thin repository when you need a boundary
If your team wants a single place to enforce query rules or you plan to change the data source later, a thin repository can create a clear seam without reimplementing EF Core.
Keep the interface focused on behavior you actually need.
public interface IBountyRepository{ Task<Bounty?> FindAsync(int id); Task AddAsync(Bounty bounty); Task SaveAsync(CancellationToken ct = default);}The EF implementation should be tiny. Avoid rewriting EF features or duplicating every DbSet method.
public class EfBountyRepository : IBountyRepository{ private readonly CantinaContext _db; public EfBountyRepository(CantinaContext db) => _db = db;
public Task<Bounty?> FindAsync(int id) => _db.Bounties.FindAsync(id).AsTask();}And your service depends on the abstraction, not EF directly.
public class RescueService{ private readonly IBountyRepository _repo; public RescueService(IBountyRepository repo) => _repo = repo;
public Task<Bounty?> GetAsync(int id) => _repo.FindAsync(id);}Tips for a healthy repository
- Keep it thin and honest
- Do not hide EF Core capabilities you need
- Prefer meaningful methods over generic everything
When queries grow up: specification or query objects
As queries become complex, pushing them into a repository method can get unwieldy. A simple specification or query object moves the query logic into a reusable unit.
Here is a minimal specification style.
public interface ISpec<T>{ IQueryable<T> Apply(IQueryable<T> query);}
public class OpenBountiesSpec : ISpec<Bounty>{ public IQueryable<Bounty> Apply(IQueryable<Bounty> q) => q.Where(b => !b.IsCaptured).AsNoTracking();}You can run a spec with DbContext directly or through a repository that accepts specs.
public class QueryService{ private readonly CantinaContext _db; public QueryService(CantinaContext db) => _db = db;
public Task<List<Bounty>> ListAsync(ISpec<Bounty> spec) => spec.Apply(_db.Bounties).ToListAsync();}Why specs help
- Centralizes query logic
- Enables reuse and composition
- Keeps services slim
Transactions and unit of work
DbContext already coordinates changes as a unit of work. You can opt into transactions explicitly when needed.
await using var tx = await _db.Database.BeginTransactionAsync();_db.Bounties.Add(new Bounty { Hunter = "Fett" });await _db.SaveChangesAsync();await tx.CommitAsync();Testing gotchas and options
Unit tests that touch DbContext are easiest with the EF Core InMemory provider, but it does not enforce relational rules like foreign keys. Use it for logic tests, not for query semantics.
var options = new DbContextOptionsBuilder<CantinaContext>() .UseInMemoryDatabase("tests").Options;
using var db = new CantinaContext(options);db.Bounties.Add(new Bounty { Hunter = "Mando" });await db.SaveChangesAsync();
var svc = new BountyService(db);var open = await svc.GetOpenAsync();For realistic behavior, use SQLite in memory or a containerized database in integration tests. That catches query translation issues before production decides to cosplay as chaos.
So which one should you choose
Use direct DbContext when
- The app is small or medium with straightforward queries
- You want full access to EF Core features with minimal ceremony
Use a thin repository when
- You need a seam for testing or swappable persistence
- You want a single place to apply cross cutting rules like soft delete or tenant filters
Use specifications or query objects when
- Queries are complex, reused, or composed
- You want to keep services focused on orchestration, not expression trees
Avoid the heavy generic repository that rewraps every EF method. EF Core already gives you DbSet and change tracking. Rebuilding that is like crafting your own lightsaber only to end up with a flashlight.
Wrap up
Data access is a tradeoff between simplicity, seams, and reuse. Start with direct DbContext for clarity. Add a thin repository if you need a boundary. Reach for specifications when queries get gnarly. Pick the smallest tool that fits today, and your future self will buy you a blue milk.