Roslyn incremental source generator that emits a whole CRUD vertical slice — request records, SlimBus handlers, and
minimal-API endpoint registration — from [CrudCreate]/[CrudUpdate]/[CrudAction]-attributed entity members.
Fluents contracts from
DKNet.SlimBus.Extensions, so a generated and a hand-written feature sit in one
pipeline with one set of interceptors.[Range] shows up in
the request record on the next build instead of drifting.[CrudCreate]s, or two members colliding on
one route fail the build with a specific diagnostic id.Reach for it when writes are mostly “create one, update one field” CRUD. Keep real orchestration (multi-entity transactions, external calls, branching) hand-written as ordinary SlimBus features.
<ItemGroup>
<PackageReference Include="DKNet.SlimBus.Generators" Version="{latest}" PrivateAssets="all" OutputItemType="Analyzer" />
</ItemGroup>
Mark the entity members — this is the only hand-written feature code:
using System.ComponentModel.DataAnnotations;
using DKNet.EfCore.Abstractions.Attributes;
using DKNet.EfCore.Abstractions.Entities;
// Domain project
public class Product : Entity
{
[CrudCreate]
public Product(string name, decimal price) { /* … */ AddEvent<ProductCreated>(); }
[CrudUpdate]
public void UpdatePrice([Range(0, 1_000_000)] decimal price) { /* … */ AddEvent<PriceChanged>(); }
}
using DKNet.EfCore.DtoGenerator;
// API project
[GenerateDto(typeof(Product))]
public partial record ProductDto;
app.MapGroup("/products").MapProductCrud(); // generated extension method
The generator only emits source; the consuming project still needs these at runtime:
Fluents.Requests.IWitResponse<TDto>, IWithKey<TKey>, IHandler<TRequest, TResponse>), NotFoundError, and the
lazy mapper (IMapper.ResultOf) the generated handlers call.IRepositorySpec for persistence and
the Specification<TEntity> base the generated by-id lookup derives from. (Not DKNet.EfCore.Repos, which was
removed.)MapsterMapper) — the IMapper the generated handlers inject.Generated code calls extension members that DKNet.AspCore.Extensions and DKNet.EfCore.Specifications
declare with C# 14 extension(...) blocks. Calling them does not require C# 14 — a consumer at
LangVersion 13 compiles the generated files fine; only declaring such a block does.
Minimum registration for a generated slice to dispatch and auto-save:
using DKNet.EfCore.Specifications; // AddSpecRepo
using Mapster; // TypeAdapterConfig
using MapsterMapper; // IMapper, Mapper
using SlimMessageBus.Host;
using SlimMessageBus.Host.Memory;
using SlimMessageBus.Host.Serialization.SystemTextJson;
builder.Services.AddSingleton<IMapper>(new Mapper(new TypeAdapterConfig()));
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddScoped<DbContext>(p => p.GetRequiredService<AppDbContext>());
builder.Services.AddSpecRepo<AppDbContext>(); // DKNet.EfCore.Specifications
builder.Services
.AddSlimBusEfCoreInterceptor<AppDbContext>() // auto-save after a successful write
.AddSlimMessageBus(mbb => mbb
.AddJsonSerializer()
.AddServicesFromAssembly(typeof(Program).Assembly) // discovers generated + hand-written handlers
.AddChildBus("Memory", mb => mb.WithProviderMemory().AutoDeclareFrom(typeof(Program).Assembly)));
[CrudCreate], [CrudUpdate], [CrudAction]All three attributes live in DKNet.EfCore.Abstractions, so the domain layer takes on no messaging dependency:
[CrudCreate] — on a public constructor or method. At most one per entity (DKCRUDGEN003 otherwise). The
method form has a sharp edge — see the emitted handler below.[CrudUpdate] — on any public instance method; one generated request per marked method.[CrudAction] — a named operation at its own route segment (see below).MapDeleteById covers it generically, and every entity also gets a
Delete{Entity}Request to hang a delete rule on (see Refusing a delete).The generator scans both the compiling project and its referenced assemblies, so an entity in a separate Domain
project is discovered from the API project with nothing redeclared. Referenced assemblies whose name is, or starts
with a dotted prefix of, System, Microsoft, netstandard, mscorlib, FluentResults, Mapster,
SlimMessageBus, Shouldly, or xunit are skipped — the match is exact-or-dot-prefixed, so a project named
Systemic.Domain is still scanned. The entity must implement
DKNet.EfCore.Abstractions.Entities.IEntity<TKey> (DKCRUDGEN006), and every marked member must be public
(DKCRUDGEN004).
[GenerateDto]The generated IWitResponse<TDto> needs a concrete DTO, resolved at generation time by scanning the compiling project
for the partial record marked [GenerateDto(typeof(TEntity))] (from DKNet.EfCore.DtoGenerator). Exactly one match is
required — zero is DKCRUDGEN001, more than one is DKCRUDGEN002 — because the domain-side attribute cannot reference
an API-layer type itself.
One sealed partial record per marked member, plus one delete request per entity, all with a Request suffix:
System.ComponentModel.DataAnnotations attributes on the parameters ([Required], [Range], …) are copied onto the
generated properties — metadata only, see the Gotchas.[CrudCreate] → implements Fluents.Requests.IWitResponse<TDto>.[CrudUpdate] and [CrudAction] → implement IWitResponse<TDto> and IWithKey<TKey>, whose Id is bound from
the route.Delete{Entity}Request → emitted for every entity with no attribute at all; implements IWithKey<TKey> only, and
gets no handler, because the delete route goes straight to the repository. See
Refusing a delete.Each request gets an internal sealed IHandler<TRequest, TDto> in {Entity}CrudHandlers.g.cs:
[CrudCreate] constructor or method with the request’s values, then
IRepositorySpec.AddAsync.Result.Fail wrapping NotFoundError (which the endpoint layer maps to 404), found entity gets the marked method
invoked.mapper.ResultOf<TDto>(entity), so the DTO materializes lazily — after the auto-save interceptor has
persisted the change, which is why database-generated values (identity keys, timestamps) are present in the response.Every snippet in this section is copied verbatim from obj/…/generated/ after compiling a consumer
project against the packaged generator. The entity below is the whole hand-written input:
using System.ComponentModel.DataAnnotations;
using DKNet.EfCore.Abstractions.Attributes;
using DKNet.EfCore.Abstractions.Entities;
namespace Catalog;
public class Product : Entity
{
private Product() { } // EF
[CrudCreate]
public Product([Required, MaxLength(100)] string name, decimal price)
{
Name = name;
Price = price;
}
public string Name { get; private set; } = null!;
public decimal Price { get; private set; }
public bool IsApproved { get; private set; }
public bool IsArchived { get; private set; }
[CrudUpdate]
public void UpdatePrice([Range(0, 1_000_000)] decimal price) => Price = price;
[CrudUpdate]
public void Rename([Required, MaxLength(100)] string name) => Name = name;
[CrudAction("approval")]
public void Approve([Required] string approver) => IsApproved = true;
[CrudAction(Verb = CrudActionVerb.Patch)]
public void Archive() => IsArchived = true;
}
plus the DTO, in the API project:
using Catalog;
using DKNet.EfCore.DtoGenerator;
namespace Api;
[GenerateDto(typeof(Product))]
public partial record ProductDto;
[CrudCreate] on a constructorThe request record takes one property per constructor parameter, required unless the parameter type is
nullable, with the parameter’s System.ComponentModel.DataAnnotations attributes reconstructed onto it:
/// <summary>Create request generated from Product's [CrudCreate] constructor.</summary>
public sealed partial record CreateProductRequest : global::DKNet.SlimBus.Extensions.Fluents.Requests.IWitResponse<global::Api.ProductDto>
{
/// <summary>Maps to constructor parameter 'name'.</summary>
[global::System.ComponentModel.DataAnnotations.Required]
[global::System.ComponentModel.DataAnnotations.MaxLength(100)]
public required string Name { get; init; }
/// <summary>Maps to constructor parameter 'price'.</summary>
public required decimal Price { get; init; }
}
Its handler calls the constructor and adds the entity — no Id property, because there is nothing to look up:
/// <summary>Generated create handler for Product. Write a class implementing the same IHandler to replace it.</summary>
internal sealed class CreateProductHandler(
global::DKNet.EfCore.Specifications.Repositories.IRepositorySpec repository,
global::MapsterMapper.IMapper mapper)
: global::DKNet.SlimBus.Extensions.Fluents.Requests.IHandler<CreateProductRequest, global::Api.ProductDto>
{
/// <inheritdoc />
public async global::System.Threading.Tasks.Task<global::FluentResults.IResult<global::Api.ProductDto>> OnHandle(
CreateProductRequest request, global::System.Threading.CancellationToken cancellationToken)
{
var entity = new global::Catalog.Product(request.Name, request.Price);
await repository.AddAsync(entity, cancellationToken);
return global::DKNet.SlimBus.Extensions.LazyMapper.LazyMapExtensions.ResultOf<global::Api.ProductDto>(mapper, entity);
}
}
[CrudCreate] is also valid on a public method, but read the emitted handler before reaching for it. Marking
public static Widget Draft([Required] string label) => new(label); produces DraftWidgetRequest and:
/// <summary>Generated create handler for Widget. Write a class implementing the same IHandler to replace it.</summary>
internal sealed class DraftWidgetHandler(
global::DKNet.EfCore.Specifications.Repositories.IRepositorySpec repository,
global::MapsterMapper.IMapper mapper)
: global::DKNet.SlimBus.Extensions.Fluents.Requests.IHandler<DraftWidgetRequest, global::Api.WidgetDto>
{
/// <inheritdoc />
public async global::System.Threading.Tasks.Task<global::FluentResults.IResult<global::Api.WidgetDto>> OnHandle(
DraftWidgetRequest request, global::System.Threading.CancellationToken cancellationToken)
{
var entity = new global::Catalog.Widget(request.Label);
await repository.AddAsync(entity, cancellationToken);
return global::DKNet.SlimBus.Extensions.LazyMapper.LazyMapExtensions.ResultOf<global::Api.WidgetDto>(mapper, entity);
}
}
The handler calls the constructor, not Draft — the marked method’s parameter list is used as the
constructor’s argument list. So the method form only works when a constructor with exactly that signature
exists, and any logic inside the factory method is skipped. Prefer marking the constructor.
[CrudUpdate] on a methodAn update request adds a route-bound Id and implements IWithKey<TKey> alongside IWitResponse<TDto>:
/// <summary>Update request generated from Product.UpdatePrice.</summary>
public sealed partial record UpdatePriceProductRequest :
global::DKNet.SlimBus.Extensions.Fluents.Requests.IWitResponse<global::Api.ProductDto>,
global::DKNet.SlimBus.Extensions.Fluents.Requests.IWithKey<global::System.Guid>
{
/// <summary>The target Product identifier (bound from route).</summary>
public global::System.Guid Id { get; set; }
/// <summary>Maps to method parameter 'price'.</summary>
[global::System.ComponentModel.DataAnnotations.Range(0, 1000000)]
public required decimal Price { get; init; }
}
Its handler fetches by id through a file-local specification and returns NotFoundError on a miss:
/// <summary>Matches a single Product by id; used by this file's generated update handlers to fetch the entity.</summary>
file sealed class ProductByIdCrudSpec : global::DKNet.EfCore.Specifications.Definitions.Specification<global::Catalog.Product>
{
/// <summary>Initializes the specification with a filter matching the given id.</summary>
public ProductByIdCrudSpec(global::System.Guid id) => WithFilter(x => x.Id.Equals(id));
}
/// <summary>Generated update handler for Product.UpdatePrice. Write a class implementing the same IHandler to replace it.</summary>
internal sealed class UpdatePriceProductHandler(
global::DKNet.EfCore.Specifications.Repositories.IRepositorySpec repository,
global::MapsterMapper.IMapper mapper)
: global::DKNet.SlimBus.Extensions.Fluents.Requests.IHandler<UpdatePriceProductRequest, global::Api.ProductDto>
{
/// <inheritdoc />
public async global::System.Threading.Tasks.Task<global::FluentResults.IResult<global::Api.ProductDto>> OnHandle(
UpdatePriceProductRequest request, global::System.Threading.CancellationToken cancellationToken)
{
var entity = await repository.FirstOrDefaultAsync(new ProductByIdCrudSpec(request.Id), cancellationToken);
if (entity is null)
return global::FluentResults.Result.Fail<global::Api.ProductDto>(
new global::DKNet.SlimBus.Extensions.NotFoundError($"Product '{request.Id}' was not found."));
entity.UpdatePrice(request.Price);
return global::DKNet.SlimBus.Extensions.LazyMapper.LazyMapExtensions.ResultOf<global::Api.ProductDto>(mapper, entity);
}
}
The ProductByIdCrudSpec above is emitted once per entity, file-scoped, and only when the entity has at
least one update or action to emit.
[CrudAction], with and without argumentsAn action request is structurally identical to an update request — the difference is entirely in routing.
Approve takes a parameter, Archive takes none, which is why one record has a property and the other has
only Id:
/// <summary>Action request generated from Product.Approve.</summary>
public sealed partial record ApproveProductRequest :
global::DKNet.SlimBus.Extensions.Fluents.Requests.IWitResponse<global::Api.ProductDto>,
global::DKNet.SlimBus.Extensions.Fluents.Requests.IWithKey<global::System.Guid>
{
/// <summary>The target Product identifier (bound from route).</summary>
public global::System.Guid Id { get; set; }
/// <summary>Maps to method parameter 'approver'.</summary>
[global::System.ComponentModel.DataAnnotations.Required]
public required string Approver { get; init; }
}
/// <summary>Action request generated from Product.Archive.</summary>
public sealed partial record ArchiveProductRequest :
global::DKNet.SlimBus.Extensions.Fluents.Requests.IWitResponse<global::Api.ProductDto>,
global::DKNet.SlimBus.Extensions.Fluents.Requests.IWithKey<global::System.Guid>
{
/// <summary>The target Product identifier (bound from route).</summary>
public global::System.Guid Id { get; set; }
}
One extension method per entity; every registration is guarded by both its CrudOp and its route name, so
Exclude can drop it by operation kind or by member name:
/// <summary>Registers the generated CRUD endpoints for Product.</summary>
public static class ProductCrudEndpointExtensions
{
/// <summary>Maps GET {id}, GET /, POST /, PUT {id} (per update request), DELETE {id} and each generated domain-action endpoint for Product.</summary>
public static global::Microsoft.AspNetCore.Routing.RouteGroupBuilder MapProductCrud(
this global::Microsoft.AspNetCore.Routing.RouteGroupBuilder group,
global::System.Action<global::DKNet.AspCore.Extensions.Endpoints.CrudMapOptions>? configure = null)
{
var options = new global::DKNet.AspCore.Extensions.Endpoints.CrudMapOptions();
configure?.Invoke(options);
options.ValidateRouteNames("Product", "GetById", "GetList", "Create", "Delete", "UpdatePrice", "Rename", "Approve", "Archive");
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetById) && !options.IsExcluded("GetById"))
{
var routeBuilder = group.MapGetById<global::Catalog.Product, global::System.Guid, global::Api.ProductDto>();
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetById, "GetById", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetList) && !options.IsExcluded("GetList"))
{
var routeBuilder = group.MapGetList<global::Catalog.Product, global::System.Guid, global::Api.ProductDto>();
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetList, "GetList", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Delete) && !options.IsExcluded("Delete"))
{
var routeBuilder = group.MapDeleteById<global::Catalog.Product, global::System.Guid, DeleteProductRequest>();
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Delete, "Delete", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Create) && !options.IsExcluded("Create"))
{
var routeBuilder = group.MapPost<CreateProductRequest, global::Api.ProductDto>("/");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Create, "Create", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update) && !options.IsExcluded("UpdatePrice"))
{
var routeBuilder = group.MapPutById<UpdatePriceProductRequest, global::System.Guid, global::Api.ProductDto>("{id}");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update, "UpdatePrice", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update) && !options.IsExcluded("Rename"))
{
var routeBuilder = group.MapPutById<RenameProductRequest, global::System.Guid, global::Api.ProductDto>("{id}/rename");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update, "Rename", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Action) && !options.IsExcluded("Approve"))
{
var routeBuilder = group.MapActionById<ApproveProductRequest, global::System.Guid, global::Api.ProductDto>("{id}/approval", "POST");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Action, "Approve", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Action) && !options.IsExcluded("Archive"))
{
var routeBuilder = group.MapParameterlessActionById<ArchiveProductRequest, global::System.Guid, global::Api.ProductDto>("{id}/archive", "PATCH");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Action, "Archive", routeBuilder);
}
return group;
}
}
Read the last four registrations as the routing rules in action: UpdatePrice was declared first so it keeps
the plain {id} PUT; Rename came second so it landed on {id}/rename; Approve used the attribute’s
explicit "approval" segment; Archive fell back to its kebab-cased method name and to PATCH because of
Verb, and — taking no parameters — was registered with MapParameterlessActionById rather than
MapActionById, so its route needs no request body.
Two things in that method are the exclusion and configuration surface. ValidateRouteNames is emitted with
every name the entity has and runs before anything is mapped, so a name Exclude/Configure passed that the
entity does not have throws there. Each registration is then behind both guards — Exclude(CrudOp.Update)
drops both PUTs, Exclude("Rename") drops only the second — and options.Apply is what runs the route’s
settings, operation-kind first, name second.
Everything the generator names is derived, never configurable beyond the attribute’s Name:
| Thing | Rule | From the example |
|---|---|---|
| Namespace | {AssemblyName}.Crud, or Generated.Crud when the compilation has no assembly name |
CrudConsumer.Crud |
| Requests file | {Entity}CrudRequests.g.cs, one per entity |
ProductCrudRequests.g.cs |
| Handlers file | {Entity}CrudHandlers.g.cs; omitted entirely when every member has a hand-written handler |
ProductCrudHandlers.g.cs |
| Endpoints file | {Entity}CrudEndpoints.g.cs; emitted only when the project references DKNet.AspCore.Extensions |
ProductCrudEndpoints.g.cs |
| Create request | Create{Entity}Request for a constructor, {Method}{Entity}Request for a method; Name on the attribute overrides both |
CreateProductRequest |
| Update / action request | {Method}{Entity}Request; Name overrides |
RenameProductRequest |
| Delete request | Delete{Entity}Request, one per entity, not overridable — suppressed when another member already resolves to that name (Refusing a delete) |
DeleteProductRequest |
| Handler | the request name with a trailing Request replaced by Handler (appended when the name does not end in Request) |
RenameProductHandler |
| By-id specification | {Entity}ByIdCrudSpec, file-scoped, one per handlers file |
ProductByIdCrudSpec |
| Endpoint extension class | {Entity}CrudEndpointExtensions |
ProductCrudEndpointExtensions |
| Endpoint method | Map{Entity}Crud |
MapProductCrud |
Route segments:
| Member | Segment |
|---|---|
First [CrudUpdate] in declaration order |
{id} — the plain by-id PUT |
Every later [CrudUpdate] |
{id}/{kebab-cased method name} |
[CrudAction("segment")] |
{id}/segment, exactly as written |
[CrudAction] with no segment |
{id}/{kebab-cased method name} |
Kebab-casing inserts a - before every upper-case character after the first and lower-cases it, so
UpdatePrice becomes update-price and an acronym like ExportXml becomes export-xml — but ExportXML
becomes export-x-m-l. Two members resolving to the same segment is DKCRUDGEN008.
Registration order inside Map{Entity}Crud is fixed: GetById, GetList, Delete, Create, each
[CrudUpdate] in declaration order, then each [CrudAction] in declaration order.
Route names:
Every registered route also carries a name, separate from its segment. The name is what
CrudMapOptions.Configure(string, …) matches on, and it is never kebab-cased, never the generated request
record’s name, and never changed by Name on [CrudCreate]/[CrudUpdate]/[CrudAction] or by an explicit
[CrudAction] segment:
| Route | Name |
|---|---|
| GET by id | GetById |
| GET list | GetList |
| POST create | Create |
| DELETE by id | Delete |
Each [CrudUpdate] member |
its C# method name, verbatim |
Each [CrudAction] member |
its C# method name, verbatim |
The four fixed routes are named exactly as their CrudOp members are spelled. Names are compared
ordinally and must be unique within an entity; two routes of one entity resolving to the same name is
DKCRUDGEN009. That needs a deliberate collision — a [CrudUpdate]/[CrudAction] method called GetById,
GetList, Create or Delete, or two attributed overloads of one method name kept apart only by explicit
route segments.
Product publishes```csharp crud-naming-example using System; using DKNet.EfCore.Abstractions.Attributes; using DKNet.EfCore.Abstractions.Entities; using DKNet.EfCore.DtoGenerator;
namespace MyDomain
{
public class Product : IEntity
[CrudUpdate]
public void Rename(string name) => Name = name;
[CrudUpdate]
public void ChangePrice(decimal price) => Price = price;
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public decimal Price { get; private set; }
} }
namespace MyApi { [GenerateDto(typeof(MyDomain.Product))] public partial record ProductDto; }
That entity publishes six routes, and these are all of them:
| Name | HTTP method | Segment |
|---|---|---|
| `GetById` | GET | `{id}` |
| `GetList` | GET | `/` |
| `Create` | POST | `/` |
| `Delete` | DELETE | `{id}` |
| `Rename` | PUT | `{id}` |
| `ChangePrice` | PUT | `{id}/change-price` |
```text crud-naming-routes
GetById
GetList
Create
Delete
Rename
ChangePrice
Rename is declared first, so it keeps the plain {id} PUT and ChangePrice gets the kebab-cased segment —
but neither name is kebab-cased, and ChangePrice is the name whether or not it is the route that moved.
GetById and Delete share the {id} segment under different verbs and still have distinct names: a name
identifies a route, not a segment. Product declares no [CrudAction], so it publishes no action routes.
Two things this generator is often assumed to do, and what it actually does:
DKNet.EfCore.DtoGenerator: you declare
[GenerateDto(typeof(Entity))] public partial record EntityDto; and that generator emits its properties.
This generator only looks the type up, by scanning the compiling assembly for exactly one [GenerateDto]
type whose constructor argument is the entity, and uses it as the TDto in IWitResponse<TDto>. Zero is
DKCRUDGEN001, more than one is DKCRUDGEN002. GenerateDtoAttribute is internal, which is fine for a
package consumer: the attribute is shipped as a source file that compiles into your own assembly.AddEvent(...) calls in an entity are your own domain code; they reach the bus after the save,
through DKNet.EfCore.Events plus
AddSlimBusEventPublisher<TDbContext>(). The generated handlers do not call
SaveChanges either — the auto-save interceptor does, which is why the save (and therefore the publish)
happens after the handler returns.Map{Entity}Crud// By operation kind — no DELETE route is registered at all.
app.MapGroup("/products").MapProductCrud(o => o.Exclude(CrudOp.Delete));
// By route name — `ChangePrice` is withdrawn, `Rename` keeps the plain `{id}` PUT.
app.MapGroup("/products").MapProductCrud(o => o.Exclude("ChangePrice"));
A non-generic Map{Entity}Crud(this RouteGroupBuilder, Action<CrudMapOptions>? configure = null) composing the
existing mappers from DKNet.AspCore.Extensions — no new mapping layer:
| Operation | Route | Response |
|---|---|---|
| GET by id | {id} |
200 + DTO / 404 |
| GET list (paged) | / |
200 |
| POST create | / |
201 + DTO body (Location header is the placeholder /) |
PUT — first [CrudUpdate] |
{id} |
200 + DTO body / 404 |
PUT — additional [CrudUpdate]s |
{id}/{kebab-case-method-name} |
200 + DTO body / 404 |
[CrudAction] (POST/PUT/PATCH) |
{id}/{segment} |
200 + DTO body / 404 |
| DELETE by id | {id} |
204 / 404, or 409 when the database rejects the delete |
Additional [CrudUpdate] methods route in declaration order: the first keeps the plain {id} PUT, and each one after
it gets its method name kebab-cased onto the route (UpdatePrice → {id}/update-price). Two members resolving to the
same segment is DKCRUDGEN008.
Delete{Entity}RequestThe DELETE route takes no request body and needs no attribute, so before there was nothing to hang a rule on.
The generator now closes that gap by emitting, into {Entity}CrudRequests.g.cs alongside the create and update
records:
/// <summary>Delete request generated for Product, carrying the target's key bound from the route.</summary>
public sealed partial record DeleteProductRequest : global::DKNet.SlimBus.Extensions.Fluents.Requests.IWithKey<global::System.Guid>
{
/// <summary>The target Product identifier (bound from route).</summary>
public global::System.Guid Id { get; set; }
}
Map{Entity}Crud binds it on the DELETE route — group.MapDeleteById<Product, Guid, DeleteProductRequest>(),
the three-type-argument overload in DKNet.AspCore.Extensions. Nothing else moves:
IRepositorySpec through MapDeleteById, not through SlimBus.To refuse a delete, validate the generated request and let the group’s failure filter answer. With the
SharpGrip.FluentValidation.AutoValidation.Endpoints pattern that is a 400 ProblemDetails:
using FluentValidation;
using CrudConsumer.Crud; // the {AssemblyName}.Crud namespace the requests are emitted into
public sealed class DeleteProductRequestValidator : AbstractValidator<DeleteProductRequest>
{
public DeleteProductRequestValidator(AppDbContext db) =>
RuleFor(x => x.Id)
.MustAsync(async (id, ct) => !await db.OrderLines.AnyAsync(l => l.ProductId == id, ct))
.WithMessage("Product is still on an order and cannot be deleted.");
}
using SharpGrip.FluentValidation.AutoValidation.Endpoints.Extensions;
builder.Services.AddValidatorsFromAssemblyContaining<DeleteProductRequestValidator>();
var products = app.MapGroup("/products");
products.MapProductCrud();
products.AddFluentValidationAutoValidation(); // only groups that opt in consult the validator
A refused delete never reaches SaveChanges, so the row survives and no audit entry or domain event is raised
for it. The rule is keyed on the request type, so a rule on DeleteProductRequest never fires for
DeleteOrderRequest.
Name collision. If a [CrudCreate]/[CrudUpdate]/[CrudAction] member already resolves to the name
Delete{Entity}Request (e.g. a [CrudUpdate] public void Delete()), the generator emits neither the delete
record nor the three-argument map call, and falls back to MapDeleteById<{Entity}, {Key}>(). That entity’s
DELETE route keeps working unchanged, but there is no delete request to guard — rename the member if you need
the rule.
The generator reports that skip as DKCRUDGEN010 (Info), naming the entity, the colliding member and the
request name it took, so the fallback is visible in the build log instead of silent. Info never fails a build:
the compilation succeeds and the DELETE route keeps answering as before.
[CrudAction]A [CrudAction] method is a named operation on the entity at its own {id}/{segment} route — it never claims the plain
{id} route, whatever verb it uses:
[CrudAction("approval")]
public void Approve([Required] string approver) { /* … */ AddEvent<OrderApproved>(); }
[CrudAction(Verb = CrudActionVerb.Patch)]
public void Archive() { /* … */ }
Compared with [CrudUpdate] using Verb = Put: an update replaces state and, positionally, may claim {id}; an action
is always a named segment and never positional. Verb = Patch only changes the advertised HTTP method — there is no
partial-update or merge semantics behind it. Marking one member with both [CrudUpdate] and [CrudAction] is
DKCRUDGEN007, and that member is emitted as neither.
The generator picks the mapper by the action method’s compile-time parameter count. Archive() above takes
none, so its route is registered with MapParameterlessActionById and dispatches on a call that carries no
body and no Content-Type header at all:
PATCH /v1/products/8f0c5a62-5f1f-4a1e-9f8f-0f4a2d3b7c10/archive HTTP/1.1
Host: localhost
Posting a body anyway is still accepted and ignored, so an existing caller sending {} keeps working
unchanged, and the target id is always the one in the route — never an Id read out of a posted body. The
published OpenAPI operation for such a route declares no requestBody, so a generated client does not send
one either.
Approve(string approver) takes a parameter, so it is unaffected: it still maps through MapActionById and
its request body still carries the action’s arguments.
{Entity}CrudEndpoints.g.cs is generated only when the compiling project references DKNet.AspCore.Extensions. A
project that wants requests and handlers without an ASP.NET Core dependency gets exactly that, rather than a file full
of unresolved types.
If the compiling project already declares a type implementing IHandler<TRequest, TDto> for a generated request, the
generator skips that request’s handler and reports DKCRUDGEN005 (Info) at the hand-written type; the request record is
still generated. Matching is by the request type’s name only — the hand-written type’s DTO type argument is not
cross-checked, so get it right by hand.
Exclusion at mapping time — by operation kind or by route name — is a separate mechanism:
CrudMapOptions.Exclude, above.
| Id | Severity | Meaning |
|---|---|---|
DKCRUDGEN001 |
Error | Entity has CRUD-attributed members but no [GenerateDto(typeof(Entity))] DTO in the compiling project. |
DKCRUDGEN002 |
Error | More than one [GenerateDto(typeof(Entity))] DTO for the entity in the compiling project. |
DKCRUDGEN003 |
Error | More than one member marked [CrudCreate]; only one is allowed. |
DKCRUDGEN004 |
Error | A CRUD-attributed member is not public. |
DKCRUDGEN005 |
Info | A hand-written handler was found for a generated request; the generated handler was skipped. |
DKCRUDGEN006 |
Error | The entity does not implement DKNet.EfCore.Abstractions.Entities.IEntity<TKey>. |
DKCRUDGEN007 |
Error | A member is marked both [CrudUpdate] and [CrudAction]; keep exactly one. |
DKCRUDGEN008 |
Error | Two members resolve to the same route segment; give one an explicit distinct segment. |
DKCRUDGEN009 |
Error | Two routes of the entity resolve to the same route name; rename one of the members. |
DKCRUDGEN010 |
Info | A CRUD-attributed member already claims the name Delete{Entity}Request; the generated delete request was skipped and the DELETE route fell back to MapDeleteById<TEntity, TKey>(). Rename the member if that entity needs a delete rule. |
There is no options object — configuration is the attributes on the entity plus the map-time exclusions.
CrudCreateAttribute / CrudUpdateAttribute:
| Member | Type | Default | Effect |
|---|---|---|---|
Name |
string? |
null |
Overrides the generated request type name (default Create{Entity}Request / {Method}{Entity}Request). |
CrudActionAttribute:
| Member | Type | Default | Effect |
|---|---|---|---|
Route (positional ctor arg) |
string? |
null → kebab-cased method name (Archive → archive) |
Route segment appended after {id}/. |
Verb |
CrudActionVerb |
Post |
Registered HTTP verb: Post, Put, or Patch. There is no Delete. |
Name |
string? |
null |
Overrides the generated request type name. |
CrudMapOptions (from DKNet.AspCore.Extensions.Endpoints):
| Member | Effect |
|---|---|
Exclude(params CrudOp[]) |
Skips the named operations entirely — nothing is registered for them, not merely hidden. Fluent, so calls chain. Nothing is excluded by default. |
Exclude(params string[] routeNames) |
Skips the one route carrying each name (see Route names), leaving the entity’s other routes of the same kind published at their existing addresses. Nothing is registered for the withdrawn route, not merely hidden. A name the entity has no route for throws ArgumentException at registration — never a silent no-op. Fluent, and nothing is excluded by default. |
Configure(CrudOp, Action<RouteHandlerBuilder>) |
Applies the setting to every generated route of that operation kind. Additive: several calls for one operation all run, in call order. Fluent. |
Configure(string routeName, Action<RouteHandlerBuilder>) |
Applies the setting to the one route carrying that name (see Route names). Additive and fluent, same as above. |
CrudOp values |
GetById, GetList, Create, Update, Delete, Action. |
Map{Entity}Crud validates every name passed to Exclude(string, …) and Configure(string, …) against the
entity’s own route names before it maps anything, so a misspelt name throws ArgumentException at
registration and the group publishes nothing — that is the safety net for “my authorization silently vanished”
and for “the route I withdrew is still live”. Settings then run per route, operation-kind settings first and
route settings after. Naming a route whose operation is excluded is not an error: the name is still validated,
and the setting is then silently dropped along with the route.
The generator runs once per compilation, resolves each entity against its key, its DTO and any hand-written handler, and writes at most three files per entity:
IEntity<TKey>, which every attributed entity must implement.[GenerateDto] DTOs this
generator resolves against.IRepositorySpec and
Specification<TEntity>, used by every generated handler.NotFoundError, the
lazy mapper, and the auto-save interceptor that persists what the generated handlers change.MapGetById,
MapGetList, MapDeleteById, MapPost, MapPutById, MapActionById, MapParameterlessActionById) that
Map{Entity}Crud composes, plus
CrudMapOptions.[Range(0, 1_000_000)] rides along on the generated property but nothing rejects an out-of-range value at the HTTP
boundary until you add validation (for example FluentValidation behind an endpoint filter) against the generated
request type.201 Created depends on the request type’s name. MapPost returns 201 only when the request type name contains
“Create” (case-insensitive) — true for the default Create{Entity}Request, and easy to lose by setting Name to
something else, which silently downgrades the response to 200.Location header on create is the placeholder /. It is not the created resource’s URL; don’t build a client
around following it.[CrudCreate] per entity. A second constructor you would like exposed needs a hand-written slice.[CrudCreate] on a method never calls that method. The generated handler always does
new {Entity}(request.…) with the marked member’s parameter list, so a factory method’s body is skipped and
the code only compiles when a matching constructor exists. It also loses the 201 Created response, because
the request is named after the method and MapPost keys 201 off the name containing “Create”.