.NET 9 - Generic Repository Pattern With EF Core (Clean Architecture)

Introduction
Write a repository for Product, then Customer, then Order, and by the third one you are copy-pasting the same five methods: get all, get by id, add, update, delete. The generic repository pattern factors that repetition into a single Repository<T> that works for any entity, so you only write code for the queries that are actually specific to an aggregate.
This video builds it inside a Clean Architecture solution on .NET 9: an API layer, an Application layer using MediatR for CQRS, a Domain layer with the entities, and a Persistence layer holding EF Core and the repositories, backed by PostgreSQL. The pattern is small; the value is in where the pieces sit and how they connect.
š¬ Watch the full video here:
The layers, and which one owns what
- Domain - entities and a marker interface. No EF Core reference.
- Application - the repository interfaces, plus MediatR commands, queries, and handlers. Depends on Domain only.
- Persistence - the
DbContext, the repository implementations, and the DI registration. Depends on Application and Domain. - API - controllers that do nothing but send MediatR messages, plus
Program.cswiring.
The rule that makes this Clean Architecture and not just folders: the interface lives in Application, the implementation lives in Persistence, and Application never references Persistence. Dependencies point inward.
The generic interface
Every entity implements a tiny marker so the repository can constrain its type parameter and know there is an Id:
public interface IBaseEntity
{
Guid Id { get; set; }
}
public interface IGenericRepository<T> where T : IBaseEntity
{
Task<IEnumerable<T>> GetAllAsync();
Task<T?> GetByIdAsync(Guid id);
Task<T> AddAsync(T entity);
Task<T> UpdateAsync(T entity);
Task<bool> DeleteAsync(Guid id);
}
The implementation uses DbContext.Set<T>()
The one EF Core trick that makes the whole pattern work is Set<T>(), which returns the DbSet for any entity type at runtime without you naming it:
public async Task<IEnumerable<T>> GetAllAsync()
=> await _context.Set<T>().ToListAsync();
public async Task<T?> GetByIdAsync(Guid id)
=> await _context.Set<T>().FindAsync(id);
One class, and it now handles CRUD for Product, Customer, Order, and anything you add later.
Registering an open generic
You do not register IGenericRepository<Product>, IGenericRepository<Customer>, and so on. You register the open generic once and the container closes it per request:
services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));
Now any handler can inject IGenericRepository<Product> and get a working repository with zero per-entity registration.
Extending it for queries that are not generic
"Get orders between two dates" is not a CRUD operation and has no place on a generic interface. The pattern's answer is a per-aggregate interface that inherits the generic one and adds only the specific methods:
public interface IOrderRepository : IGenericRepository<Order>
{
Task<IEnumerable<Order>> GetOrdersInRangeAsync(DateTime start, DateTime end);
}
public class OrderRepository : GenericRepository<Order>, IOrderRepository
{
public OrderRepository(ApplicationDbContext context) : base(context) { }
public Task<IEnumerable<Order>> GetOrdersInRangeAsync(DateTime start, DateTime end) => /* ... */;
}
OrderRepository gets all five CRUD methods by inheritance and adds the one custom query. This is the sweet spot of the pattern: generic where things repeat, specific where they do not.
Where MediatR fits
Controllers are thin. ProductsController.GetAll() is one line: Ok(await mediator.Send(new GetAllProductsQuery())). The handler injects IGenericRepository<Product>, calls GetAllAsync(), and projects entities to DTOs so EF Core types never leak out of the Application layer. Commands (CreateProductCommand, UpdateProductCommand, DeleteProductCommand) follow the same shape. CQRS here is not about separate databases - it is just one message and one handler per use case.
Is the generic repository worth it?
It is a genuinely debated pattern, and the criticism has merit: DbContext and DbSet<T> are already a repository and unit of work, so a thin generic wrapper can be pure ceremony. It earns its place when you want a hard boundary that keeps EF Core out of your Application layer, a single place to add cross-cutting behavior (soft delete, auditing, tenant filtering), and easy test doubles. If you do not need those, injecting DbContext into handlers directly is a defensible choice.
Common pitfalls
- Leaking
IQueryableout of the repository. ReturningIQueryable<T>lets callers compose queries and defeats the abstraction. Return materialized results or take explicit filter parameters. - A generic method for every possible filter.
GetAllAsync(Expression<Func<T,bool>> predicate, ...)grows into a worseDbSet. Keep the generic interface CRUD-only; put real queries on specific interfaces. - Registering closed generics one by one. Use the open-generic
typeof(IGenericRepository<>)registration. - Calling
SaveChangesAsyncper operation with no unit of work. Fine here, but if one use case touches multiple aggregates you want a single transaction, which is a separate unit-of-work discussion.
Key Takeaways
- A generic repository removes the repeated CRUD you would otherwise write per entity, using
DbContext.Set<T>()to resolve theDbSetat runtime. - Register it as an open generic:
AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>)). - Non-CRUD queries go on a per-aggregate interface that inherits the generic one, so you keep both convenience and specificity.
- In Clean Architecture the interface lives in Application and the implementation in Persistence; Application never references Persistence.
- Handlers project entities to DTOs so EF Core types stay inside the Persistence and Application boundary.
- The pattern is optional - it pays off when you want a firm EF Core boundary and a place for cross-cutting behavior, not as a reflex.
Get the Full Source Code
The complete runnable solution - all four layers, the generic and order repositories, the MediatR commands and queries, the PostgreSQL DbContext and migrations, and the controllers - is available to Patreon supporters. If you want to run the full CQRS flow instead of rebuilding it from the walkthrough above, you can find it on Patreon.