Muslim Framework
Muslim.GenericRepository v10.0.1
Install NuGet
Enterprise Data Access in .NET 10

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.

Core Architecture

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.

PackageVersionTarget FrameworkDescription
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
Release Notes

What's new in 10.0.1 #

Fixed • Tracking & Audit
  • Stale Tracking Release: Update and UpdateRange, 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: IgnoreUpdateProperty now fills and keeps the audit columns like every other update, instead of writing null to CreatedBy and CreatedDate.
Added • AI Agent Guidance
  • Automatic Skill Delivery: Agent guidance is copied on build to .agents/skills/muslim-genericrepository/. See Coding-agent guidance.
Interface Package • XML Documentation
  • Documentation only; no member signature changed.
  • Fixed the XML examples for Find(condition, selector) and the FirstOrDefault<TMap> / SingleOrDefault<TMap> pairs, which previously encountered compiler ambiguity (CS0121) until the lambda parameter was explicitly typed.
Getting Started

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 Dto from Muslim.EntityAndDto and 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 no HttpContext there, so ICurrentUser must still return a user id (a system user).
  • AddAutoInjection registers IProductRepository as scoped. It does not register open generics. To inject IRepository<T> directly, add:
    builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
Dependency Resolution

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>()

SYNC STARTUP
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>()

SYNC MULTI-TENANT
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>(); }
}
Master Contract

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

TRACKED
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

UNTRACKED HIGH-PERF
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();
Change Tracker

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 call Save().
  • 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.
Query Interface

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)

SYNC ENTITY
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

SYNC DTO
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)

ASYNC ENTITY
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

ASYNC DTO
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.

Query Interface

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)

SYNC
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

SYNCDTO
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)

ASYNC
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)

ASYNC
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

ASYNCDTO
public virtual async Task<List<TMap>> GetAllAsync<TMap>() where TMap : IDto
    => await Table.ProjectTo<TMap>(_mapperConfig).ToListAsync();
Filtering & Paging

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)

SYNC
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)

SYNC
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

SYNCDTO FILTER
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

SYNCDTO
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!)

SYNCPROJECTION
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)

SYNCCOMPUTED
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);
Query Interface

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)

SYNC
public virtual TEntity FirstOrDefault(bool isTracking = true)
    => Read(isTracking).FirstOrDefault()!;

TEntity FirstOrDefault(Expression<Func<TEntity, bool>> condition, bool isTracking = true)

SYNC
public virtual TEntity FirstOrDefault(Expression<Func<TEntity, bool>> condition, bool isTracking = true)
    => Read(isTracking).FirstOrDefault(condition)!;

TMap FirstOrDefault<TMap>() where TMap : IDto

SYNCDTO
public virtual TMap FirstOrDefault<TMap>() where TMap : IDto
    => Table.ProjectTo<TMap>(_mapperConfig).FirstOrDefault()!;

TMap FirstOrDefault<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto

SYNCDTO
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

SYNCDTO
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)

SYNCCUSTOM
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);
Query Interface

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)

SYNC
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

SYNCDTO
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

SYNCDTO
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;
Fast-Fail Existence

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()

SYNC
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)

SYNC
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()

ASYNC
public virtual async Task<bool> AnyAsync() => await Table.AnyAsync();

Task<bool> AnyAsync(Expression<Func<TEntity, bool>> condition)

ASYNC
public virtual async Task<bool> AnyAsync(Expression<Func<TEntity, bool>> condition) => await Table.AnyAsync(condition);
Aggregation

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()

SYNC
public virtual int Count() => Table.Count();

int Count(Expression<Func<TEntity, bool>> condition)

SYNC
public virtual int Count(Expression<Func<TEntity, bool>> condition) =>
    Table.Count(condition);

Task<int> CountAsync()

ASYNC
public virtual async Task<int> CountAsync() => await Table.CountAsync();

Task<int> CountAsync(Expression<Func<TEntity, bool>> condition)

ASYNC
public virtual async Task<int> CountAsync(Expression<Func<TEntity, bool>> condition)
    => await Table.CountAsync(condition);
Insert & Audit

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.

PrefixWhat it doesUse 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)

SYNCADD
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)

SYNCADD & SAVE
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

SYNCDTO
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)

SYNCRANGE
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)

SYNCREQUEST-RESPONSE 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;
Update & Detach

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)

SYNCUPDATE
public virtual void Update(TEntity entity)
    => Table.Update(PrepareUpdate(entity));

Marks modified in tracker without saving. Commit later with Save().

void SaveUpdate(TEntity entity)

SYNCUPDATE & SAVE
public virtual void SaveUpdate(TEntity entity)
{
    Table.Update(PrepareUpdate(entity));
    Save();
}

Task SaveUpdateAsync(TEntity entity)

ASYNCUPDATE & SAVE
public virtual async Task SaveUpdateAsync(TEntity entity)
{
    Table.Update(await PrepareUpdateAsync(entity));
    await SaveAsync();
}

void Update<TMap>(TMap dto) where TMap : IDto

SYNCDTO
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)

SYNCAUDIT FIXED
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);
Hard Delete & Bulk

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.

FormReturnsDescription
Delete, DeleteById, DeleteRangeThe removed entity or entitiesReturns the removed object instance
Delete<TMap>, DeleteById<TMap>The removed row mapped to DTOAutomatic AutoMapper projection
*NoReturnvoid or TaskOptimized for zero allocation
DeleteWhereAsyncTask<int>Bulk execution in 1 SQL command via EF Core ExecuteDeleteAsync

TEntity Delete(TEntity entity)

SYNC
public virtual TEntity Delete(TEntity entity)
    => Table.Remove(entity).Entity;

TEntity SaveDeleteById(int id)

SYNCSAVE
public virtual TEntity SaveDeleteById(int id)
{
    var removed = Table.Remove(FindOrThrow(id)).Entity;
    Save();
    return removed;
}

Task<int> DeleteWhereAsync(Expression<Func<TEntity, bool>> predicate)

ASYNCBULK SQL
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);
Soft Delete & Restore

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)

SYNC
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)

SYNCSAVE
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)

ASYNCBULK UPDATE
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)

ASYNCRESTORE
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);
Change Tracker State

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)

SYNC
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)

SYNC
public virtual void ChangeEntityState(int id, EntityState state)
    => SetLocalState(id, state);

Task Detach(TEntity entity)

ASYNC
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)

SYNCNO QUERY
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)

SYNCNO QUERY
public void DetachLocalById(int id)
    => SetLocalState(id, EntityState.Detached);
Raw SQL Execution

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)

SYNCSQL QUERY
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)

SYNCSQL COMMAND
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)

SYNCRAW SQL
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.

Unit of Work

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()

SYNCCOMMIT
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()

ASYNCCOMMIT
public virtual async Task<int> SaveAsync() => await _context.SaveChangesAsync();

void Dispose() — obsolete

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.

Automated Metadata

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.

OperationMaintained ColumnsBehavior
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.

Architectural Guidelines

Good to Know #

  • Never call Dispose(): The DbContext belongs to the request scope and is shared across all repositories in that request.
  • Cancellation Tokens: Async methods do not accept CancellationToken parameters 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).
AI & Coding Agents

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>