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.
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.
| Package | Version | Target Framework | Description |
|---|---|---|---|
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 |
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.
-
Find with an entity filter:
Find(filter)andFindAsync(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)andFindAsync<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:
DeleteRangeByIdsAsyncno longer reports a fully successful range as failed, andSoftDeleteRange*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.SetServiceProviderand 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”.
-
New package
Muslim.GenericService.Interface: holdsIService<TEntity>and its twelve part interfaces. Namespaces are unchanged andMuslim.GenericServiceforwards the types with[TypeForwardedTo], so existing code and compiled assemblies keep working. -
Partial class:
Service<TEntity>is split likeRepository<TEntity>intoServices/Read,Services/WriteandServices/SqlOperations. Members and behavior are unchanged.
-
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.
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*orUpdate*, even an emptyAbstractValidator<TDto>. A DTO without one fails validation. -
A per-entity repository (
NoteRepository : Repository<Note>, INoteRepository) is registered byAddAutoInjectionasINoteRepositoryonly. The parameterlessService<T>()needsIRepository<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.Interfacealone.
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
// 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.
Service<TEntity> #
Service<TEntity> is a partial class split by concern, like Repository<TEntity>.
Each file declares the part interface it implements:
| File | Implements |
|---|---|
Services/Service.cs | Constructors, _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)
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()
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; }
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);
}
}
Reading a Result #
Every method except the counts and raw SQL returns Result<T>. Read it instead of wrapping calls in try/catch:
| Member | Meaning |
|---|---|
IsSuccess | true when the call worked. A missing row from GetById, FirstOrDefault or SingleOrDefault is still a success. |
Response | The value. Default on failure, except CreateAsync<TMap> and UpdateAsync<TMap> validation failures, which echo the DTO. |
Error | List<string>: the exception message (and inner message), or one entry per failed item in range operations. null on success. |
ValidatorError | List<ValidatorError> with PropertyName and ErrorMessage when validation failed. |
Page | Count, PageSize, NumberOfPages for Find*; all null when pageSize is 0. |
Filter | Set 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.
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)
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
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;
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
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;
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:
| Overload | Rows returned | Page.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, projected | All rows matching the filter |
Find<TMap>(IFilter<TMap>) | Filter applied to the projection | Rows returned (page length) |
Find(condition, pageSize), Find<TMap>(condition, pageSize, selector) | Every match, unpaged | All matches |
Find<TMap>(Expression<Func<TMap, bool>>, selector, pageSize) | Every match, unpaged | All matches |
Task<Result<List<TMap>>> FindAsync<TMap>(IFilter<TEntity> filter) where TMap : IDto
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)
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)
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!)
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;
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
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);
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;
IAnyService Overview #
Existence checks. These return plain values, not Result<T>:
Task<int> AnyAsync(Expression<Func<TEntity, bool>> condition)
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();
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);
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
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)
var result = await users.CreateAsync<UserDto, CreateUserDto>(request); // in: CreateUserDto, out: UserDto
Task<Result<List<TMap>>> CreateRangeAsync<TMap>(IEnumerable<TMap> dtos) where TMap : IDto
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;
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
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;
IDeleteService Overview #
IDeleteService<TEntity> removes rows. Each call commits; a missing id is a failed result.
| Family | Methods | Result |
|---|---|---|
| Single, mapped | Delete<TMap>(entity), DeleteById<TMap>(id), DeleteAsync<TMap>, DeleteByIdAsync<TMap>(id), DeleteByIdAsync(id) | The deleted row |
| Ranges | DeleteRange<TMap>, DeleteRangeAsync<TMap> (one save) | List |
| By ids, sync | DeleteRangeByIds<TMap>: stops at the first failure; earlier deletes stay committed | List or failure |
| By ids, async | DeleteRangeByIdsAsync<TMap>: per item, failures listed in Error | List of successes |
| No return | DeleteNoReturn(id), DeleteNoReturnAsync(id), DeleteRangeNoReturnAsync(ids) | Result<bool> |
| Bulk SQL | DeleteWhereAsync(predicate), DeleteByIdsAsync(ids) | Result<int> affected rows |
Task<Result<List<TMap>>> DeleteRangeByIdsAsync<TMap>(IEnumerable<int> id) where TMap : IDto
// 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.
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
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)
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)
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);
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);
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 emptyAbstractValidator<TDto>is enough. - Fallback: outside a Muslim.Inject scope, the provider given to
ValidateResult.SetServiceProvideris used. With neither, writes return a failed result: “No service provider is configured”. - Ranges:
CreateRange*andUpdateRange*validate every item before saving any of them. - Entities:
CreateAsync(TEntity)is not validated.
Troubleshooting #
| Symptom | Cause 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 size | You 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 returned | Add a global query filter, or filter in the query. |
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 oneSave(). - Cancellation Tokens: async methods do not take a
CancellationTokenin 10.0.1; adding them would break implementers of the interfaces. - Plain-value methods:
Any,Count,ExecuteSql*andFromSqlInterpolatedreturn-1,falseornullon 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: callInjectExtensions.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).
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>