HomeNikola Knezevic

In this article

Banner

Generic Repositories in .NET

06 Aug 2026
5 min

Special Thanks to Our Sponsors:

Sponsor Logo

EF Core is too slow? Discover how you can easily insert 14x faster (reducing saving time by 94%).

Boost your performance with our method within EF Core: Bulk Insert, update, delete and merge.

Thousands of satisfied customers have trusted our library since 2014.

👉 Learn more

Sponsor Newsletter

When building applications with EF Core, the same data access operations show up again and again. Add, update, remove and get all appear across almost every entity.

Copying that logic into every repository quickly becomes noisy. Small inconsistencies creep in and the same patterns get reimplemented with slight differences.

We want reuse and consistency, without boxing ourselves into a one-size-fits-all abstraction.

That's where a hybrid take on generic repositories comes in.

Generic Repositories

A generic repository centralizes common persistence operations behind a shared interface and implementation.

The goal is straightforward. Reuse CRUD style methods for every entity and keep that behavior consistent.

A typical shape looks like this:

csharp
public interface IRepository<TEntity> where TEntity : class
{
    void Add(TEntity entity);
    void Update(TEntity entity);
    void Remove(TEntity entity);
    Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
    Task<TEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
}

On paper this looks clean. One interface, many entities, less duplicated code.

The Criticism

Generic repositories are often labeled an anti-pattern and for good reason.

Real applications rarely stop at generic CRUD. You need custom queries, existence checks, filtered reads and entity-specific behavior.

Once that happens, the generic interface either grows into a dumping ground or you start casting and leaking query details through awkward extension points.

Another common issue is that a pure generic repository often mirrors DbSet<T>. At that point the abstraction adds ceremony without much value.

Still, throwing away the reusable parts entirely is also wasteful. Common operations do repeat and they should stay consistent.

Hybrid Approach

There is a middle ground. Keep a base repository for shared operations and use specific interfaces for entity-focused methods.

The idea is simple:

  • BaseRepository - Handles reusable operations like Add, Update, Remove and GetAll
  • Specific interface - Declares both shared methods and custom ones for that entity
  • Concrete repository - Inherits the base and implements the custom behavior

Callers depend on the specific interface, not on a generic IRepository<T>. That keeps the API intentional while still reusing the boilerplate.

Base Repository

Here is a base repository that wraps common EF Core operations:

csharp
public abstract class BaseRepository<TEntity> where TEntity : class
{
    protected readonly ApplicationDbContext DbContext;

    protected BaseRepository(ApplicationDbContext dbContext) =>
        DbContext = dbContext;

    public void Add(TEntity entity) =>
        DbContext.Set<TEntity>().Add(entity);

    public void Update(TEntity entity) =>
        DbContext.Set<TEntity>().Update(entity);

    public void Remove(TEntity entity) =>
        DbContext.Set<TEntity>().Remove(entity);

    public async Task<IEnumerable<TEntity>> GetAllAsync(
        CancellationToken cancellationToken = default) =>
        await DbContext.Set<TEntity>().ToListAsync(cancellationToken);
}

Notice that it is abstract. You do not inject this type directly. Concrete repositories inherit it and expose a clear interface to the rest of the app.

Also notice that saving is not here. Persistence still goes through a Unit of Work boundary. I've covered that pattern in my blog post on Unit of Work in .NET.

Specific Interface

Each entity gets its own repository interface. Shared methods are declared explicitly, along with anything unique to that entity:

csharp
public interface IProductRepository
{
    Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    Task<IEnumerable<Product>> GetAllAsync(CancellationToken cancellationToken = default);

    void Add(Product entity);
    void Update(Product entity);
    void Remove(Product entity);

    Task<bool> ExistsAsync(Guid id, CancellationToken cancellationToken = default);
}

Methods like ExistsAsync and GetByIdAsync belong here because they are product-specific in shape and intent.

Your application code depends on IProductRepository. It never needs to know that a base class exists underneath.

Concrete Repository

The concrete repository combines both sides. It inherits shared behavior and implements the custom methods:

csharp
public sealed class ProductRepository(ApplicationDbContext dbContext) :
    BaseRepository<Product>(dbContext),
    IProductRepository
{
    public async Task<Product?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default) =>
        await DbContext.Set<Product>()
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);

    public async Task<bool> ExistsAsync(
        Guid id,
        CancellationToken cancellationToken = default) =>
        await DbContext.Set<Product>()
            .AnyAsync(x => x.Id == id, cancellationToken);
}

Add, Update, Remove and GetAllAsync come from the base. GetByIdAsync and ExistsAsync live only where they belong.

Registration stays simple:

csharp
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<IUnitOfWork>(sp =>
    sp.GetRequiredService<ApplicationDbContext>());

And handlers consume the specific interface:

csharp
internal sealed class CreateProductCommandHandler(
    IProductRepository productRepository,
    IUnitOfWork unitOfWork) : IRequestHandler<CreateProductCommand, Result<Guid>>
{
    public async Task<Result<Guid>> Handle(
        CreateProductCommand request,
        CancellationToken cancellationToken)
    {
        var product = new Product(
            Guid.NewGuid(),
            DateTime.UtcNow,
            request.Name,
            request.Description,
            request.Price);

        productRepository.Add(product);

        await unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success(product.Id);
    }
}

The repository tracks the change. The Unit of Work commits it. Responsibilities stay separated.

Conclusion

Generic repositories try to solve reuse and consistency. Used alone, they often become rigid and hide the queries your domain actually needs.

A hybrid approach keeps the useful part. Shared operations live in a base repository, while specific interfaces and concrete classes own entity-focused behavior.

That mix avoids many of the pitfalls of a pure generic repository without giving up reuse.

If you want to check out examples I created, you can find the source code here:

Source Code

I hope you enjoyed it, subscribe and get a notification when a new blog is up!

Subscribe

Stay tuned for valuable insights every Thursday morning.