If you have started a .NET 8 API project recently, someone on your team has probably asked it: do we wrap EF Core in a Repository, or do we inject the DbContext directly into controllers? Both approaches are used in production codebases. Both have genuine trade-offs. And the debate often generates more heat than signal because the answer depends on the kind of application you are building.
This post examines both options with realistic code, identifies the actual trade-offs across testability, query expressiveness, and team ergonomics, and gives you a concrete decision framework so the conversation stays short next time.
You will walk away with working code for both patterns, a clear set of scenarios where each one earns its keep, and a summary table you can paste into a design doc.
The Repository pattern originates in Domain-Driven Design (Evans, 2003) and describes a collection-like abstraction over a domain's persistence layer. The pattern was widely adopted in .NET during the NHibernate and LINQ-to-SQL era, when data access code was genuinely hard to test in isolation.
Direct DbContext usage became the mainstream recommendation as EF Core matured. DbContext is already an abstraction over database access, DbSet already looks and behaves like a generic in-memory collection, and EF Core's in-memory and SQLite test providers make the DbContext itself testable without a Repository in front of it.
Both approaches build on the same EF Core engine. The difference is whether your controller sees IProductRepository or CatalogDbContext.
Both snippets fetch a filtered, projected list of products. The first uses a Repository interface; the second injects the DbContext directly.
Repository pattern approach
// Abstraction
public interface IProductRepository
{
Task<List<ProductSummary>> GetPageAsync(
string? category, int page, int pageSize, CancellationToken ct = default);
}
// Implementation
public class ProductRepository : IProductRepository
{
private readonly CatalogDbContext _db;
public ProductRepository(CatalogDbContext db) => _db = db;
public Task<List<ProductSummary>> GetPageAsync(
string? category, int page, int pageSize, CancellationToken ct) =>
_db.Products
.Where(p => category == null || p.Category == category)
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProductSummary(p.Id, p.Name, p.Price))
.ToListAsync(ct);
}
// Controller
[ApiController, Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repo;
public ProductsController(IProductRepository repo) => _repo = repo;
[HttpGet]
public Task<List<ProductSummary>> GetAsync(
[FromQuery] string? category, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, CancellationToken ct = default) =>
_repo.GetPageAsync(category, page, pageSize, ct);
}
Direct DbContext approach
// Controller — DbContext injected directly
[ApiController, Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly CatalogDbContext _db;
public ProductsController(CatalogDbContext db) => _db = db;
[HttpGet]
public Task<List<ProductSummary>> GetAsync(
[FromQuery] string? category, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, CancellationToken ct = default) =>
_db.Products
.Where(p => category == null || p.Category == category)
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProductSummary(p.Id, p.Name, p.Price))
.ToListAsync(ct);
}
The query logic is identical. The difference is where it lives (a dedicated class vs. the controller action) and what the controller declares as its dependency.
1. Testability
This is the most cited reason for adding a Repository, but it no longer holds the weight it once did. EF Core ships with an in-memory provider (UseInMemoryDatabase) and integrates cleanly with SQLite in-memory mode for tests that need relational behavior. Both options let you test controller logic without a real database and without mocking an interface.
That said, the Repository interface is still the right choice when you need to test a service class in complete isolation, without spinning up any EF Core infrastructure at all. Mocking IProductRepository takes three lines; setting up an in-memory DbContext takes more.
2. Query expressiveness and the leaky abstraction problem
The Repository pattern works cleanly for simple CRUD. It starts to buckle when requirements grow. Callers start asking for GetByIdWithDetailsAsync, GetPageWithCategoryFilterAsync, GetCountByCategoryAsync, and before long the interface has fifteen methods. The alternative is to return IQueryable<T> from the Repository, which leaks the EF Core abstraction straight through the layer the Repository was supposed to hide.
Direct DbContext usage sidesteps this entirely. The controller or service composes the query inline, using the full EF Core query API including Include, AsSplitQuery, ExecuteUpdateAsync, and PostgreSQL-specific operators exposed by Npgsql.
// A query that a Repository interface handles awkwardly
var result = await _db.Orders
.AsSplitQuery()
.Include(o => o.Lines)
.Where(o => o.Status == OrderStatus.Pending
&& o.PlacedAt < DateTimeOffset.UtcNow.AddDays(-7))
.OrderByDescending(o => o.PlacedAt)
.Select(o => new StaleOrderDto(o.Id, o.CustomerEmail, o.Lines.Count))
.ToListAsync(ct);
3. Code volume, indirection, and onboarding cost
For a ten-entity API, the Repository pattern adds ten interfaces, ten implementation classes, and ten DI registrations. That is not inherently bad, but it is cost that needs to deliver proportional value. On a team of two or three developers building an internal API, the indirection often obscures more than it clarifies. On a larger team with strict bounded-context boundaries, the same interfaces become a meaningful contract between feature teams.
Use the context of your project, not a blanket rule, to make the call.
Choose the Repository pattern when:
- Your domain model is complex and you are applying DDD; repositories map to aggregate roots
- You need to swap data sources (for example, a cache-backed implementation alongside a database-backed one)
- Your team writes service classes with many dependencies and values the ability to mock them in unit tests without any EF Core setup
- Multiple bounded contexts share entity types but need different persistence strategies per context
Choose direct DbContext when:
- You are building a CRUD-heavy or query-heavy API where queries vary widely per endpoint
- Your team is small or the API is a single-bounded-context service
- You want full access to EF Core features without working around interface boundaries
- You use integration tests with a real (or SQLite in-memory) database and do not need interface mocks
A practical middle ground is to keep DbContext in controllers for most operations, but extract a dedicated service class for any business operation that is called from more than one place. That service class can own a partial Repository interface scoped to only the methods it actually needs, rather than a complete repository per entity.
// A scoped service that encapsulates one business operation
// and exposes a minimal interface for testing
public interface IOrderFulfillmentService
{
Task FulfillAsync(int orderId, CancellationToken ct = default);
}
public class OrderFulfillmentService : IOrderFulfillmentService
{
private readonly CatalogDbContext _db;
public OrderFulfillmentService(CatalogDbContext db) => _db = db;
public async Task FulfillAsync(int orderId, CancellationToken ct)
{
var order = await _db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == orderId, ct)
?? throw new InvalidOperationException($"Order {orderId} not found.");
order.Status = OrderStatus.Shipped;
order.FulfilledAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
}
}
| Concern | Repository pattern | Direct DbContext |
|---|---|---|
| Unit testability | Easy to mock with any mocking library | Requires in-memory or SQLite provider setup |
| Integration testability | Works; no advantage over direct DbContext | Clean with EF Core test helpers |
| Query flexibility | Constrained by interface shape; IQueryable leaks | Full EF Core API available inline |
| Code volume | High: interfaces, impls, DI registrations | Low: one class, one dependency |
| DDD fit | Natural; maps to aggregate root access | Awkward for complex domain models |
| EF Core feature access | Limited without leaking IQueryable | Full, including bulk operations |
| Multi-source support | Straightforward via implementation swap | Requires refactoring to add a second source |
The Repository pattern solves a real problem. That problem is not "how do I use EF Core." It is "how do I isolate domain logic from any particular persistence technology." If you are not building for that isolation, the pattern adds cost without delivering its core benefit.
Lean on direct DbContext injection for API projects that are CRUD-heavy, single-context, or built by small teams. Reach for repositories when your domain model is complex, your team practices DDD, or you have a genuine need to swap persistence strategies per context.
- Read the EF Core testing documentation: The official docs cover the SQLite in-memory approach and its trade-offs against full integration tests with a real database.
- Explore the next post in this series: Pagination in a .NET 8 Web API with EF Core and PostgreSQL, covering offset-based and keyset-based approaches and when each one breaks down at scale.
- Try the scoped service pattern: Extract one business operation from a controller into a dedicated service class and write a unit test against its interface. It is a useful calibration exercise for deciding where interface boundaries add real value.
No comments: