LoomKit.Requests.Crud
10.0.1
dotnet add package LoomKit.Requests.Crud --version 10.0.1
NuGet\Install-Package LoomKit.Requests.Crud -Version 10.0.1
<PackageReference Include="LoomKit.Requests.Crud" Version="10.0.1" />
<PackageVersion Include="LoomKit.Requests.Crud" Version="10.0.1" />
<PackageReference Include="LoomKit.Requests.Crud" />
paket add LoomKit.Requests.Crud --version 10.0.1
#r "nuget: LoomKit.Requests.Crud, 10.0.1"
#:package LoomKit.Requests.Crud@10.0.1
#addin nuget:?package=LoomKit.Requests.Crud&version=10.0.1
#tool nuget:?package=LoomKit.Requests.Crud&version=10.0.1
LoomKit.Requests.Crud
Generic CRUD base-handler classes for LoomKit.Requests: abstract Request/Response/Handler triads for Create, GetById, List, UpdateById, DeleteById, Import and "link/unlink related" use cases, plus a repository/unit-of-work contract and SQL-level field projection.
Status: early stage. The public API may still change between versions — pin a commit/tag if you depend on it.
Features
BaseCreateHandler,BaseGetByIdHandler,BaseListHandler,BaseUpdateByIdHandler,BaseDeleteByIdHandler,BaseImportHandler,BaseUpdateRelatedByIdHandler,BaseHandlerWithDto— each pairs a request/response record with a handler that runs anauthorize → validate → ... → commit → build responsepipeline, returningLoomKit.Esits'Esit<TResponse>.- SQL-level field selection:
BaseGetByIdHandlerandBaseListHandlerprune a DTO's projection expression down to only the fields a caller asked for (Selects=Id,Name,User.Email), so EF Core (or anyIQueryableprovider) only selects what's needed. BeforeCommitActivities/AfterCommitActivitieshooks around every mutating handler's commit step, for side effects (publishing events, notifications, cache invalidation, ...).- Bring your own mapping (no bundled mapping library) and your own filtering/ordering (no bundled query-string parser) — see Extensibility below.
Requirements
- .NET 10
LoomKit.Requests.Abstractions—IRequest<T>/IRequestHandler<,>contracts these handlers implementLoomKit.Esits— theEsit/Esit<T>result type used throughout- Your own implementations of
IBaseRepository<TEntity, TEntityId>andIUnitOfWork<TAuditable, TAudit>(e.g. backed by EF Core) - A working
IRequestSenderto actually dispatch requests to these handlers — addLoomKit.Requests(or any otherIRequestSenderimplementation) to your composition-root project - If your response DTOs are shared with client code, add
LoomKit.Requests.Crud.Abstractionsto that shared project to implementIProjectable<TEntity, TDto>— see that package's README
Installation
dotnet add package LoomKit.Requests.Crud
Core concepts
| Type | Kind | Purpose |
|---|---|---|
BaseCreateHandler<...> |
abstract class | Create-entity pipeline: materialize entity from DTO, validate, persist, commit, map to response DTO. |
BaseGetByIdHandler<...> |
abstract class | Fetch-by-id pipeline with SQL-level projection pruning based on Selects. |
BaseListHandler<...> |
abstract class | Filter/order/page/project pipeline returning a paged Data list plus counts. |
BaseUpdateByIdHandler<...> |
abstract class | Fetch-by-id, clone, mutate, validate, commit, map to response DTO. |
BaseDeleteByIdHandler<...> |
abstract class | Fetch-by-id, validate, delete, commit, map to response DTO. |
BaseImportHandler<...> |
abstract class | Bulk prepare/authorize/validate/clean/create pipeline with a single synthetic audit Change. |
BaseUpdateRelatedByIdHandler<...> |
abstract class | Reconciles a principal's related-entity set (link the new ones, unlink the removed ones). |
BaseHandlerWithDto<...> |
abstract class | Generic authorize → validate → process pipeline for use cases that don't fit strict CRUD. |
IBaseRepository<TEntity, TEntityId> |
interface | Minimal repository contract (ListAsync, GetByIdAsync, CreateAsync, DeleteAsync) every handler above depends on. |
IBulkRepository<TEntity, TEntityId> |
interface | Bulk create/delete contract for BaseImportHandler's CreateEntities/CleanEntities hooks. |
IUnitOfWork<TAuditable, TAudit> |
interface | Transactional boundary (SaveChangesAsync, CommitAsync with a per-change audit builder, RollbackAsync). |
Change / ChangeSummary |
model | Audit-trail unit-of-work-tracked mutation record. |
ProjectionPruner |
static class | Prunes a full projection expression down to the fields listed in Selects. |
Quick start
using LoomKit.Esits;
using LoomKit.Requests.Crud.Abstracts;
public record CreateProductDto : BaseCreateResponse<ProductDto>;
public class CreateProductHandler
: BaseCreateHandler<IProductRepository, Product, int, CreateProductRequest, ProductInput, CreateProductResponse, ProductDto, MyAuditable, MyAudit>
{
public CreateProductHandler(IProductRepository repository, IUnitOfWork<MyAuditable, MyAudit> unitOfWork)
: base(repository, unitOfWork) { }
protected override Task<Esit> AuthorizeRequest(CreateProductRequest request) => Esit.Success().AsTask();
protected override Task<Esit> ValidateRequest(CreateProductRequest request) => Esit.Success().AsTask();
protected override Task<Esit> ValidateBusinessRules(Product entity, CreateProductRequest request) => Esit.Success().AsTask();
protected override Task<Esit<Product>> CreateEntity(ProductInput? data) =>
new Product { Name = data!.Name }.ToEsit().AsTask();
protected override ProductDto MapToDto(Product entity) => new() { Id = entity.Id, Name = entity.Name };
}
BaseGetByIdHandler/BaseListHandler read a DTO's own projection expression instead of mapping in memory:
public class GetProductByIdHandler : BaseGetByIdHandler<IProductRepository, Product, int, GetProductByIdRequest, GetProductByIdResponse, ProductDto>
{
protected override Expression<Func<Product, ProductDto>> BuildProjection() => ProductDto.Projection;
protected override string[] DefaultSelects => ProductDto.DefaultSelects;
// AuthorizeRequest / ValidateRequest ...
}
Selects and field projection
BaseGetByIdHandler and BaseListHandler don't map a fully-loaded entity to a DTO in memory — they prune a projection expression down to only the requested fields, then let the query provider (e.g. EF Core) translate that into a SELECT with only those columns.
A DTO declares its full projection and, optionally, a default field set:
using System.Linq.Expressions;
public record ProductDto
{
public int Id { get; init; }
public string Name { get; init; }
public UserDto? User { get; init; }
// EF Core translates this into an optimized SQL SELECT.
// Use property-initializer syntax, not a positional record constructor.
public static Expression<Func<Product, ProductDto>> Projection =>
e => new ProductDto
{
Id = e.Id,
Name = e.Name,
User = new UserDto
{
Email = e.User.Email,
Roles = e.User.Roles.Select(r => new RoleDto
{
Id = r.Id,
Name = r.Name
}).ToList()
}
};
// Fields included by default when the caller doesn't request specific ones.
// Omit (or return []) to always include every field.
public static string[] DefaultSelects => ["Id", "Name"];
}
The Selects request field accepts a comma-separated list of dot-nested paths following the DTO's own property structure (not the entity's):
Id,Name,User.Email,User.Roles.Id,User.Roles.Name
- Simple path:
"Name" - Nested object path:
"User.Email" - Nested collection path:
"User.Roles.Name"
If Selects is null, the DTO's DefaultSelects are used. If DefaultSelects is [], the full projection is returned. Path matching is case-insensitive.
ProjectionPruner only supports property-initializer DTO syntax (new Dto { Prop = ... }), not positional record constructors.
Implementing IProjectable<TEntity, TDto> from LoomKit.Requests.Crud.Abstractions on the DTO is optional — BuildProjection()/DefaultSelects just need to return the right shapes — but it's a convenient way to keep the convention explicit, especially when the DTO lives in a project shared with client code.
Extensibility
- Mapping (
MapToDto):BaseCreateHandler,BaseDeleteByIdHandlerandBaseUpdateByIdHandlercall an abstractMapToDto(TEntity entity)to build the response DTO after a mutation. There's no bundled mapping library — implement it with whatever you already use (a mapping library, a source-generated mapper, or plain manual code). - Filtering and ordering (
BaseListHandler):ApplyRequestFilteringToQueryable/ApplyRequestOrderingToQueryableare abstract — there's no bundled query-string parser. Implement them with whatever filtering/ordering approach you use, or return the queryable unchanged if you don't support it.ApplyRequestPagingToQueryablehas a working default (Skip/Take) you can override if needed. - Commit-time side effects: every mutating handler exposes
BeforeCommitActivities/AfterCommitActivitiesaround its commit step — publish domain events, invalidate caches, etc. from there. - Exception handling: override
HandleExceptionon any handler to customize the error returned on an unhandled exception (the default wraps it as a generic "unexpected" error).
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- LoomKit.Esits (>= 10.0.0)
- LoomKit.Requests.Abstractions (>= 10.0.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.0.1 | 181 | 8/25/2026 |