The repository pattern draws a persistence boundary around named read/write operations — not a generic GetAll wrapper around every DbSet. EF Core's DbContext is already a unit of work.
Handler → IOrderReadRepository.GetDetailAsync()
│
▼
DbContext (scoped)
projection / includes
New to this → stay here. Merging a PR → when repositories help. On-call / interview → when they hurt · decision guide · if an interviewer asks.
Early in my career, every data access class had an interface named IRepository<T>. Generic methods for GetAll, GetById, Add, Update, Delete. It felt clean until I watched a junior developer fight IQueryable leakage, duplicate EF Core includes across three repositories, and write GetByIdWithDetails variants until the interface was a mini ORM.
EF Core is already a repository and unit of work. DbContext tracks changes; DbSet<T> is a collection gateway. The question is not "repository yes or no." It is what boundary you are drawing and whether that boundary reduces coordination cost for your team.
This post is how I use the repository pattern in C# on real ASP.NET Core products — healthcare claim workflows, marketplace catalog and orders, multi-tenant SaaS — and where I leave EF Core exposed in application services.
Repository pattern in C# with EF Core
The repository design pattern in C# is a class (usually behind an interface) that hides how rows are loaded and saved. With EF Core that class talks to DbContext.
A named window at the bank (“reserve stock for this SKU”) earns its keep. A master key to every vault (IRepository<T>.GetAll) is how juniors Include the world. Three shapes:
| Shape | Example | Verdict |
|---|---|---|
| Generic CRUD wrapper | IRepository<T> + GetAll | Overkill — duplicates DbSet |
| Named feature repository | IOrderReadRepository.GetDetailAsync | Useful when the query repeats |
| Persistence port | IReportExportStore | Useful when you might swap Dapper / blobs |
public interface IInventoryRepository
{
Task ReserveStockAsync(Guid sku, int qty, Guid tenantId, CancellationToken ct);
}
public sealed class InventoryRepository(AppDbContext db) : IInventoryRepository
{
public async Task ReserveStockAsync(Guid sku, int qty, Guid tenantId, CancellationToken ct)
{
var row = await db.Stock.SingleAsync(
s => s.Sku == sku && s.TenantId == tenantId, ct);
if (row.Available < qty)
throw new InsufficientStockException(sku);
row.Available -= qty;
await db.SaveChangesAsync(ct);
}
}
When to use: the same complex query appears in two handlers, or tenant filters must live in one place.
When not to: wrapping every DbSet in IRepository<T> on day one. Inject DbContext (or IApplicationDbContext) and be honest about LINQ. If you are about to add GetByIdWithDetails2, stop and use a specification.
What people mean by "repository"
Usually one of three things:
- Generic CRUD wrapper —
IRepository<T>with LINQ exposed or hidden - Feature-specific repository —
IOrderRepositorywithGetOrderDetailForInvoiceAsync - Persistence port — an interface that hides EF entirely behind domain-friendly methods
Only the second and third consistently help mature codebases. The generic wrapper often adds files without adding clarity.
When repositories help
Complex queries reused across handlers
On a healthcare SaaS project, "active cases for clinician dashboard" involved tenant filter, assignment rules, status exclusions, and a projection with six related entities. Three MediatR query handlers needed the same shape with small variations.
A dedicated ICaseReadRepository with methods like GetBoardItemsAsync(ClinicianBoardFilter filter) centralized the EF Core query, indexes, and AsNoTracking behavior. Handlers stayed thin; performance tuning had one home.
That is a repository worth having: named read operations that encode business language, not GetAll.
Testing application logic without SQL
When a service must orchestrate "reserve inventory, create order, emit event," I mock IOrderRepository and IInventoryRepository at meaningful method boundaries — not mock DbSet<Order>.
Integration tests still hit SQL Server (or Testcontainers). Unit tests for orchestration stay fast when repositories represent use-case-shaped operations.
Swapping persistence (rare but real)
I have replaced blob metadata storage and read models backed by Dapper while keeping handler signatures stable. The port was IReportExportStore, not IRepository<ExportJob>. Abstraction followed a proven need, not a template.
Multi-tenant guardrails
Repositories can enforce TenantId filters in one place so individual queries do not forget .Where(x => x.TenantId == _tenant.Id). In marketplace seller tools, that prevented a class of cross-tenant data leaks during rapid feature work — provided the repository methods were the encouraged path for reads.
When repositories hurt
Generic IRepository<T> on every entity
You end up with:
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(Guid id, CancellationToken ct);
Task AddAsync(T entity, CancellationToken ct);
// ... Update, Delete, Find, Count
}
Handlers either call _repo.GetById and still need ten includes, or they reach for specifications, or they bypass the repository with a raw DbContext injection "just this once." The abstraction leaked the moment queries became interesting.
If every method is CRUD, inject DbContext (or a thin IApplicationDbContext interface) into handlers and use EF Core directly. Be honest about LINQ.
Hiding IQueryable poorly
Returning IQueryable<T> from a repository pushes composition upstack and makes unit tests meaningless. Hiding it completely but offering twenty specialized methods recreates the database in C#. The balance is methods that return concrete DTOs or domain results for each use case worth naming.
Unit of work duplication
EF Core's SaveChangesAsync already batches transactions. Wrapping it in IUnitOfWork that merely forwards to the same context is boilerplate unless you coordinate multiple contexts or non-EF resources in one transaction.
I use IUnitOfWork when a single application operation touches EF and an outbox table, or multiple bounded contexts. I skip it when one DbContext suffices.
How I structure data access today
For a typical mid-size ASP.NET Core API:
Application/
Orders/
Queries/
GetOrderDetailHandler.cs // may call IOrderReadRepository
Commands/
PlaceOrderHandler.cs // uses IOrderRepository + domain services
Infrastructure/
Persistence/
ApplicationDbContext.cs
Repositories/
OrderReadRepository.cs
OrderWriteRepository.cs
Sometimes read and write repositories split because read models use projections and writes load aggregates. That is CQRS-lite, not mandatory repository religion.
Example read repository method:
public async Task<OrderDetailDto?> GetDetailAsync(Guid orderId, Guid tenantId, CancellationToken ct)
{
return await _db.Orders
.AsNoTracking()
.Where(o => o.Id == orderId && o.TenantId == tenantId)
.Select(o => new OrderDetailDto(
o.Id,
o.Status,
o.Lines.Select(l => new OrderLineDto(l.Sku, l.Qty)).ToList()))
.FirstOrDefaultAsync(ct);
}
The handler does not know about Include. The repository name documents intent.
EF Core features I do not hide
Migrations, configurations, and indexes live in Infrastructure. Global filters for soft delete and tenant id sit on the DbContext. Transactions wrap commands at the handler or behavior layer.
I do not wrap every DbSet call in a repository file "for consistency." Consistency that slows delivery without reducing bugs is not architecture — it is habit.
Specification pattern as a middle ground
When queries compose dynamically (marketplace catalog filters: category, price range, seller rating, in-stock only), pure one-method-per-query repositories explode. I use specifications or query objects:
public record ProductSearchSpec(ProductFilter Filter, Guid TenantId);
public static class ProductQueries
{
public static IQueryable<ProductListItemDto> Apply(
this IQueryable<Product> query,
ProductSearchSpec spec) => /* filtered projection */;
}
Invoked from a handler with _db.Products.Apply(spec). Still testable, still one LINQ pipeline, without pretend generic repositories.
Decision guide I use in code reviews
| Signal | Approach |
|---|---|
| Simple CRUD, few queries | DbContext in handlers |
| Repeated complex reads | Feature read repository |
| Write aggregate with invariants | Repository or domain service + context |
| Dynamic filter grids | Specification / query object |
Generic IRepository<T> everywhere | Stop and justify each interface |
Real product examples
Healthcare intake: Write side loads PatientCase aggregate roots with explicit methods; read side uses ICaseReadRepository projections for boards. PHI access logging hooks live on the read repository methods that power export and bulk views.
eCommerce checkout: PlaceOrderHandler coordinates inventory reservation and payment initiation. Repositories expose ReserveStockAsync and CreateOrderAsync — verbs, not entities. Refund flows reuse the same order repository with different authorization.
Marketplace seller portal: Seller-scoped repositories always take SellerId from the current user context, not from request body parameters. That convention blocked an entire category of IDOR mistakes.
Bottom line
The repository pattern is useful when it names meaningful persistence operations, centralizes complex EF Core, and supports testing at real boundaries. It is overkill when it duplicates DbSet, pretends LINQ is hidden, or multiplies files without isolating change.
On .NET products with EF Core, I default to direct context access for simple paths and introduce feature-specific repositories when query complexity, reuse, or tenant safety demands a single home. Generic repositories for every entity are a smell I push back on in every review.
If an interviewer asks
Whether DbContext is already a repository; when IRepository<T> is overkill; repository vs specification.
Strong answer: DbContext + DbSet already provide collection access and change tracking. Add a repository when a complex query repeats across handlers or tenant filters must live in one place. Generic CRUD interfaces that leak IQueryable or grow twenty GetByIdWith* methods are ceremony without benefit — use specs or query objects for dynamic filters instead.