Generic Repository Pattern C# implementations can centralize repeated EF Core operations, but they are not automatically a better architecture than using DbContext directly. The practical decision is whether your application needs a stable persistence boundary with purposeful operations, or whether another CRUD abstraction would only hide capabilities that EF Core already provides.
This guide preserves the original .NET 8 Staff repository and its working historical sample. It explains how that implementation operates, where it is useful, and which limitations matter before adapting it to production. The code has not been rewritten or presented as a newly executed test against a later .NET version.
Table of Contents
Decide Whether You Need a Generic Repository
EF Core’s DbContext already behaves as a unit of work, and each DbSet<TEntity> provides repository-like access to an entity set. For a straightforward application, injecting a short-lived context into an application service often produces the clearest code because LINQ, change tracking, transactions, and provider-specific behavior remain visible.
A separate repository earns its place when it creates an intentional boundary. Examples include exposing domain-specific operations, isolating persistence from the application layer, or allowing unit tests to replace query results without evaluating EF Core LINQ. It is less useful when every method merely repeats Add, Update, Remove, and ToListAsync.
- Use
DbContextdirectly when the application is small, EF Core is an accepted dependency, and query behavior belongs close to the use case. - Use focused repositories when aggregates or application operations need a stable persistence contract.
- Use a generic repository only when its shared behavior is genuinely uniform across the supported entities.
- Do not choose the pattern solely because it promises automatic ORM replacement. Query semantics, transactions, and provider capabilities rarely become interchangeable through CRUD methods alone.
What the Original .NET 8 Sample Does
The original project builds on the Staff database from the refreshed Entity Framework Code First .NET 8 guide. It adds a Dnc.Staff.Repository class library, references Dnc.Staff.Data, and creates one generic contract for CRUD operations, filtering, and eager loading.

The implementation materializes read queries as IEnumerable<TEntity> and calls SaveChangesAsync inside every write operation. That design is easy to follow in a learning sample, but those choices also define its production limits. The repository is not a transparent replacement for every EF Core feature.

Define the Generic Repository Contract
Create an Interfaces directory in Dnc.Staff.Repository and add the original IGenericRepository.cs contract:
namespace Dnc.Staff.Repository.Interfaces
{
public interface IGenericRepository<TEntity> where TEntity : class
{
Task<IEnumerable<TEntity>> GetAllAsync();
Task<IEnumerable<TEntity>> GetAllIncludeAsync(Expression<Func<TEntity, object>>[] properties);
Task<TEntity> FindByKey(int key);
Task<IEnumerable<TEntity>> FindByAsync(Expression<Func<TEntity, bool>> predicate);
Task<IEnumerable<TEntity>> GetByIncludeAsync(
Expression<Func<TEntity, bool>> predicate,
Expression<Func<TEntity, object>>[] properties);
Task<int> AddAsync(TEntity entity);
Task<int> AddRangeAsync(IEnumerable<TEntity> entities);
Task<int> UpdateAsync(TEntity entity);
Task<int> UpdateRangeAsync(IEnumerable<TEntity> entities);
Task<int> DeleteAsync(TEntity entity);
Task<int> DeleteRangeAsync(IEnumerable<TEntity> entities);
}
}
The contract makes the sample’s intended operations explicit and prevents callers from depending directly on DbSet<TEntity>. It also returns materialized results rather than IQueryable<TEntity>, so a caller cannot append arbitrary LINQ that leaks EF Core query construction through the abstraction.
That boundary has a cost: every new query shape needs another repository operation. Cancellation, pagination, projections, split queries, compiled queries, concurrency handling, and query tags are not represented by this contract. Treat that as a design decision, not as an implementation detail.
Implement the Generic Repository
The original implementation receives StaffDbContext through dependency injection and resolves the correct entity set with Context.Set<TEntity>(). The complete historical implementation is preserved below:
namespace Dnc.Staff.Repository
{
public class GenericRepository<TEntity> : IGenericRepository<TEntity>
where TEntity : class
{
public StaffDbContext Context { get; }
public DbSet<TEntity> Table { get; }
public GenericRepository(StaffDbContext context)
{
Context = context;
Table = Context.Set<TEntity>();
}
public async Task<IEnumerable<TEntity>> GetAllAsync()
{
return await Table.AsNoTracking().ToListAsync();
}
public async Task<IEnumerable<TEntity>> GetAllIncludeAsync(
Expression<Func<TEntity, object>>[] properties)
{
var queryable = Table.AsNoTracking();
foreach (var property in properties)
{
queryable = queryable.Include(property);
}
return await queryable.ToListAsync();
}
public async Task<IEnumerable<TEntity>> GetByIncludeAsync(
Expression<Func<TEntity, bool>> predicate,
Expression<Func<TEntity, object>>[] properties)
{
var queryable = Table.AsNoTracking();
var query = properties.Aggregate(
queryable,
(current, property) => current.Include(property));
return await query.Where(predicate).ToListAsync();
}
public async Task<TEntity> FindByKey(int key)
{
return await Table.AsNoTracking()
.SingleOrDefaultAsync(BuildLambda<TEntity>(key));
}
public async Task<IEnumerable<TEntity>> FindByAsync(
Expression<Func<TEntity, bool>> predicate)
{
return await Table.AsNoTracking()
.Where(predicate)
.ToListAsync();
}
public async Task<int> AddAsync(TEntity entity)
{
await Table.AddAsync(entity);
return await SaveChangesAsync();
}
public async Task<int> AddRangeAsync(IEnumerable<TEntity> entities)
{
await Table.AddRangeAsync(entities);
return await SaveChangesAsync();
}
public async Task<int> DeleteAsync(TEntity entity)
{
Table.Remove(entity);
return await SaveChangesAsync();
}
public async Task<int> DeleteRangeAsync(IEnumerable<TEntity> entities)
{
Table.RemoveRange(entities);
return await SaveChangesAsync();
}
public async Task<int> UpdateAsync(TEntity entity)
{
Table.Update(entity);
return await SaveChangesAsync();
}
public async Task<int> UpdateRangeAsync(IEnumerable<TEntity> entities)
{
Table.UpdateRange(entities);
return await SaveChangesAsync();
}
private static Expression<Func<TItem, bool>> BuildLambda<TItem>(int id)
{
var item = Expression.Parameter(typeof(TItem), "item");
var property = Expression.Property(item, "Id");
var constant = Expression.Constant(id);
var equal = Expression.Equal(property, constant);
return Expression.Lambda<Func<TItem, bool>>(equal, item);
}
private async Task<int> SaveChangesAsync()
{
try
{
return await Context.SaveChangesAsync();
}
catch (Exception ex)
{
throw new InvalidOperationException(ex.Message);
}
}
}
}
AsNoTracking is appropriate for these read-only operations because the returned entities are not intended to be modified and saved through the same tracked instance. The include methods build the query before calling ToListAsync, which keeps filtering and eager loading in the database query rather than in memory.
BuildLambda constructs item => item.Id == id dynamically. It works for the original Staff entities because they use an integer property named Id. It is not a universal key lookup for entities with GUID, string, differently named, or composite keys.
Understand the Important Limitations
The sample demonstrates the pattern, but a production adaptation should address the following boundaries explicitly:
- One save per method: calling
SaveChangesAsyncinside every write prevents the caller from naturally composing several repository operations into one unit of work. A production design may separate mutation from commit. - Hard-coded key convention:
FindByKeyassumes an integer property namedId. EF Core metadata or a focused repository method is safer for other key shapes. - Disconnected updates:
Table.Update(entity)can mark all properties as modified. When values come from a request, load the entity and map allowed fields to reduce overposting and unintended changes. - Lost exception context: throwing a new exception with only
ex.Messagediscards the original stack and inner exception. Production code should preserve the exception or translate only known persistence failures while retaining diagnostic context. - No cancellation or pagination: every list operation can read an unbounded result set and cannot observe a request cancellation token.
- No concurrency policy: the contract does not expose or handle optimistic concurrency tokens and
DbUpdateConcurrencyException. - Testing still needs the database: repository stubs can test application decisions, but translated LINQ, constraints, transactions, and provider behavior still require integration tests against the real database engine.
These limitations do not mean the historical code never worked. They show the difference between a focused learning sample and a persistence boundary that owns production behavior.
Choose a Better Boundary for Complex Queries
A generic CRUD interface becomes strained when a use case needs projections, aggregates, paging, conditional includes, raw SQL, temporal queries, or provider-specific features. Returning IQueryable<TEntity> appears to solve that problem, but it moves EF Core query construction back into the caller and weakens the abstraction.
A more durable option is to expose operations that describe the application job, such as GetDepartmentStaffPageAsync or FindProjectsForManagerAsync. The repository can then own projection, tracking mode, includes, ordering, page size, cancellation, and the query’s performance contract. Not every entity needs a dedicated repository; create one where the domain or use case justifies it.
For commands that span several aggregates, keep one scoped DbContext for the unit of work and commit at the application boundary. EF Core contexts are designed for a short unit of work and are not thread-safe, so do not share one across concurrent operations.
Verify the Repository Before Production
Verification should prove both the application’s decision logic and the database behavior. A useful minimum plan is:
- Run the original Staff sample and confirm that each CRUD operation changes the expected rows.
- Enable EF Core command logging in a development environment and inspect the SQL generated by filters and includes.
- Test empty results, duplicate matches, missing keys, and entities whose key does not follow the sample’s
int Idconvention. - Send a cancellation token through any production read that can be expensive or request-bound.
- Execute integration tests against the same database provider used in production; do not assume EF Core InMemory reproduces relational behavior.
- Run two conflicting updates when concurrency matters and verify the intended retry, merge, or rejection policy.
- Confirm that multi-step writes either commit together or roll back together.
- Verify logs preserve the original exception type, stack, and provider details without exposing secrets to clients.
The expected evidence is concrete: the correct rows and relationships, bounded SQL, a reproducible concurrency outcome, and an atomic result for multi-step writes. Passing mocked repository tests alone is not enough to establish those properties.
Current Compatibility Status
The repository and sample code were created for .NET 8 and verified when the original article was published in September 2024. This editorial refresh preserves that implementation and does not claim it was rebuilt against a newer framework. Before using the sample in a maintained application, align the target framework, EF Core packages, database provider, and tooling versions, then run the verification plan above.
The complete historical project remains available in the original GitHub repository. Use it to inspect the code in context, not as proof that every production concern described in this refresh has already been implemented.
Conclusion
A generic repository can remove repeated EF Core operations and create a useful persistence boundary when its behavior is genuinely shared. It should not be an automatic layer in every application, because DbContext already supplies repository and unit-of-work behavior and exposes EF Core’s query capabilities directly.
The original Staff sample remains a clear demonstration of the pattern. For production use, decide who owns the unit of work, replace hard-coded key assumptions where necessary, preserve exception context, add cancellation and bounded queries, define concurrency behavior, and verify database semantics against the real provider.
References
- Microsoft Learn: DbContext lifetime, configuration, and initialization
- Microsoft Learn: Choosing a testing strategy for EF Core
- Microsoft Learn: Testing without the production database
- Microsoft Learn: Testing against the production database system
- Microsoft Learn: Handling EF Core concurrency conflicts
- Microsoft Learn: Implementing the persistence layer with EF Core
Found this useful? Support more practical developer content.