Repository Pattern in C# with EF Core: Examples and Pitfalls

Last updated: October 18, 2026 · Tested with .NET 10 (SDK 10.0.401), EF Core 10.0.12 with SQLite, xUnit 2.9.3, Moq 4.20.72, Windows 11

Short answer: A repository puts your data access behind an interface (IProductRepository), so business code and controllers don’t touch DbContext and can be unit tested with a mock. With EF Core it’s optional: DbSet<T> is already a repository. I ran the usual tutorial code (a specific repository, a generic Repository<T>, and a specific repository that inherits the generic one) against a real EF Core 10 database. Three things went wrong:

  • Inheriting the generic base turned a soft delete into a hard delete.
  • The generic GetByIdAsync returned deleted rows.
  • A generic Update() un-deleted a deleted row.

The mock-based unit tests passed through all of it. Real output and fixes below.

The Problem a Repository Solves

Here’s a controller that queries EF Core directly. The soft-delete rule (!p.IsDeleted) has to be repeated in every query, and testing the controller needs a database:

public class ProductController(AppDbContext context) : ControllerBase
{
    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var product = await context.Products.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);
        return product == null ? NotFound() : Ok(product);
    }
}

A repository moves that rule to one place and gives the controller an interface it can depend on instead.

A Specific Repository

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public bool IsDeleted { get; set; }
}

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<IEnumerable<Product>> GetAllAsync();
    Task AddAsync(Product product);
    void Update(Product product);
    void Remove(Product product);
}

public class SoftDeleteProductRepository(AppDbContext context) : IProductRepository
{
    public Task<Product?> GetByIdAsync(int id) =>
        context.Products.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);

    public async Task<IEnumerable<Product>> GetAllAsync() =>
        await context.Products.Where(p => !p.IsDeleted).ToListAsync();

    public async Task AddAsync(Product product) => await context.Products.AddAsync(product);
    public void Update(Product product) => context.Products.Update(product);
    public void Remove(Product product) => product.IsDeleted = true; // soft delete
}

The controller now depends only on the interface:

[ApiController]
[Route("api/products")]
public class ProductController(IProductRepository repository) : ControllerBase
{
    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var product = await repository.GetByIdAsync(id);
        return product == null ? NotFound() : Ok(product);
    }
}

Removing product 1 and reading it back (two products seeded, SQLite in memory):

[1] Specific repo after Remove: GetByIdAsync(1) = null, rows in table = 2

The row is still in the table, marked deleted, and the repository no longer returns it. That’s correct.

The Generic Repository, and Where It Breaks

With many entities, tutorials usually introduce a generic base and let specific repositories inherit it:

public class Repository<T>(AppDbContext context) : IRepository<T> where T : class
{
    protected readonly DbSet<T> DbSet = context.Set<T>();

    public async Task<T?> GetByIdAsync(int id) => await DbSet.FindAsync(id);
    public async Task<IEnumerable<T>> GetAllAsync() => await DbSet.ToListAsync();
    public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) =>
        await DbSet.Where(predicate).ToListAsync();
    public async Task AddAsync(T entity) => await DbSet.AddAsync(entity);
    public void Update(T entity) => DbSet.Update(entity);
    public void Remove(T entity) => DbSet.Remove(entity);
}

public class ProductRepository(AppDbContext context) : Repository<Product>(context), IProductRepository
{
    public async Task<IEnumerable<Product>> GetExpensiveProductsAsync(decimal min) =>
        await DbSet.Where(p => p.Price >= min && !p.IsDeleted).ToListAsync();
}

This compiles without a warning: the base class methods satisfy IProductRepository. But they don’t know about soft delete. The same calls as before:

[2] Generic-based repo after Remove: rows in table = 1, row 1 still exists = False
[3] Generic GetByIdAsync(1) on a soft-deleted row = Keyboard (IsDeleted=True), GetAllAsync count = 2

The row is gone. Remove now runs DbSet.Remove and issues a DELETE. Rows that were soft-deleted earlier come back from GetByIdAsync and GetAllAsync. Nothing in the type system flags this. Moving from a specific repository to “generic base plus inheritance” silently changes what Remove means.

Fix 1: make the rule part of the model with a global query filter

protected override void OnModelCreating(ModelBuilder b)
{
    b.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
}
[4] With HasQueryFilter: GetByIdAsync(1) = null, GetAllAsync count = 1, IgnoreQueryFilters count = 2

Every query against Products, including the generic repository’s FindAsync, ToListAsync and any LINQ elsewhere in the app, now excludes deleted rows. When you really need them, for example in an admin restore screen, use IgnoreQueryFilters().

One subtlety I didn’t expect: the filter is applied in SQL, against the values saved in the database. An entity that’s already tracked and changed in memory is still returned:

[4b] Tracked, IsDeleted=true, not saved: FindAsync(1) = Keyboard, FirstOrDefaultAsync = Keyboard

FindAsync returns it straight from the change tracker, and FirstOrDefaultAsync matches the row in the database (still not deleted there), then hands back the tracked instance. Call SaveChanges before you expect a delete to be invisible within the same context.

Fix 2: override Remove in the specific repository

public class Repository<T>(AppDbContext context) : IRepository<T> where T : class
{
    // ...
    public virtual void Remove(T entity) => DbSet.Remove(entity);
}

public class ProductRepository(AppDbContext context) : Repository<Product>(context), IProductRepository
{
    public override void Remove(Product product) => product.IsDeleted = true;   // keep soft delete
    // ...
}

Use both: the query filter guards reads, and the override guards deletes.

Gotcha: a Generic Update() Un-Deletes Rows

A typical PUT endpoint maps the request body to a new Product and calls repository.Update(...). DbSet.Update attaches the object and marks every property as modified, so the generated SQL writes all columns:

[7] UPDATE "Products" SET "IsDeleted" = @p0, "Name" = @p1, "Price" = @p2 WHERE "Id" = @p3 RETURNING 1;
[7] UPDATE "Products" SET "Price" = @p0 WHERE "Id" = @p1 RETURNING 1;

The first statement comes from Update() on a new object. The second comes from loading the entity and changing one property, where change tracking sends only Price. Writing every column means IsDeleted is written too, with the object’s default value, false. I soft-deleted product 2, then sent an update the way a PUT handler would:

var dto = new { Id = 2, Name = "Monitor 27in", Price = 279m };   // e.g. from a PUT request body
repository.Update(new Product { Id = dto.Id, Name = dto.Name, Price = dto.Price });
await db.SaveChangesAsync();
[8] Soft-deleted row after a detached Update(): Name = Monitor 27in, IsDeleted = False

The deleted product is back. The same happens to any column the request doesn’t carry, such as CreatedAt or CreatedBy. Fix: load the entity, copy the allowed fields onto it, and save. Or use ExecuteUpdateAsync for a targeted SET. Keep a generic Update(T) out of the public repository interface.

Unit Testing: Why the Mock Tests Didn’t Catch Any of This

The standard argument for repositories is that you can mock them. That works, and it tests the controller:

[Fact]
public async Task Get_ReturnsNotFound_WhenProductMissing()
{
    var mockRepo = new Mock<IProductRepository>();
    mockRepo.Setup(r => r.GetByIdAsync(99)).ReturnsAsync((Product?)null);

    var controller = new ProductController(mockRepo.Object);
    var result = await controller.Get(99);

    Assert.IsType<NotFoundResult>(result);
}

But a mock replaces the repository, so it can’t find bugs in the repository. I added one test that runs the real ProductRepository against SQLite in memory:

public class ProductRepositoryTests : IDisposable
{
    private readonly SqliteConnection _connection = new("Data Source=:memory:");

    public ProductRepositoryTests() => _connection.Open();
    public void Dispose() => _connection.Dispose();

    private AppDbContext NewContext() =>
        new(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(_connection).Options);

    [Fact]
    public async Task Remove_SoftDeletes_TheRow()
    {
        await using (var db = NewContext())
        {
            await db.Database.EnsureCreatedAsync();
            db.Products.Add(new Product { Id = 1, Name = "Keyboard" });
            await db.SaveChangesAsync();
        }

        await using (var db = NewContext())
        {
            IProductRepository repo = new ProductRepository(db);   // the generic-based repository
            repo.Remove((await repo.GetByIdAsync(1))!);
            await db.SaveChangesAsync();
        }

        await using (var db = NewContext())
            Assert.True(await db.Products.AnyAsync(p => p.Id == 1 && p.IsDeleted));
    }
}

dotnet test with the inherited generic Remove:

Failed RepoApi.Tests.ProductRepositoryTests.Remove_SoftDeletes_TheRow [1 s]
   Assert.True() Failure
Expected: True
Actual:   False
Failed!  - Failed:     1, Passed:     2, Skipped:     0, Total:     3, Duration: 1 s - RepoApi.Tests.dll (net10.0)

And after the override fix:

Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3, Duration: 1 s - RepoApi.Tests.dll (net10.0)

Both mock tests passed in both runs. Mock the repository to test controllers and services; test the repository itself against a real database engine. SQLite in memory takes about a second here. Use the EF Core InMemory provider only with care: it isn’t relational and doesn’t enforce constraints the way a real database does. For more on mocks, see TDD in C# with xUnit and Moq.

Unit of Work, Briefly

Repository methods above don’t call SaveChanges. A unit of work owns the commit, so changes made through several repositories are saved together:

public interface IAppUnitOfWork
{
    IProductRepository Products { get; }
    IRepository<Category> Categories { get; }
    Task<int> CompleteAsync(CancellationToken ct = default);
}

public class AppUnitOfWork(AppDbContext context) : IAppUnitOfWork
{
    public IProductRepository Products { get; } = new ProductRepository(context);
    public IRepository<Category> Categories { get; } = new Repository<Category>(context);
    public Task<int> CompleteAsync(CancellationToken ct = default) => context.SaveChangesAsync(ct);
}

Two mistakes to avoid. Don’t make the unit of work IDisposable and dispose the context: DI owns it, and later code in the same request gets ObjectDisposedException: Cannot access a disposed context instance. And don’t call SaveChanges inside repository methods, because that splits one operation into several transactions. The measured details of both (a rollback after a failed insert, partial writes from two saves) are in Repository and Unit of Work Pattern in C# with EF Core.

Registering It with Dependency Injection

builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IAppUnitOfWork, AppUnitOfWork>();

Repositories must be Scoped, matching the DbContext. A singleton repository would capture one context for the app’s lifetime. The open-generic registration (typeof(IRepository<>)) is explained in C# generics.

Should You Return IQueryable?

  • Return IEnumerable<T>, a list or DTOs (as above) when the repository is meant to own every query. Callers can’t add filters that bypass your rules. The cost is a new method for each new query shape.
  • Return IQueryable<T> when callers need to compose (paging, sorting, projections). Then the repository is mostly a thin wrapper over DbSet, and the global query filter, not the repository, has to enforce rules like soft delete.

Whichever you choose, put cross-cutting rules (soft delete, multi-tenancy) in query filters rather than in repository methods. Rules in methods are easy to bypass, as the generic base showed.

Do You Need a Repository with EF Core?

Often not. DbContext is already a unit of work and DbSet<T> a repository. A repository earns its place when:

  • you want controllers and services testable with plain mocks;
  • queries with domain meaning (GetExpensiveProductsAsync) should live in one place;
  • you might change the data source, or part of it isn’t EF Core (Dapper, an external API).

Skip it, or keep it thin, for small CRUD apps. That’s especially true in vertical slice architecture, where each handler can use the DbContext directly. In Clean Architecture the repository interface usually lives in the application layer and its EF Core implementation in infrastructure.

Checklist

  • Soft delete and tenant rules in HasQueryFilter, not only in repository methods.
  • If a specific repository inherits a generic base, override every method whose meaning differs (Remove).
  • No generic Update(T) for request DTOs: load, copy allowed fields, save.
  • No SaveChanges inside repositories; one commit per operation through the unit of work.
  • Repositories and unit of work registered as Scoped, and never dispose the DbContext yourself.
  • Mock repositories in controller/service tests, and test the repositories against SQLite or a real database.

FAQ

Is the generic repository pattern an anti-pattern?
Not by itself, but a generic base exposes the same operations for every entity, including ones that should behave differently. In this test that turned a soft delete into a hard delete. Keep generic code for entities where the generic behavior is really correct.

Does FindAsync respect global query filters?
When it goes to the database, yes. When the entity is already tracked, it returns the tracked instance without querying, even if your in-memory change would make the filter exclude it (run [4b] above).

Should repository methods be async?
Yes for anything that hits the database (ToListAsync, FirstOrDefaultAsync). Add, Update and Remove only change the tracker, so synchronous versions are fine. The commit is async in the unit of work.

Conclusion

A repository gives you a clean seam for testing controllers and a home for domain queries. The risks are in the details tutorials skip. A generic base quietly replaces specific behavior, a generic Update overwrites columns the caller never sent, and mocks hide both. Put rules like soft delete in the model with a query filter, override what differs, and test repositories against a real database.

References: EF Core global query filters · EF Core change tracking · Testing against your EF Core application · Designing the infrastructure persistence layer

This article is part of the C# tutorials guide (language features, patterns, performance and testing).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *