Modern EF Core Generic Repository for .NET 10
One unified generic class, Repository<TEntity>, gives every entity high-performance reads, safe tracked writes, automated UTC auditing, bulk SQL operations, and AutoMapper DTO projections out of the box.
dotnet add package Muslim.GenericRepository
.NET 10 Ready
Targeted for net10.0 with maximum C# 13/14 language optimizations.
Automatic Audit
CreatedBy, UpdateBy, DeletedBy and UTC dates filled transparently on every write.
Smart Tracker Release
New in 10.0.1: automatically detaches stale tracked instances during updates.
15 Modular Interfaces
Fine-grained segregated interfaces for queries, mutations, bulk and raw SQL.
Overview #
Muslim.GenericRepository is an enterprise-grade EF Core repository for .NET 10. One generic class,
Repository<TEntity>, gives every table reads, writes, soft delete, bulk operations and raw SQL,
and fills CreatedBy, UpdateBy, DeletedBy and their dates on every write.
The contract lives in a separate package, Muslim.GenericRepository.Interface.
IRepository<TEntity> is built from fifteen small, single-responsibility interfaces; each section below covers one of them,
with the implementation as it ships in 10.0.1 and when to use it.
| Package | Version | Target Framework | Description |
|---|---|---|---|
Muslim.GenericRepository |
10.0.1 | net10.0 |
Concrete EF Core repository implementation and internal tracking helpers |
Muslim.GenericRepository.Interface |
10.0.1 | net10.0 |
Contracts and segregated interfaces without dependency on EF Core internals |
What's new in 10.0.1 #
-
Stale Tracking Release:
UpdateandUpdateRange, from an entity or a DTO, no longer throw “another instance with the same key value is already being tracked” when the row was loaded earlier in the same context. The old unchanged or modified copy is released and the new one attached. A pending insert or delete of that row is never dropped. -
Audit Column Retention:
IgnoreUpdatePropertynow fills and keeps the audit columns like every other update, instead of writingnulltoCreatedByandCreatedDate.
-
Automatic Skill Delivery: Agent guidance is copied on build to
.agents/skills/muslim-genericrepository/. See Coding-agent guidance.
- Documentation only; no member signature changed.
-
Fixed the XML examples for
Find(condition, selector)and theFirstOrDefault<TMap>/SingleOrDefault<TMap>pairs, which previously encountered compiler ambiguity (CS0121) until the lambda parameter was explicitly typed.
Install & Setup #
Install the NuGet package using the .NET CLI. It brings Muslim.GenericRepository.Interface,
Muslim.EntityAndDto, Muslim.Helpers, Muslim.Inject and AutoMapper 12 with it.
The database provider is not included, so add the one you use:
dotnet add package Muslim.GenericRepository
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
using AutoMapper;
using Muslim.EntityAndDto.Dtos;
using Muslim.EntityAndDto.Entitys;
using Muslim.GenericRepository.Interface;
using Muslim.GenericRepository.Repositories;
public class Product : Entity
{
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
public class ProductDto : Dto
{
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
public class ProductProfile : Profile
{
public ProductProfile() => CreateMap<Product, ProductDto>().ReverseMap();
}
public interface IProductRepository : IRepository<Product> { }
internal class ProductRepository : Repository<Product>, IProductRepository { }
using Microsoft.EntityFrameworkCore;
using Muslim.Helpers;
using Muslim.Inject;
using Muslim.GenericRepository.Repositories;
builder.Services.AddDbContext<ShopContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddAutoMapper(typeof(ProductProfile).Assembly);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, HttpCurrentUser>(); // your class, reads the user from the request
builder.Services.AddAutoInjection(typeof(IProductRepository).Assembly); // IProductRepository -> ProductRepository
BaseRepository.SetDbContext<ShopContext>();
var app = builder.Build();
InjectExtensions.SetServiceProvider(app.Services);
app.Use(async (context, next) =>
{
InjectExtensions.SetServiceProvider(context.RequestServices); // before anything uses a repository
await next();
});
public class ProductService(IProductRepository products)
{
public Task<ProductDto> GetAsync(int id)
=> products.GetByIdAsync<ProductDto>(id);
public async Task<int> CreateAsync(CreateProductDto dto)
{
var product = await products.SaveCreateAsync(new Product { Name = dto.Name, Price = dto.Price });
return product.Id;
}
public Task DeleteAsync(int id)
=> products.SaveSoftDeleteByIdAsync(id);
}
global using Muslim.GenericRepository.Interface; // IRepository<T>
global using Muslim.IGenericRepository; // ICreate<T>, IFind<T>, ...
global using Muslim.GenericRepository.Repositories; // Repository<T>, BaseRepository
global using Muslim.EntityAndDto.Entitys; // Entity
global using Muslim.EntityAndDto.Dtos; // Dto
global using Muslim.Helpers; // ICurrentUser
global using Muslim.Inject; // AddAutoInjection, InjectExtensions
Current User Resolution: You also need a class that implements ICurrentUser from Muslim.Helpers, registered as scoped. The repository resolves it on first write and stores UserId.ToString() in CreatedBy, UpdateBy and DeletedBy. If it is missing, the first write throws InvalidOperationException naming the type.
Setup Notes & Best Practices
- DTOs inherit
DtofromMuslim.EntityAndDtoand need an AutoMapper map configured from the entity. -
The middleware that sets the provider per request is required. Without it every request shares one
DbContext, or startup fails with “Cannot resolve scoped service”. - Place that middleware before any middleware that touches a repository, such as authentication handlers that load users or tenant resolution.
-
In background work or queued hosted services, wrap each unit of work in
InjectExtensions.RunInScopeAsync. There is noHttpContextthere, soICurrentUsermust still return a user id (a system user). -
AddAutoInjectionregistersIProductRepositoryas scoped. It does not register open generics. To injectIRepository<T>directly, add:
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
BaseRepository Overview #
BaseRepository resolves what every repository needs: the DbContext, AutoMapper and the current
user. Repositories have a parameterless constructor, so these are pulled from the ambient provider set by
InjectExtensions.SetServiceProvider. Everything is resolved on first use, not in the constructor, so a
repository that is built but never queried opens no connection.
static void SetDbContext<TDbContext>()
public static void SetDbContext<TDbContext>() => _defaultDbContextType = typeof(TDbContext);
Sets the default context for every repository that does not choose its own. Call it once at startup in Program.cs. If it is never
called, the first query throws “No DbContext registered”.
protected void UseContext<TDbContext>()
protected void UseContext<TDbContext>()
{
_contextType = typeof(TDbContext);
_dbContext = null;
}
Binds one repository to another context. Call it from that repository's constructor. The choice is per instance, so it never changes the context of other repositories running at the same time:
internal class TenantRepository : Repository<Tenant>, ITenantRepository
{
public TenantRepository() { UseContext<TenantDbContext>(); }
}
IRepository Overview #
IRepository<TEntity> is the full surface for one entity. It inherits every interface below and adds
two IQueryable entry points for anything the named methods do not cover: joins, grouping, paging and custom
ordering.
public interface IRepository<TEntity> : IAutoInjection
, IGetById<TEntity>, IGetAll<TEntity>, IFind<TEntity>
, IFirstOrDefault<TEntity>, ISingleOrDefault<TEntity>
, ICreate<TEntity>, IUpdate<TEntity>, IEntityState<TEntity>
, IDelete<TEntity>, ISoftDelete<TEntity>
, ICount<TEntity>, IAny<TEntity>
, ISqlOperations<TEntity>, ISave<TEntity>
where TEntity : Entity
IQueryable<TEntity> Query
public virtual IQueryable<TEntity> Query => Table;
The table as a tracked query. Nothing runs until you enumerate it (ToList, FirstOrDefault,
CountAsync), so compose freely first. Entities you load through it can be edited and saved.
IQueryable<TEntity> QueryAsNoTracking
public virtual IQueryable<TEntity> QueryAsNoTracking => Table.AsNoTracking();
The read-only counterpart. Results are not watched by the change tracker, so it is faster and lighter, and edits to them are ignored on save:
var page = await repo.QueryAsNoTracking
.Where(x => x.Price > 0)
.OrderBy(x => x.Name)
.Skip(20).Take(20)
.ToListAsync();
Tracking and isTracking #
Every read that returns entities takes bool isTracking = true. All of them go through one unified helper:
protected IQueryable<TEntity> Read(bool isTracking)
=> isTracking ? Table : Table.AsNoTracking();
true(default): the result is tracked. Edit it and callSave().false: the result is not tracked. Faster on large results you only read; edits are ignored on save.- Reads that return a DTO are never tracked, so they have no such switch.
IGetById Overview #
IGetById<TEntity> loads one row by primary key, as an entity or as a DTO. Each returns
null when no row has that id.
TEntity GetById(int id, bool isTracking = true)
public virtual TEntity GetById(int id, bool isTracking = true)
=> isTracking
? Table.Find(id)!
: Table.AsNoTracking().FirstOrDefault(x => x.Id == id)!;
Tracked reads use EF Core's Find, which returns an already-loaded instance without a query.
Find is tracking-only, so the untracked path queries by key instead and always hits the database.
TMap GetById<TMap>(int id) where TMap : IDto
public virtual TMap GetById<TMap>(int id) where TMap : IDto
=> Table.ProjectTo<TMap>(_mapperConfig).SingleOrDefault(x => x.Id == id)!;
Projects through AutoMapper and selects only the columns the DTO needs. The usual choice for API responses.
Task<TEntity> GetByIdAsync(int id, bool isTracking = true)
public virtual async Task<TEntity> GetByIdAsync(int id, bool isTracking = true)
=> isTracking
? (await Table.FindAsync(id))!
: (await Table.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id))!;
Async counterpart of the entity overload.
Task<TMap> GetByIdAsync<TMap>(int id) where TMap : IDto
public virtual async Task<TMap> GetByIdAsync<TMap>(int id) where TMap : IDto
=> (await Table.ProjectTo<TMap>(_mapperConfig).SingleOrDefaultAsync(x => x.Id == id))!;
Async counterpart of the DTO overload.
IGetAll Overview #
IGetAll<TEntity> loads every matching row. On a table that grows without limit, page with
Query or filter with Find instead.
List<TEntity> GetAll(bool isTracking = true)
public virtual List<TEntity> GetAll(bool isTracking = true)
=> Read(isTracking).ToList();
Loads the whole table. Pass false for lists you only display.
List<TMap> GetAll<TMap>() where TMap : IDto
public virtual List<TMap> GetAll<TMap>() where TMap : IDto
=> Table.ProjectTo<TMap>(_mapperConfig).ToList();
Projects every row to a DTO. The standard choice for read-only list screens.
Task<List<TEntity>> GetAllAsync(bool isTracking = true)
public virtual async Task<List<TEntity>> GetAllAsync(bool isTracking = true)
=> await Read(isTracking).ToListAsync();
Task<List<TEntity>> GetAllAsync(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
public virtual async Task<List<TEntity>> GetAllAsync(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
=> await Read(isTracking).Where(condition).ToListAsync();
Loads only the rows that match. For example, all active rows, untracked:
var active = await repository.GetAllAsync(x => !x.IsDeleted, isTracking: false);
Task<List<TMap>> GetAllAsync<TMap>() where TMap : IDto
public virtual async Task<List<TMap>> GetAllAsync<TMap>() where TMap : IDto
=> await Table.ProjectTo<TMap>(_mapperConfig).ToListAsync();
IFind Overview #
IFind<TEntity> returns many rows, by a condition or by an IFilter<T> from
Muslim.Filter carrying the paging, sorting and search of a grid. Overloads that return entities take
isTracking; overloads that project do not.
List<TEntity> Find(IFilter<TEntity> filter, bool isTracking = true)
public virtual List<TEntity> Find(IFilter<TEntity> filter, bool isTracking = true)
=> filter.Apply(Read(isTracking)).ToList();
Applies the filter's condition, ordering and paging to the table.
List<TEntity> Find(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
public virtual List<TEntity> Find(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
=> Read(isTracking).Where(condition).ToList();
List<TMap> Find<TMap>(IFilter<TMap> filter) where TMap : class, IHaveFilter
public virtual List<TMap> Find<TMap>(IFilter<TMap> filter) where TMap : class, IHaveFilter
=> filter.Apply(Table.ProjectTo<TMap>(_mapperConfig)).ToList();
Projects first, then applies a filter written against the DTO. Use it when the grid's column names are the DTO's.
List<TMap> Find<TMap>(IFilter<TEntity> filter) where TMap : IDto
public virtual List<TMap> Find<TMap>(IFilter<TEntity> filter) where TMap : IDto
=> filter.Apply(Table).ProjectTo<TMap>(_mapperConfig).ToList();
Filters the entity first, then projects. Cheaper of the two filter overloads when the filter only uses real columns.
List<TMap> Find<TMap>(Expression<Func<TEntity, bool>> condition, Expression<Func<TEntity, TMap>> selector = default!)
public virtual List<TMap> Find<TMap>(Expression<Func<TEntity, bool>> condition, Expression<Func<TEntity, TMap>> selector = default!)
=> selector is null
? Table.Where(condition).ProjectTo<TMap>(_mapperConfig).ToList()
: Table.Where(condition).Select(selector).ToList();
Filters on entity columns, then projects. Without a selector AutoMapper builds the projection; with one, you shape the result yourself. When you pass a selector, type the condition's parameter explicitly to prevent CS0121 compiler ambiguity:
var dtos = repository.Find<UserDto>(x => x.IsActive);
var rows = repository.Find((User x) => x.IsActive, x => new { x.Id, x.Name });
List<TMap> Find<TMap>(Expression<Func<TMap, bool>> condition, Expression<Func<TEntity, TMap>> selector)
public virtual List<TMap> Find<TMap>(Expression<Func<TMap, bool>> condition, Expression<Func<TEntity, TMap>> selector)
=> Table.Select(selector).Where(condition).ToList();
Projects first, then filters on the projected shape. Use it for conditions on computed values:
var rows = repository.Find(r => r.Total > 100, x => new { x.Id, Total = x.Qty * x.Price });
Async Twin Overloads
Each overload above has an async twin with the same parameters and behavior:
Task<List<TEntity>> FindAsync(IFilter<TEntity> filter, bool isTracking = true);
Task<List<TEntity>> FindAsync(Expression<Func<TEntity, bool>> condition, bool isTracking = true);
Task<List<TMap>> FindAsync<TMap>(IFilter<TMap> filter) where TMap : class, IHaveFilter;
Task<List<TMap>> FindAsync<TMap>(IFilter<TEntity> filter) where TMap : IDto;
Task<List<TMap>> FindAsync<TMap>(Expression<Func<TEntity, bool>> condition, Expression<Func<TEntity, TMap>> selector = default!);
Task<List<TMap>> FindAsync<TMap>(Expression<Func<TMap, bool>> condition, Expression<Func<TEntity, TMap>> selector);
IFirstOrDefault Overview #
IFirstOrDefault<TEntity> reads the first matching row, or null when there is none.
Use it when several rows may match and any one will do. No ordering is applied by default, so “first” is whatever the database
returns first; order through Query if it must be deterministic.
TEntity FirstOrDefault(bool isTracking = true)
public virtual TEntity FirstOrDefault(bool isTracking = true)
=> Read(isTracking).FirstOrDefault()!;
TEntity FirstOrDefault(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
public virtual TEntity FirstOrDefault(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
=> Read(isTracking).FirstOrDefault(condition)!;
TMap FirstOrDefault<TMap>() where TMap : IDto
public virtual TMap FirstOrDefault<TMap>() where TMap : IDto
=> Table.ProjectTo<TMap>(_mapperConfig).FirstOrDefault()!;
TMap FirstOrDefault<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto
public virtual TMap FirstOrDefault<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto
=> Table.Where(condition).ProjectTo<TMap>(_mapperConfig).FirstOrDefault()!;
Filters on the entity, then projects. The condition can use columns the DTO does not have. This is the faster of the two DTO overloads.
TMap FirstOrDefault<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto
public virtual TMap FirstOrDefault<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto
=> Table.ProjectTo<TMap>(_mapperConfig).FirstOrDefault(condition)!;
Projects first, then filters on the DTO. Use it only for values that exist after mapping.
Avoid Ambiguity (CS0121): The two DTO overloads above take the same lambda shape. Explicitly type the parameter:
repo.FirstOrDefault<ProductDto>((Product x) => x.Price > 0); // filter entity, then map
repo.FirstOrDefault<ProductDto>((ProductDto d) => d.Label == text); // map to dto, then filter
TMap FirstOrDefault<TMap>(Expression<Func<TEntity, bool>> condition, Expression<Func<TEntity, TMap>> selector)
public virtual TMap FirstOrDefault<TMap>(Expression<Func<TEntity, bool>> condition, Expression<Func<TEntity, TMap>> selector)
=> Table.Where(condition).Select(selector).FirstOrDefault()!;
Filters on the entity and shapes the result with your own selector without AutoMapper. Any shape works, even anonymous objects.
Async Twin Overloads
Task<TEntity> FirstOrDefaultAsync(bool isTracking = true);
Task<TEntity> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> condition, bool isTracking = true);
Task<TMap> FirstOrDefaultAsync<TMap>() where TMap : IDto;
Task<TMap> FirstOrDefaultAsync<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto;
Task<TMap> FirstOrDefaultAsync<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto;
Task<TMap> FirstOrDefaultAsync<TMap>(Expression<Func<TEntity, bool>> condition, Expression<Func<TEntity, TMap>> selector);
ISingleOrDefault Overview #
ISingleOrDefault<TEntity> reads the one row that matches. It throws
InvalidOperationException when more than one row matches. Use it where a duplicate means the data is
invalid, such as a unique SKU or user email. Where multiple matches are acceptable, use FirstOrDefault.
TEntity SingleOrDefault(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
public virtual TEntity SingleOrDefault(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
=> Read(isTracking).SingleOrDefault(condition)!;
TMap SingleOrDefault<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto
public virtual TMap SingleOrDefault<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto
=> Table.Where(condition).ProjectTo<TMap>(_mapperConfig).SingleOrDefault()!;
Filters on entity, then maps: repo.SingleOrDefault<UserDto>((User x) => x.Email == email);
TMap SingleOrDefault<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto
public virtual TMap SingleOrDefault<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto
=> Table.ProjectTo<TMap>(_mapperConfig).SingleOrDefault(condition)!;
Async Twin Overloads
Task<TEntity> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> condition, bool isTracking = true);
Task<TMap> SingleOrDefaultAsync<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto;
Task<TMap> SingleOrDefaultAsync<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto;
IAny Overview #
IAny<TEntity> answers “is there at least one matching record?”. The database short-circuits at the first match, making this
substantially faster and cheaper than Count() > 0.
bool Any()
public virtual bool Any() => Table.Any();
Whether the table contains any row, for example before seeding initial data.
bool Any(Expression<Func<TEntity, bool>> condition)
public virtual bool Any(Expression<Func<TEntity, bool>> condition) => Table.Any(condition);
Whether any row matches a predicate. Ideal for guarding uniqueness:
if (repository.Any(x => x.Code == code))
throw new InvalidOperationException("Code already used.");
Task<bool> AnyAsync()
public virtual async Task<bool> AnyAsync() => await Table.AnyAsync();
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> condition)
public virtual async Task<bool> AnyAsync(Expression<Func<TEntity, bool>> condition) => await Table.AnyAsync(condition);
ICount Overview #
ICount<TEntity> counts rows using SELECT COUNT(*) directly in SQL; no entities or columns are loaded into memory.
To ask only whether rows exist, prefer Any.
int Count()
public virtual int Count() => Table.Count();
int Count(Expression<Func<TEntity, bool>> condition)
public virtual int Count(Expression<Func<TEntity, bool>> condition) =>
Table.Count(condition);
Task<int> CountAsync()
public virtual async Task<int> CountAsync() => await Table.CountAsync();
Task<int> CountAsync(Expression<Func<TEntity, bool>> condition)
public virtual async Task<int> CountAsync(Expression<Func<TEntity, bool>> condition)
=> await Table.CountAsync(condition);
ICreate Overview #
ICreate<TEntity> inserts rows. Every method fills CreatedDate (UTC) and
CreatedBy from the current user before the row is added to the tracker. Never set these manually.
| Prefix | What it does | Use it when |
|---|---|---|
Create* |
Adds the row to the EF Core context only. Nothing reaches the database until Save(). |
The insert is one step of several operations that must succeed or fail atomically. |
SaveCreate* |
Adds the row and executes Save() in one roundtrip. |
The insert is the whole task and you need the generated ID immediately. |
TEntity Create(TEntity entity)
public virtual TEntity Create(TEntity entity)
=> Table.Add(ApplyAuditCreate(entity)).Entity;
Stamps audit columns and attaches to the tracker. Generated database ID is assigned after Save().
TEntity SaveCreate(TEntity entity)
public virtual TEntity SaveCreate(TEntity entity)
{
var created = Table.Add(ApplyAuditCreate(entity)).Entity;
Save();
return created;
}
Adds and saves immediately. The returned entity has its primary key populated.
TMap SaveCreate<TMap>(TEntity entity) where TMap : IDto
public virtual TMap SaveCreate<TMap>(TEntity entity) where TMap : IDto
{
var created = Table.Add(ApplyAuditCreate(entity)).Entity;
Save();
return _mapper.Map<TMap>(created);
}
Saves, then maps the result to a DTO. (Note: Create<TMap> without save maps before saving, so its DTO has no ID).
List<TEntity> CreateRange(IEnumerable<TEntity> entities)
public virtual List<TEntity> CreateRange(IEnumerable<TEntity> entities)
{
var list = ApplyAuditCreate(entities);
Table.AddRange(list);
return list;
}
The input is evaluated into a list once, ensuring lazy sources (like LINQ Select) are safely stamped.
TResult SaveCreate<TResult, TMap>(TMap dto)
public virtual TResult SaveCreate<TResult, TMap>(TMap dto)
where TMap : IDto
where TResult : IDto
{
var created = Table.Add(ApplyAuditCreate(_mapper.Map<TEntity>(dto))).Entity;
Save();
return _mapper.Map<TResult>(created);
}
Accepts a request DTO, maps to entity, stamps, saves, and returns the response DTO in one clean statement:
var response = await repo.SaveCreateAsync<ProductDto, CreateProductDto>(request);
All ICreate Overloads Reference
// From an entity
TEntity Create(TEntity entity);
TMap Create<TMap>(TEntity entity) where TMap : IDto;
Task<TEntity> CreateAsync(TEntity entity);
Task<TMap> CreateAsync<TMap>(TEntity entity) where TMap : IDto;
TEntity SaveCreate(TEntity entity);
TMap SaveCreate<TMap>(TEntity entity) where TMap : IDto;
Task<TEntity> SaveCreateAsync(TEntity entity);
Task<TMap> SaveCreateAsync<TMap>(TEntity entity) where TMap : IDto;
List<TEntity> CreateRange(IEnumerable<TEntity> entities);
List<TMap> CreateRange<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
Task<List<TEntity>> CreateRangeAsync(IEnumerable<TEntity> entities);
Task<List<TMap>> CreateRangeAsync<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
List<TEntity> SaveCreateRange(IEnumerable<TEntity> entities);
List<TMap> SaveCreateRange<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
Task<List<TEntity>> SaveCreateRangeAsync(IEnumerable<TEntity> entities);
Task<List<TMap>> SaveCreateRangeAsync<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
// From a DTO
TEntity Create<TMap>(TMap dto) where TMap : IDto;
TResult Create<TResult, TMap>(TMap dto) where TMap : IDto where TResult : IDto;
Task<TEntity> CreateAsync<TMap>(TMap dto) where TMap : IDto;
Task<TResult> CreateAsync<TResult, TMap>(TMap dto) where TMap : IDto where TResult : IDto;
List<TEntity> CreateRange<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
List<TResult> CreateRange<TResult, TMap>(IEnumerable<TMap> dtos) where TMap : IDto where TResult : IDto;
Task<List<TEntity>> CreateRangeAsync<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
Task<List<TResult>> CreateRangeAsync<TResult, TMap>(IEnumerable<TMap> dtos) where TMap : IDto where TResult : IDto;
TEntity SaveCreate<TMap>(TMap dto) where TMap : IDto;
TResult SaveCreate<TResult, TMap>(TMap dto) where TMap : IDto where TResult : IDto;
Task<TEntity> SaveCreateAsync<TMap>(TMap dto) where TMap : IDto;
Task<TResult> SaveCreateAsync<TResult, TMap>(TMap dto) where TMap : IDto where TResult : IDto;
List<TEntity> SaveCreateRange<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
List<TResult> SaveCreateRange<TResult, TMap>(IEnumerable<TMap> dtos) where TMap : IDto where TResult : IDto;
Task<List<TEntity>> SaveCreateRangeAsync<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
Task<List<TResult>> SaveCreateRangeAsync<TResult, TMap>(IEnumerable<TMap> dtos) where TMap : IDto where TResult : IDto;
IUpdate Overview #
IUpdate<TEntity> writes changes to existing rows from an entity or a DTO. Every update fills
UpdateDate (UTC) and UpdateBy, and preserves CreatedBy, CreatedDate
and soft-delete columns by reading them back from the database.
New in 10.0.1: Automatic Stale Tracker Detachment
If another copy of the same row is already tracked (e.g. loaded earlier to verify existence), it is released automatically. Pending inserts or deletes are safely kept.
private TEntity PrepareUpdate(TEntity entity)
{
ApplyAuditUpdate(entity); // reads stored audit columns, stamps UpdateBy/UpdateDate
ReleaseStaleCopy(entity); // new in 10.0.1: detaches stale tracked instances
return entity;
}
private void ReleaseStaleCopy(TEntity entity)
{
var tracked = Table.Local.FindEntry(entity.Id);
if (tracked is null || ReferenceEquals(tracked.Entity, entity)) return;
if (tracked.State is EntityState.Unchanged or EntityState.Modified)
tracked.State = EntityState.Detached;
}
Typical safe workflow in Web APIs:
var product = await repo.GetByIdAsync(dto.Id) ?? throw new KeyNotFoundException();
await repo.SaveUpdateAsync(dto); // previously loaded copy is safely detached, DTO values are updated
void Update(TEntity entity)
public virtual void Update(TEntity entity)
=> Table.Update(PrepareUpdate(entity));
Marks modified in tracker without saving. Commit later with Save().
void SaveUpdate(TEntity entity)
public virtual void SaveUpdate(TEntity entity)
{
Table.Update(PrepareUpdate(entity));
Save();
}
Task SaveUpdateAsync(TEntity entity)
public virtual async Task SaveUpdateAsync(TEntity entity)
{
Table.Update(await PrepareUpdateAsync(entity));
await SaveAsync();
}
void Update<TMap>(TMap dto) where TMap : IDto
public virtual void Update<TMap>(TMap dto) where TMap : IDto
=> Table.Update(PrepareUpdate(_mapper.Map<TEntity>(dto)));
void IgnoreUpdateProperty(TEntity entity, Expression<Func<TEntity, object>> propertyExpression)
public virtual void IgnoreUpdateProperty(TEntity entity, Expression<Func<TEntity, object>> propertyExpression)
{
PrepareUpdate(entity);
var entry = _context.Entry(entity);
entry.State = EntityState.Modified;
entry.Property(propertyExpression).IsModified = false;
}
Updates every column except the specified one. Fixed in 10.0.1: it now fills and preserves the audit columns like every other update.
repo.IgnoreUpdateProperty(user, x => x.PasswordHash);
repo.Save(); // all columns updated, PasswordHash remains untouched, audit columns updated
All IUpdate Overloads
void Update(TEntity entity);
void SaveUpdate(TEntity entity);
Task SaveUpdateAsync(TEntity entity);
void UpdateRange(IEnumerable<TEntity> entities);
void SaveUpdateRange(IEnumerable<TEntity> entities);
Task SaveUpdateRangeAsync(IEnumerable<TEntity> entities);
void Update<TMap>(TMap dto) where TMap : IDto;
void SaveUpdate<TMap>(TMap dto) where TMap : IDto;
Task SaveUpdateAsync<TMap>(TMap dto) where TMap : IDto;
void UpdateRange<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
void SaveUpdateRange<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
Task SaveUpdateRangeAsync<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
void IgnoreUpdateProperty(TEntity entity, Expression<Func<TEntity, object>> propertyExpression);
IDelete Overview #
IDelete<TEntity> removes rows permanently from the database. Where audit history matters, use
soft delete instead. Methods by ID throw KeyNotFoundException when the ID does
not exist.
| Form | Returns | Description |
|---|---|---|
Delete, DeleteById, DeleteRange | The removed entity or entities | Returns the removed object instance |
Delete<TMap>, DeleteById<TMap> | The removed row mapped to DTO | Automatic AutoMapper projection |
*NoReturn | void or Task | Optimized for zero allocation |
DeleteWhereAsync | Task<int> | Bulk execution in 1 SQL command via EF Core ExecuteDeleteAsync |
TEntity Delete(TEntity entity)
public virtual TEntity Delete(TEntity entity)
=> Table.Remove(entity).Entity;
TEntity SaveDeleteById(int id)
public virtual TEntity SaveDeleteById(int id)
{
var removed = Table.Remove(FindOrThrow(id)).Entity;
Save();
return removed;
}
Task<int> DeleteWhereAsync(Expression<Func<TEntity, bool>> predicate)
public virtual async Task<int> DeleteWhereAsync(Expression<Func<TEntity, bool>> predicate)
=> await Table.Where(predicate).ExecuteDeleteAsync();
Bulk delete: executes one direct SQL statement, loads no rows into memory, and requires no Save().
DeleteByIdsAsync(IEnumerable<int> ids) does the same for a batch of primary keys.
All IDelete Overloads
// Sync — return entity
TEntity Delete(TEntity entity);
TEntity SaveDelete(TEntity entity);
TEntity DeleteById(int id);
TEntity SaveDeleteById(int id);
IEnumerable<TEntity> DeleteRange(IEnumerable<TEntity> entities);
IEnumerable<TEntity> SaveDeleteRange(IEnumerable<TEntity> entities);
// Sync — return DTO
TMap Delete<TMap>(TEntity entity) where TMap : IDto;
TMap SaveDelete<TMap>(TEntity entity) where TMap : IDto;
TMap DeleteById<TMap>(int id) where TMap : IDto;
TMap SaveDeleteById<TMap>(int id) where TMap : IDto;
IEnumerable<TMap> DeleteRange<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
IEnumerable<TMap> SaveDeleteRange<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
// Sync — no return
void DeleteNoReturn(TEntity entity);
void SaveDeleteNoReturn(TEntity entity);
void DeleteByIdNoReturn(int id);
void SaveDeleteByIdNoReturn(int id);
void DeleteRangeNoReturn(IEnumerable<TEntity> entities);
void SaveDeleteRangeNoReturn(IEnumerable<TEntity> entities);
// Async
Task<TEntity> DeleteByIdAsync(int id);
Task<TEntity> SaveDeleteAsync(TEntity entity);
Task<TEntity> SaveDeleteByIdAsync(int id);
Task<IEnumerable<TEntity>> SaveDeleteRangeAsync(IEnumerable<TEntity> entities);
Task<TMap> DeleteByIdAsync<TMap>(int id) where TMap : IDto;
Task<TMap> SaveDeleteAsync<TMap>(TEntity entity) where TMap : IDto;
Task<TMap> SaveDeleteByIdAsync<TMap>(int id) where TMap : IDto;
Task<IEnumerable<TMap>> SaveDeleteRangeAsync<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
Task DeleteByIdNoReturnAsync(int id);
Task SaveDeleteNoReturnAsync(TEntity entity);
Task SaveDeleteByIdNoReturnAsync(int id);
Task SaveDeleteRangeNoReturnAsync(IEnumerable<TEntity> entities);
// Bulk — one direct SQL statement
Task<int> DeleteWhereAsync(Expression<Func<TEntity, bool>> predicate);
Task<int> DeleteByIdsAsync(IEnumerable<int> ids);
ISoftDelete Overview #
ISoftDelete<TEntity> retains records in the database while stamping audit flags:
IsDeleted = true, DeletedDate = DateTime.UtcNow and DeletedBy.
It mirrors the clean method conventions of IDelete and adds instantaneous restore methods.
Query Filters: The repository sets the audit flag. To exclude soft-deleted rows from standard queries, add EF Core's query filter: HasQueryFilter(x => !x.IsDeleted). Muslim.ConfigureTable configures this automatically.
TEntity SoftDelete(TEntity entity)
public virtual TEntity SoftDelete(TEntity entity)
=> Table.Update(ApplyAuditSoftDelete(entity)).Entity;
private TEntity ApplyAuditSoftDelete(TEntity entity)
{
entity.IsDeleted = true;
entity.DeletedDate = DateTime.UtcNow;
entity.DeletedBy = CurrentUserId;
return entity;
}
TEntity SaveSoftDeleteById(int id)
public virtual TEntity SaveSoftDeleteById(int id)
{
var updated = Table.Update(ApplyAuditSoftDelete(FindOrThrow(id))).Entity;
Save();
return updated;
}
Task<int> SoftDeleteWhereAsync(Expression<Func<TEntity, bool>> predicate)
public virtual async Task<int> SoftDeleteWhereAsync(Expression<Func<TEntity, bool>> predicate)
{
var userId = CurrentUserId;
var now = DateTime.UtcNow;
return await Table.Where(predicate).ExecuteUpdateAsync(s => s
.SetProperty(e => e.IsDeleted, true)
.SetProperty(e => e.DeletedDate, now)
.SetProperty(e => e.DeletedBy, userId));
}
Executes a single SQL UPDATE without loading entities into memory. SoftDeleteByIdsAsync(ids) operates across an ID collection.
Task<int> RestoreByIdAsync(int id)
public virtual async Task<int> RestoreByIdAsync(int id)
=> await Table.Where(e => e.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(e => e.IsDeleted, false)
.SetProperty(e => e.DeletedDate, (DateTime?)null)
.SetProperty(e => e.DeletedBy, (string?)null));
Clears the deletion flag and timestamps in a single query. RestoreWhereAsync(predicate) restores all matching records.
Restoring with Query Filter Enabled: With a global soft-delete query filter on, restore queries cannot see deleted rows and return 0. Perform restore operations using a repository configured with an unfiltered context (using UseContext<T>()).
All ISoftDelete Overloads
// Sync — return entity
TEntity SoftDelete(TEntity entity);
TEntity SaveSoftDelete(TEntity entity);
TEntity SoftDeleteById(int id);
TEntity SaveSoftDeleteById(int id);
// Sync — return DTO
TMap SoftDelete<TMap>(TEntity entity) where TMap : IDto;
TMap SaveSoftDelete<TMap>(TEntity entity) where TMap : IDto;
TMap SoftDeleteById<TMap>(int id) where TMap : IDto;
TMap SaveSoftDeleteById<TMap>(int id) where TMap : IDto;
// Sync — no return
void SoftDeleteNoReturn(TEntity entity);
void SaveSoftDeleteNoReturn(TEntity entity);
void SoftDeleteByIdNoReturn(int id);
void SaveSoftDeleteByIdNoReturn(int id);
void SoftDeleteRangeNoReturn(IEnumerable<TEntity> entities);
void SaveSoftDeleteRangeNoReturn(IEnumerable<TEntity> entities);
// Async
Task<TEntity> SoftDeleteByIdAsync(int id);
Task<TEntity> SaveSoftDeleteAsync(TEntity entity);
Task<TEntity> SaveSoftDeleteByIdAsync(int id);
Task<TMap> SoftDeleteByIdAsync<TMap>(int id) where TMap : IDto;
Task<TMap> SaveSoftDeleteAsync<TMap>(TEntity entity) where TMap : IDto;
Task<TMap> SaveSoftDeleteByIdAsync<TMap>(int id) where TMap : IDto;
Task SoftDeleteNoReturnAsync(TEntity entity);
Task SaveSoftDeleteNoReturnAsync(TEntity entity);
Task SoftDeleteByIdNoReturnAsync(int id);
Task SaveSoftDeleteByIdNoReturnAsync(int id);
Task SoftDeleteRangeNoReturnAsync(IEnumerable<TEntity> entities);
Task SaveSoftDeleteRangeNoReturnAsync(IEnumerable<TEntity> entities);
// Bulk — direct SQL execution
Task<int> SoftDeleteWhereAsync(Expression<Func<TEntity, bool>> predicate);
Task<int> SoftDeleteByIdsAsync(IEnumerable<int> ids);
// Restore
Task<int> RestoreByIdAsync(int id);
Task<int> RestoreWhereAsync(Expression<Func<TEntity, bool>> predicate);
IEntityState Overview #
IEntityState<TEntity> directly changes how EF Core's change tracker watches an entity you already have, without a
new database roundtrip. Since 10.0.1, updates release stale tracked instances automatically, so manual detachment is rarely required before an update.
void ChangeEntityState(TEntity entity, EntityState state)
public virtual void ChangeEntityState(TEntity entity, EntityState state)
=> SetLocalState(entity.Id, state);
private void SetLocalState(int id, EntityState state)
{
var tracked = Table.Local.FirstOrDefault(e => e.Id == id);
if (tracked is null) return;
_context.Entry(tracked).State = state;
}
void ChangeEntityState(int id, EntityState state)
public virtual void ChangeEntityState(int id, EntityState state)
=> SetLocalState(id, state);
Task Detach(TEntity entity)
public async Task Detach(TEntity entity)
{
var tracked = await Table.FindAsync(entity.Id);
if (tracked is null) return;
_context.Entry(tracked).State = EntityState.Detached;
}
Uses FindAsync, so it may query the database if the row is not loaded yet.
void DetachLocal(TEntity entity)
public void DetachLocal(TEntity entity)
=> SetLocalState(entity.Id, EntityState.Detached);
Detaches the row only if it is already in the local change tracker. Never queries the database.
void DetachLocalById(int id)
public void DetachLocalById(int id)
=> SetLocalState(id, EntityState.Detached);
ISqlOperations Overview #
ISqlOperations<TEntity> executes parameterized raw SQL statements. Always prefer interpolated methods: every {value} in the interpolated string is converted to a secure SQL parameter automatically, preventing SQL injection.
IEnumerable<TEntity> FromSqlInterpolated(FormattableString sql)
public virtual IEnumerable<TEntity> FromSqlInterpolated(FormattableString sql)
=> Table.FromSqlInterpolated(sql).ToList();
var rows = repo.FromSqlInterpolated($"SELECT * FROM Product WHERE Price > {minPrice}");
int ExecuteSqlInterpolated(FormattableString sql)
public virtual int ExecuteSqlInterpolated(FormattableString sql)
=> _context.Database.ExecuteSqlInterpolated(sql);
Executes an action command and returns the number of affected rows. ExecuteSqlInterpolatedAsync is its async version.
int ExecuteSqlRaw(string sql, params object[] parameters)
public virtual int ExecuteSqlRaw(string sql, params object[] parameters)
=> _context.Database.ExecuteSqlRaw(sql, parameters);
Sends raw SQL text. Build string from constants only and pass every parameter in parameters.
ExecuteSqlRawAsync is the async counterpart.
ISave Overview #
ISave<TEntity> commits what the non-save mutation methods added. All repositories in one HTTP request share a single
DbContext instance, so calling Save() once commits pending changes across all repositories atomically.
int Save()
public virtual int Save() => _context.SaveChanges();
Saves changes and returns the number of written rows:
productRepo.Create(product);
orderRepo.Update(order);
productRepo.Save(); // both saved together in one atomic transaction
Task<int> SaveAsync()
public virtual async Task<int> SaveAsync() => await _context.SaveChangesAsync();
void Dispose() — obsolete
[Obsolete("The DbContext is owned by the DI scope and shared with other repositories. " +
"Disposing it here breaks every other repository in the same request; let the scope dispose it.")]
void Dispose();
Do not call it: The DbContext belongs to the request scope; disposing it inside a repository causes the remainder of the request to throw ObjectDisposedException.
Audit Columns #
These columns are automatically maintained by Muslim.GenericRepository. Never set them manually. All timestamps are in UTC; convert to local timezone exclusively for presentation.
| Operation | Maintained Columns | Behavior |
|---|---|---|
| Create | CreatedDate, CreatedBy |
Current UTC timestamp and current user identity |
| Update | UpdateDate, UpdateBy |
Stamps updated timestamp and user. Preserves original Created* and soft-delete values from DB. |
| Soft Delete | IsDeleted, DeletedDate, DeletedBy |
Sets IsDeleted = true with UTC timestamp and actor ID |
| Restore | IsDeleted = false, DeletedDate, DeletedBy |
Sets IsDeleted = false and clears deleted date and actor |
Bulk Methods Note: Bulk methods (DeleteWhereAsync, SoftDeleteByIdsAsync, RestoreByIdAsync) execute directly as single SQL statements without loading rows into the EF Core change tracker, bypassing any entity-level SaveChanges overrides.
Good to Know #
- Never call
Dispose(): TheDbContextbelongs to the request scope and is shared across all repositories in that request. - Cancellation Tokens: Async methods do not accept
CancellationTokenparameters in 10.0.1 yet. - Transactions: There is no explicit custom transaction API needed. To commit several entity changes atomically across multiple repositories, perform your additions/edits and call
Save()once. -
Unit Testing: When writing unit tests with a mock database, prefer a relational engine such as SQLite in memory. EF Core's InMemory database provider does not support SQL bulk operations (
ExecuteUpdateAsync/ExecuteDeleteAsync).
Coding-agent Guidance #
New in 10.0.1: When building a project that references Muslim.GenericRepository, an optimized guidance skill for AI coding assistants is copied automatically to
.agents/skills/muslim-genericrepository/ at the root of your repository.
AI agents that read skills from .agents/skills (such as GitHub Copilot, Codex, Cursor, and Antigravity) use this guidance to write idiomatically correct repository code without guessing method names. Your existing files and other skills are never modified or removed.
To disable automatic skill generation, add this to your project file or Directory.Build.props:
<PropertyGroup>
<MuslimGenericRepositoryGenerateSkill>false</MuslimGenericRepositoryGenerateSkill>
</PropertyGroup>