Muslim Framework
Muslim.GenericService v10.0.1
Install NuGet
Service Layer for .NET 10

Generic CRUD Services that Never Throw for .NET 10

One generic class, Service<TEntity>, wraps IRepository<TEntity> and gives every entity reads, DTO projection, filter paging, validated create and update, soft delete and restore, bulk deletes and counts — all returning a Result<T> your controllers can hand straight back.

> dotnet add package Muslim.GenericService

Result<T> Everywhere

Repository exceptions become a failed result with messages instead of escaping to the caller.

Validated Writes

DTOs are checked with FluentValidation from the current request scope before anything is saved.

Paging with Totals

Filter-based finds return one page plus the count of every matching row in Page.

13 Modular Interfaces

New in 10.0.1: the contract ships alone in Muslim.GenericService.Interface.

Core Architecture

Overview #

Muslim.GenericService is the service layer that sits on top of Muslim.GenericRepository. One generic class, Service<TEntity>, calls the repository for every operation and returns a Result<T> from Muslim.Result. Repository exceptions are caught and turned into a failed result; DTOs passed to create and update are validated first. Every write commits on its own.

The contract lives in a separate package, Muslim.GenericService.Interface, the same way IRepository<T> lives in Muslim.GenericRepository.Interface. IService<TEntity> is built from twelve small 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.GenericService 10.0.1 net10.0 Service<TEntity>, the implementation, split into partial files by concern
Muslim.GenericService.Interface 10.0.1 net10.0 IService<TEntity> and its twelve part interfaces; no EF Core, no repository
Release Notes

What's new in 10.0.1 #

First .NET 10 release (the last published version is 8.0.0). No public member was added, removed or changed.

Fixed • Results & Counts
  • Find with an entity filter: Find(filter) and FindAsync(filter) no longer fail with “Missing map from X to X”. The call used to bind to the repository's projection overload.
  • Page counts: FindAsync(filter), FindAsync(condition, pageSize) and FindAsync<TMap>(condition, pageSize, selector) count the matching rows instead of the whole table.
  • No more throwing Find: FindAsync(condition, pageSize) returns a failed result instead of rethrowing.
  • Range results: DeleteRangeByIdsAsync no longer reports a fully successful range as failed, and SoftDeleteRange* no longer carries a spurious “not found” error.
  • CreateRange: database errors return a failed result instead of escaping as an exception.
  • UpdateRange: every saved row is detached first (only invalid ones used to be), and the input is enumerated once.
  • Validation scope: validators are resolved from the current Muslim.Inject request scope, so writes no longer fail without ValidateResult.SetServiceProvider and scoped validators work.
  • Missing repository: the error names the missing IRepository<X> registration instead of “Object reference not set to an instance of an object”.
Interface Package • Partial Class
  • New package Muslim.GenericService.Interface: holds IService<TEntity> and its twelve part interfaces. Namespaces are unchanged and Muslim.GenericService forwards the types with [TypeForwardedTo], so existing code and compiled assemblies keep working.
  • Partial class: Service<TEntity> is split like Repository<TEntity> into Services/Read, Services/Write and Services/SqlOperations. Members and behavior are unchanged.
Added • AI Agent Guidance
  • Automatic Skill Delivery: agent guidance is copied on build to .agents/skills/muslim-genericservice/. See Coding-agent guidance.
  • README, XML documentation, package metadata, and SQLite-backed regression tests.
Getting Started

Install & Setup #

Install the service and the repository implementation. The service package depends only on the two interface packages, so the repository itself, and your database provider, are added by you:

dotnet add package Muslim.GenericService
dotnet add package Muslim.GenericRepository
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
using AutoMapper;
using FluentValidation;
using Muslim.EntityAndDto.Dtos;
using Muslim.EntityAndDto.Entitys;

public class Note : Entity
{
    public string Title { get; set; } = "";
}

public class NoteDto : Dto
{
    public string Title { get; set; } = "";
}

public class NoteDtoValidator : AbstractValidator<NoteDto>
{
    public NoteDtoValidator() => RuleFor(x => x.Title).NotEmpty();
}

public class NoteProfile : Profile
{
    public NoteProfile() => CreateMap<Note, NoteDto>().ReverseMap();
}
using FluentValidation;
using Muslim.GenericRepository.Interface;
using Muslim.GenericRepository.Repositories;
using Muslim.GenericService;
using Muslim.Helpers;
using Muslim.Inject;

builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddAutoMapper(typeof(NoteProfile));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, HttpCurrentUser>();
builder.Services.AddValidatorsFromAssemblyContaining<NoteDtoValidator>();   // scoped validators are fine
builder.Services.AddAutoInjection(typeof(INoteService).Assembly);           // INoteRepository, INoteService
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));  // IRepository<T> for any entity
builder.Services.AddScoped(typeof(IService<>), typeof(Service<>));        // IService<T> for any entity
BaseRepository.SetDbContext<AppDbContext>();

var app = builder.Build();
InjectExtensions.SetServiceProvider(app.Services);
app.Use(async (context, next) =>
{
    InjectExtensions.SetServiceProvider(context.RequestServices);   // before anything uses a service
    await next();
});
using Muslim.GenericRepository.Interface;
using Muslim.GenericRepository.Repositories;
using Muslim.GenericService;

public interface INoteRepository : IRepository<Note> { }
public class NoteRepository : Repository<Note>, INoteRepository { }

public interface INoteService : IService<Note>
{
    Task<int> CountArchivedAsync();
}

// Pass the repository to the base constructor.
public class NoteService(INoteRepository repository) : Service<Note>(repository), INoteService
{
    public Task<int> CountArchivedAsync() => CountAsync(x => x.IsDeleted);
}
[ApiController, Route("notes")]
public class NotesController(INoteService notes) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<IActionResult> Get(int id)
    {
        var result = await notes.GetByIdAsync<NoteDto>(id);
        if (!result.IsSuccess) return BadRequest(result);
        return result.Response is null ? NotFound() : Ok(result);
    }

    [HttpPost]
    public async Task<IActionResult> Create(NoteDto request)
    {
        var result = await notes.CreateAsync(request);   // validated, inserted, Id filled
        return result.IsSuccess ? Ok(result) : BadRequest(result);
    }
}

Repository wiring: everything the repository needs (DbContext, AutoMapper, ICurrentUser, BaseRepository.SetDbContext<T>() and the per-request provider) is set up exactly as the Muslim.GenericRepository docs describe. The service adds validators and the two open-generic registrations.

Setup Notes & Best Practices

  • Register a validator for every DTO you pass to Create* or Update*, even an empty AbstractValidator<TDto>. A DTO without one fails validation.
  • A per-entity repository (NoteRepository : Repository<Note>, INoteRepository) is registered by AddAutoInjection as INoteRepository only. The parameterless Service<T>() needs IRepository<T>, so either pass the repository to the base constructor or add the open-generic line.
  • In background work wrap each unit of work in InjectExtensions.RunInScopeAsync; there is no request scope there.
  • Projects that only call services (a controller layer, another library) can reference Muslim.GenericService.Interface alone.
Master Contract

IService Overview #

IService<TEntity> is the full surface for one entity. It lives in Muslim.GenericService.Interface, namespace Muslim.GenericService; the twelve interfaces it inherits are in Muslim.GenericService.Interfaces. Derive a per-entity interface from it to add methods; Muslim.Inject registers the implementation.

public interface IService<TEntity> : IAutoInjection
    , IGetAllService<TEntity>, IFindService<TEntity>, IGetByIdService<TEntity>
    , IFirstOrDefaultService<TEntity>, ISingleOrDefaultService<TEntity>
    , ICreateService<TEntity>, IUpdateService<TEntity>
    , IDeleteService<TEntity>, ISoftDeleteService<TEntity>
    , IAnyService<TEntity>, ICountService<TEntity>, IExecuteSqlService<TEntity>
    where TEntity : Entity

Type forwarding from Muslim.GenericService

10.0.1 BINARY COMPAT
// Muslim.GenericService/TypeForwarders.cs
[assembly: TypeForwardedTo(typeof(Muslim.GenericService.IService<>))]
[assembly: TypeForwardedTo(typeof(IFindService<>))]
// ... one line for each of the 13 interfaces

The interfaces used to be declared in Muslim.GenericService.dll. The forwards let assemblies compiled against that older version find them in the new package without recompiling. Source code needs no change either: the namespaces are the same.

Implementation

Service<TEntity> #

Service<TEntity> is a partial class split by concern, like Repository<TEntity>. Each file declares the part interface it implements:

FileImplements
Services/Service.csConstructors, _repository, the private repository accessor and validation helper
Services/Read/GetById, GetAll, Find, FirstOrDefault, SingleOrDefault, Any, Count
Services/Write/Create, Update, Delete, SoftDelete
Services/SqlOperations/ExecuteSql

Service(IRepository<TEntity> repository)

PREFERRED
public Service(IRepository<TEntity> repository)
{
    _repository = repository;
}

Uses the given repository. Works with constructor injection in a derived service and with fakes in tests.

Service()

AMBIENT
public Service()
{
    _repository = InjectExtensions.Inject<IRepository<TEntity>>();
}

// Every operation goes through this accessor.
private IRepository<TEntity> Repository => _repository ?? throw new InvalidOperationException(
    $"No IRepository<{typeof(TEntity).Name}> is available to {GetType().Name}. Pass one to the constructor " +
    "(for example a per-entity repository injected into a derived service), or register the open generic: " +
    "services.AddScoped(typeof(IRepository<>), typeof(Repository<>)).");

Resolves IRepository<TEntity> from the ambient Muslim.Inject provider. If nothing is registered, the object is still built and every call returns a failed result with the message above.

public IRepository<TEntity> _repository { get; set; }

PROPERTY

The full repository. Derived services use it for anything the service does not wrap: staging several writes and one Save(), or QueryAsNoTracking with custom shapes.

Extending a service

All public methods are virtual. Override one to add rules, and call base to keep validation and error handling:

public class UserService(IUserRepository repository) : Service<User>(repository), IUserService
{
    public override async Task<Result<TMap>> CreateAsync<TMap>(TMap dto)
    {
        if (dto is CreateUserDto c && await AnyAsync(x => x.Email == c.Email) == 1)
            return Result<TMap>.Fail("Email already registered");
        return await base.CreateAsync(dto);
    }
}
Muslim.Result

Reading a Result #

Every method except the counts and raw SQL returns Result<T>. Read it instead of wrapping calls in try/catch:

MemberMeaning
IsSuccesstrue when the call worked. A missing row from GetById, FirstOrDefault or SingleOrDefault is still a success.
ResponseThe value. Default on failure, except CreateAsync<TMap> and UpdateAsync<TMap> validation failures, which echo the DTO.
ErrorList<string>: the exception message (and inner message), or one entry per failed item in range operations. null on success.
ValidatorErrorList<ValidatorError> with PropertyName and ErrorMessage when validation failed.
PageCount, PageSize, NumberOfPages for Find*; all null when pageSize is 0.
FilterSet only by FindAsync<TMap>(IFilter<TEntity>): the filter that ran.

Check both: a successful GetByIdAsync(404) has IsSuccess = true and Response == null. Treat !IsSuccess as an error and Response is null as not found.

Query Interface

IGetByIdService Overview #

IGetByIdService<TEntity> loads one row by primary key, as an entity or as a DTO. A missing id is a success with Response == null.

Result<TEntity> GetById(int id)

SYNC ENTITY
public virtual Result<TEntity> GetById(int id)
{
    try
    {
        var entity = Repository.GetById(id);
        return Result<TEntity>.Success(entity);
    }
    catch (Exception e)
    {
        return Result<TEntity>.Exception(e);
    }
}

The pattern every service method follows: call the repository, wrap the value, turn any exception into a failed result. The entity is tracked.

Task<Result<TMap>> GetByIdAsync<TMap>(int id) where TMap : IDto

ASYNC DTO
public virtual async Task<Result<TMap>> GetByIdAsync<TMap>(int id) where TMap : IDto
{
    try
    {
        var dto = await Repository.GetByIdAsync<TMap>(id);
        return Result<TMap>.Success(dto);
    }
    catch (Exception e)
    {
        return Result<TMap>.Exception(e);
    }
}

Projects through AutoMapper and selects only the DTO's columns. The usual choice for API responses.

All IGetByIdService Overloads

Result<TEntity> GetById(int id);
Task<Result<TEntity>> GetByIdAsync(int id);
Result<TMap> GetById<TMap>(int id) where TMap : IDto;
Task<Result<TMap>> GetByIdAsync<TMap>(int id) where TMap : IDto;
Query Interface

IGetAllService Overview #

IGetAllService<TEntity> loads every row. On a table that grows without limit, page with a filter instead.

Task<Result<List<TMap>>> GetAllAsync<TMap>() where TMap : IDto

ASYNCDTO
public virtual async Task<Result<List<TMap>>> GetAllAsync<TMap>() where TMap : IDto
{
    try
    {
        var dtos = await Repository.GetAllAsync<TMap>();
        return Result<List<TMap>>.Success(dtos);
    }
    catch (Exception e)
    {
        return Result<List<TMap>>.Exception(e);
    }
}

Projects every row to a DTO. The standard choice for read-only lists.

All IGetAllService Overloads

Result<List<TEntity>> GetAll();
Task<Result<List<TEntity>>> GetAllAsync();
Result<List<TMap>> GetAll<TMap>() where TMap : IDto;
Task<Result<List<TMap>>> GetAllAsync<TMap>() where TMap : IDto;
Filtering & Paging

IFindService Overview #

IFindService<TEntity> returns many rows, by an IFilter<T> from Muslim.Filter or by a condition, and fills Page. What Page.Count means depends on the overload:

OverloadRows returnedPage.Count
Find(IFilter<T>), FindAsync(IFilter<T>)One page (filter's sort and paging)All rows matching the filter
Find<TMap>(IFilter<T>), FindAsync<TMap>(IFilter<T>)One page, projectedAll rows matching the filter
Find<TMap>(IFilter<TMap>)Filter applied to the projectionRows returned (page length)
Find(condition, pageSize), Find<TMap>(condition, pageSize, selector)Every match, unpagedAll matches
Find<TMap>(Expression<Func<TMap, bool>>, selector, pageSize)Every match, unpagedAll matches

Task<Result<List<TMap>>> FindAsync<TMap>(IFilter<TEntity> filter) where TMap : IDto

ASYNCDTOPAGED
public virtual async Task<Result<List<TMap>>> FindAsync<TMap>(IFilter<TEntity> filter) where TMap : IDto
{
    try
    {
        var dtos = await Repository.FindAsync<TMap>(filter);
        var count = await filter.ApplyFilterOnly(Repository.Query).CountAsync();
        var result = Result<List<TMap>>.Success(dtos, count, filter.PageSize);
        result.Filter = filter;
        return result;
    }
    catch (Exception e)
    {
        return Result<List<TMap>>.Exception(e);
    }
}

The grid endpoint: one projected page plus the total of every matching row. Two queries run, one COUNT and one page.

public class UserFilter : Filter<User>
{
    public string? Name { get; set; }
    protected override void ApplyFilter() => WhereIfNotEmpty(Name, n => x => x.Name.Contains(n));
}

// GET /users?name=al&pageIndex=1&pageSize=20&sortColumn=Name, bound to a UserFilter
var page = await users.FindAsync<UserDto>(filter);   // Response: 20 rows, Page.Count: total

Task<Result<List<TEntity>>> FindAsync(IFilter<TEntity> filter)

ASYNCFIXED
public virtual async Task<Result<List<TEntity>>> FindAsync(IFilter<TEntity> filter)
{
    try
    {
        // isTracking selects the entity overload; see Find(IFilter<TEntity>).
        var entities = await Repository.FindAsync(filter, isTracking: true);
        // Rows matching the filter, as in Find(filter); this used to count the whole table.
        var count = await filter.ApplyFilterOnly(Repository.Query).CountAsync();
        return Result<List<TEntity>>.Success(entities, count, filter.PageSize);
    }
    catch (Exception e)
    {
        return Result<List<TEntity>>.Exception(e);
    }
}

Fixed in 10.0.1. Without isTracking the repository call bound to FindAsync<TMap>(IFilter<TMap>) with TMap = TEntity and failed with “Missing map from X to X”; the count also covered the whole table.

Task<Result<List<TEntity>>> FindAsync(Expression<Func<TEntity, bool>> condition, int pageSize = 0)

ASYNCFIXED
public virtual async Task<Result<List<TEntity>>> FindAsync(Expression<Func<TEntity, bool>> condition, int pageSize = 0)
{
    try
    {
        var entities = await Repository.FindAsync(condition);
        // Matching rows, as in Find(condition); this used to count the whole table.
        var count = await Repository.CountAsync(condition);
        return Result<List<TEntity>>.Success(entities, count, pageSize);
    }
    catch (Exception e)
    {
        // Was the only Find overload that wrote to the console and rethrew.
        return Result<List<TEntity>>.Exception(e);
    }
}

Returns every match. pageSize only fills Page.NumberOfPages; it never limits the query.

Result<List<TMap>> Find<TMap>(Expression<Func<TEntity, bool>> condition, int pageSize = 0, Expression<Func<TEntity, TMap>> selector = default!)

SYNCSELECTOR

Projects with your own expression instead of AutoMapper:

var items = users.Find<UserListItem>(x => x.IsActive, 0, x => new UserListItem { Id = x.Id, Name = x.Name });

All IFindService Overloads

Result<List<TEntity>> Find(IFilter<TEntity> filter);
Result<List<TMap>> Find<TMap>(IFilter<TMap> filter) where TMap : class, IHaveFilter;
Result<List<TMap>> Find<TMap>(IFilter<TEntity> filter) where TMap : IDto;
Result<List<TEntity>> Find(Expression<Func<TEntity, bool>> condition, int pageSize = 0);
Result<List<TMap>> Find<TMap>(Expression<Func<TEntity, bool>> condition, int pageSize = 0,
    Expression<Func<TEntity, TMap>> selector = default!);
Result<List<TMap>> Find<TMap>(Expression<Func<TMap, bool>> condition,
    Expression<Func<TEntity, TMap>> selector, int pageSize = 0);

Task<Result<List<TEntity>>> FindAsync(IFilter<TEntity> filter);
Task<Result<List<TEntity>>> FindAsync(Expression<Func<TEntity, bool>> condition, int pageSize = 0);
Task<Result<List<TMap>>> FindAsync<TMap>(IFilter<TEntity> filter) where TMap : IDto;
Task<Result<List<TMap>>> FindAsync<TMap>(Expression<Func<TEntity, bool>> condition,
    int pageSize = 0, Expression<Func<TEntity, TMap>> selector = default!);
Task<Result<List<TMap>>> FindAsync<TMap>(Expression<Func<TMap, bool>> condition,
    Expression<Func<TEntity, TMap>> selector, int pageSize = 0);

// On the class only, not on IService<T>:
Task<Result<List<TMap>>> FindAsync<TMap>(IFilter<TMap> filter) where TMap : class, IHaveFilter;
Query Interface

IFirstOrDefaultService Overview #

IFirstOrDefaultService<TEntity> returns the first match, as an entity or projected. No match is a success with Response == null.

CS0121 ambiguity: FirstOrDefaultAsync<TDto>(x => x.Email == email) matches both the entity-side and the DTO-side overload when both types have Email. Type the lambda parameter: (User x) => ... for the entity side, (UserDto d) => ... for the DTO side.

Task<Result<TMap>> FirstOrDefaultAsync<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto

ASYNCDTO
var found = await notes.FirstOrDefaultAsync<NoteDto>((Note x) => x.Title == "final");

Filters on the entity, then projects the first match.

All IFirstOrDefaultService Overloads

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

Task<Result<TEntity>> FirstOrDefaultAsync();
Task<Result<TMap>> FirstOrDefaultAsync<TMap>() where TMap : IDto;
Task<Result<TEntity>> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> condition);
Task<Result<TMap>> FirstOrDefaultAsync<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto;
Task<Result<TMap>> FirstOrDefaultAsync<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto;
Task<Result<TMap>> FirstOrDefaultAsync<TMap>(Expression<Func<TEntity, bool>> condition,
    Expression<Func<TEntity, TMap>> selector);
Query Interface

ISingleOrDefaultService Overview #

ISingleOrDefaultService<TEntity> returns the only match. No match succeeds with null; more than one match is a failed result carrying EF Core's InvalidOperationException message. The same CS0121 rule as FirstOrDefault applies to the <TMap> forms, sync and async.

All ISingleOrDefaultService Overloads

Result<TEntity> SingleOrDefault(Expression<Func<TEntity, bool>> condition);
Result<TMap> SingleOrDefault<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto;
Result<TMap> SingleOrDefault<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto;
Task<Result<TEntity>> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> condition);
Task<Result<TMap>> SingleOrDefaultAsync<TMap>(Expression<Func<TEntity, bool>> condition) where TMap : IDto;
Task<Result<TMap>> SingleOrDefaultAsync<TMap>(Expression<Func<TMap, bool>> condition) where TMap : IDto;
Query Interface

IAnyService Overview #

Existence checks. These return plain values, not Result<T>:

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

ASYNC
public virtual async Task<int> AnyAsync(Expression<Func<TEntity, bool>> condition)
{
    try
    {
        return await Repository.AnyAsync(condition) ? 1 : 0;
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
        return -1;
    }
}

Returns 1 when a row matches, 0 when none does, and -1 on error. Compare with == 1, not as a boolean.

All IAnyService Overloads

int Any(Expression<Func<TEntity, bool>> condition);        // 1, 0, or -1 on error
bool Any();                                                   // false on error
Task<int> AnyAsync(Expression<Func<TEntity, bool>> condition);
Task<bool> AnyAsync();
Query Interface

ICountService Overview #

Row counts, optionally filtered, run as SQL COUNT. Every overload returns -1 on error and writes the exception to the console.

All ICountService Overloads

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

ICreateService Overview #

ICreateService<TEntity> inserts and commits. DTO forms are validated before anything touches the database; the entity form is not. The repository fills the audit columns.

Task<Result<TMap>> CreateAsync<TMap>(TMap dto) where TMap : IDto

ASYNCSAVE
public virtual async Task<Result<TMap>> CreateAsync<TMap>(TMap dto) where TMap : IDto
{
    try
    {
        var validatorError = Validate(dto, out var isValid);

        if (!isValid)
        {
            var result = Result<TMap>.ValidatorFail(validatorError);
            result.Response = dto;
            return result;
        }

        dto = await Repository.SaveCreateAsync<TMap, TMap>(dto);
        return Result<TMap>.Success(dto);
    }
    catch (Exception e)
    {
        return Result<TMap>.Exception(e);
    }
}

Validates, maps to the entity, inserts, and maps back with the generated Id. On a validation failure the DTO is echoed in Response.

Task<Result<TResult>> CreateAsync<TResult, TMap>(TMap dto)

ASYNCMAP IN / OUT
var result = await users.CreateAsync<UserDto, CreateUserDto>(request);   // in: CreateUserDto, out: UserDto

Task<Result<List<TMap>>> CreateRangeAsync<TMap>(IEnumerable<TMap> dtos) where TMap : IDto

ASYNCRANGEFIXED

Validates every DTO first; any invalid one fails the whole call with all ValidatorErrors. Then inserts all in one SaveChanges. Since 10.0.1 a database error (for example a unique index) returns a failed result instead of throwing.

All ICreateService Overloads

Result<TMap> Create<TMap>(TMap dto) where TMap : IDto;
Result<TResult> Create<TResult, TMap>(TMap dto) where TMap : IDto where TResult : IDto;
Result<List<TMap>> CreateRange<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
Task<Result<TMap>> CreateAsync<TMap>(TMap dto) where TMap : IDto;
Task<Result<TEntity>> CreateAsync(TEntity entity);                 // no validation
Task<Result<TResult>> CreateAsync<TResult, TMap>(TMap dto) where TMap : IDto where TResult : IDto;
Task<Result<List<TMap>>> CreateRangeAsync<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
Update

IUpdateService Overview #

IUpdateService<TEntity> updates from DTOs that carry Id. DTOs are validated first, any tracked copy of the row is detached, and the repository keeps CreatedBy/CreatedDate and stamps UpdateBy/UpdateDate. The same DTO is returned, not a re-read row. A missing id is a failed result.

Task<Result<IEnumerable<TMap>>> UpdateRangeAsync<TMap>(IEnumerable<TMap> dtos) where TMap : IDto

ASYNCRANGEFIXED
public virtual async Task<Result<IEnumerable<TMap>>> UpdateRangeAsync<TMap>(IEnumerable<TMap> dtos)
    where TMap : IDto
{
    try
    {
        // Enumerated once: a lazy sequence used to be validated, saved and returned as three
        // different sets of objects.
        var list = dtos.ToList();
        var validatorErrors = new List<ValidatorError>();
        var isValid = true;
        foreach (var dto in list)
        {
            var errors = Validate(dto, out var _isValid);
            if (_isValid) continue;

            validatorErrors.AddRange(errors);
            isValid = isValid && _isValid;
        }

        if (!isValid) return Result<IEnumerable<TMap>>.ValidatorFail(validatorErrors);

        // Detach every row being saved, as UpdateAsync does.
        foreach (var dto in list)
            Repository.ChangeEntityState(dto.Id, EntityState.Detached);

        await Repository.SaveUpdateRangeAsync(list);
        return Result<IEnumerable<TMap>>.Success(list);
    }
    catch (Exception e)
    {
        return Result<IEnumerable<TMap>>.Exception(e);
    }
}

Loading the rows earlier in the same request (for an existence or permission check) no longer causes “already being tracked”.

All IUpdateService Overloads

Result<TMap> Update<TMap>(TMap dto) where TMap : IDto;
Result<IEnumerable<TMap>> UpdateRange<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
Task<Result<TMap>> UpdateAsync<TMap>(TMap dto) where TMap : IDto;
Task<Result<IEnumerable<TMap>>> UpdateRangeAsync<TMap>(IEnumerable<TMap> dtos) where TMap : IDto;
Hard Delete

IDeleteService Overview #

IDeleteService<TEntity> removes rows. Each call commits; a missing id is a failed result.

FamilyMethodsResult
Single, mappedDelete<TMap>(entity), DeleteById<TMap>(id), DeleteAsync<TMap>, DeleteByIdAsync<TMap>(id), DeleteByIdAsync(id)The deleted row
RangesDeleteRange<TMap>, DeleteRangeAsync<TMap> (one save)List
By ids, syncDeleteRangeByIds<TMap>: stops at the first failure; earlier deletes stay committedList or failure
By ids, asyncDeleteRangeByIdsAsync<TMap>: per item, failures listed in ErrorList of successes
No returnDeleteNoReturn(id), DeleteNoReturnAsync(id), DeleteRangeNoReturnAsync(ids)Result<bool>
Bulk SQLDeleteWhereAsync(predicate), DeleteByIdsAsync(ids)Result<int> affected rows

Task<Result<List<TMap>>> DeleteRangeByIdsAsync<TMap>(IEnumerable<int> id) where TMap : IDto

ASYNCPER ITEMFIXED
// Result.Create() starts as a failed "not found" result, so a range where every delete
// succeeded used to come back with IsSuccess = false. AddError below marks real failures.
var result = new Result<List<TMap>> { IsSuccess = true };
result.Response = new List<TMap>();

foreach (var i in id)
{
    try
    {
        var dto = await Repository.SaveDeleteByIdAsync<TMap>(i);
        result.Response.Add(dto);
    }
    catch (Exception e)
    {
        result.AddError(e.Message);
    }
}

return result;

Response holds the rows that were deleted, Error has one entry per failed id, and IsSuccess is false if any failed.

Bulk SQL: DeleteWhereAsync, DeleteByIdsAsync and DeleteRangeNoReturnAsync run a single ExecuteDelete immediately: no change tracker, no interceptors, no SaveChanges override.

Soft Delete & Restore

ISoftDeleteService Overview #

ISoftDeleteService<TEntity> keeps the row and sets IsDeleted, DeletedDate and DeletedBy. Each single and range method detaches a tracked copy of the row first.

Query Filters: soft-deleted rows stay visible unless the DbContext has HasQueryFilter(x => !x.IsDeleted). With such a filter, RestoreByIdAsync and RestoreWhereAsync cannot see the rows and return 0.

Task<Result<TMap>> SoftDeleteByIdAsync<TMap>(int id) where TMap : IDto

ASYNCSAVE
public virtual async Task<Result<TMap>> SoftDeleteByIdAsync<TMap>(int id) where TMap : IDto
{
    try
    {
        Repository.ChangeEntityState(id, EntityState.Detached);
        var dto = await Repository.SaveSoftDeleteByIdAsync<TMap>(id);
        return Result<TMap>.Success(dto);
    }
    catch (Exception e)
    {
        return Result<TMap>.Exception(e);
    }
}

Task<Result<IEnumerable<TMap>>> SoftDeleteRangeByIdsAsync<TMap>(IEnumerable<int> ids)

ASYNCPER ITEMFIXED

Processes each id separately: successes in Response, one Error per failure, IsSuccess false if any failed. Since 10.0.1 a fully successful call has Error == null (it used to carry a “not found” message). The same applies to SoftDeleteRange, SoftDeleteRangeByIds and SoftDeleteRangeAsync.

Task<Result<int>> RestoreByIdAsync(int id)

ASYNCRESTORE

Clears the deletion flag in one SQL statement and returns the affected row count. RestoreWhereAsync(predicate) restores every match.

All ISoftDeleteService Overloads

// Return mapped DTO
Result<TMap> SoftDelete<TMap>(TEntity entity) where TMap : IDto;
Result<TMap> SoftDeleteById<TMap>(int id) where TMap : IDto;
Result<IEnumerable<TMap>> SoftDeleteRange<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
Result<IEnumerable<TMap>> SoftDeleteRangeByIds<TMap>(IEnumerable<int> id) where TMap : IDto;
Task<Result<TMap>> SoftDeleteAsync<TMap>(TEntity entity) where TMap : IDto;
Task<Result<TMap>> SoftDeleteByIdAsync<TMap>(int id) where TMap : IDto;
Task<Result<IEnumerable<TMap>>> SoftDeleteRangeAsync<TMap>(IEnumerable<TEntity> entities) where TMap : IDto;
Task<Result<IEnumerable<TMap>>> SoftDeleteRangeByIdsAsync<TMap>(IEnumerable<int> id) where TMap : IDto;

// No return
Result<bool> SoftDeleteNoReturn(int id);
Task<Result<bool>> SoftDeleteNoReturnAsync(int id);
Task<Result<bool>> SoftDeleteRangeNoReturnAsync(IEnumerable<int> ids);

// Bulk SQL and restore
Task<Result<int>> SoftDeleteWhereAsync(Expression<Func<TEntity, bool>> predicate);
Task<Result<int>> SoftDeleteByIdsAsync(IEnumerable<int> ids);
Task<Result<int>> RestoreByIdAsync(int id);
Task<Result<int>> RestoreWhereAsync(Expression<Func<TEntity, bool>> predicate);
Raw SQL

IExecuteSqlService Overview #

Raw SQL through the repository. The execute methods return affected rows, or -1 on error; FromSqlInterpolated returns entities, or null on error.

SQL injection: use the interpolated forms ($"... {value}" becomes a parameter) or pass parameters to ExecuteSqlRaw. Never concatenate user input into the raw string.

All IExecuteSqlService Overloads

Task<int> ExecuteSqlInterpolatedAsync(FormattableString sql);
int ExecuteSqlInterpolated(FormattableString sql);
int ExecuteSqlRaw(string sql, params object[] parameters);
Task<int> ExecuteSqlRawAsync(string sql, params object[] parameters);
IEnumerable<TEntity> FromSqlInterpolated(FormattableString sql);
Muslim.Validator

Validation #

Create* and Update* run FluentValidation through Muslim.Validator before writing. Validators are resolved from the current Muslim.Inject request scope, so scoped validators (the default of AddValidatorsFromAssemblyContaining) work:

// Services/Service.cs
private static List<ValidatorError> Validate<TDto>(TDto dto, out bool isValid)
    => InjectExtensions.TryInject<IServiceProvider>(out var provider)
        ? ValidateResult.Errors(dto, provider, out isValid)
        : ValidateResult.Errors(dto, out isValid);
  • A validator for every DTO: without an IValidator<TDto> the call fails with “No validators were found” / “No Property To Validate”. An empty AbstractValidator<TDto> is enough.
  • Fallback: outside a Muslim.Inject scope, the provider given to ValidateResult.SetServiceProvider is used. With neither, writes return a failed result: “No service provider is configured”.
  • Ranges: CreateRange* and UpdateRange* validate every item before saving any of them.
  • Entities: CreateAsync(TEntity) is not validated.
Diagnostics

Troubleshooting #

SymptomCause and fix
Error: “No IRepository<X> is available to XService”Parameterless constructor and no IRepository<> registration. Pass the repository to the base constructor, or add AddScoped(typeof(IRepository<>), typeof(Repository<>)).
ValidatorError: “No validators were found”No IValidator<TDto> registered for that DTO. Add one.
Error: “No service provider is configured”Neither the per-request Muslim.Inject provider nor ValidateResult.SetServiceProvider is set. Add the middleware.
Error: “Cannot resolve scoped service ... from root provider”The ambient provider is the root. Add SetServiceProvider(context.RequestServices) per request, or use RunInScopeAsync.
CS0121 on FirstOrDefaultAsync<TDto> / SingleOrDefault<TDto>Type the lambda parameter: (User x) => ... or (UserDto d) => ....
Page.Count equals the page sizeYou used Find<TMap>(IFilter<TMap>). Filter the entity type and project with Find<TMap>(IFilter<TEntity>).
Error: “Missing map from X to Y”AutoMapper has no CreateMap<X, Y>(). Add it to a profile.
Soft-deleted rows still returnedAdd a global query filter, or filter in the query.
Architectural Guidelines

Good to Know #

  • One call, one transaction: every service write commits on its own. For several writes in one transaction, use the repositories' staging methods (Create, Update, Delete) and one Save().
  • Cancellation Tokens: async methods do not take a CancellationToken in 10.0.1; adding them would break implementers of the interfaces.
  • Plain-value methods: Any, Count, ExecuteSql* and FromSqlInterpolated return -1, false or null on error and write the exception to the console.
  • Unit Testing: construct the service with a repository (new NoteService(fakeRepository)), or use SQLite in memory with the full wiring. The ambient provider is process-wide: call InjectExtensions.Reset() in setup and teardown and disable test parallelization.
  • AutoMapper advisory: AutoMapper 12.0.1, which arrives with Muslim.GenericRepository, has a published high-severity advisory (GHSA-rvv3-g6hj-g44x).
AI & Coding Agents

Coding-agent Guidance #

New in 10.0.1: when a project that references Muslim.GenericService builds, a short guide for AI coding agents (SKILL.md plus two references) is copied to .agents/skills/muslim-genericservice/ at the repository root. Installing or restoring alone does nothing; start a new agent session after the first build.

Agents that read .agents/skills (such as Codex, GitHub Copilot in VS Code, Gemini CLI, Cursor and OpenCode) can use it. Claude Code reads .claude/skills/ instead; copy the folder there if you use it. Your own files, edited files, other skills and AGENTS.md are never overwritten, and removing the package does not delete the folder.

To disable automatic skill generation, add this to your project file or Directory.Build.props:

<PropertyGroup>
  <MuslimGenericServiceGenerateSkill>false</MuslimGenericServiceGenerateSkill>
</PropertyGroup>