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.