DKNet

DKNet.SlimBus.Generators

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.

✨ Why use it?

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.

🚀 Quick Start

<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:

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

🧩 Features

Discovery — [CrudCreate], [CrudUpdate], [CrudAction]

All three attributes live in DKNet.EfCore.Abstractions, so the domain layer takes on no messaging dependency:

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

DTO resolution — [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.

Request records

One sealed partial record per marked member, plus one delete request per entity, all with a Request suffix:

Handlers

Each request gets an internal sealed IHandler<TRequest, TDto> in {Entity}CrudHandlers.g.cs:

Declaration and emitted code, side by side

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 constructor

The 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 method

An 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 arguments

An 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; }

}

The endpoint file, for all four members at once

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.

Naming and routing conventions

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.

Worked example — every route 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 { [CrudCreate] public Product(string name, decimal price) { Name = name; Price = price; }

    [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.

The DTO and domain-event paths

Two things this generator is often assumed to do, and what it actually does:

Endpoint registration — 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.

Refusing a delete — Delete{Entity}Request

The 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:

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.

Domain actions — [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.

An action with no parameters takes no request body

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.

Endpoint emission is opt-in

{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.

Overrides — hand-written handlers win

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.

Diagnostics

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.

⚙️ Configuration reference

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.

🧱 Where it fits

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:

Workflow diagram: the generator scans the compiling assembly and its non-framework references for CRUD-attributed members, looks up GenerateDto DTOs in the compiling assembly only, and scans that assembly's syntax for IHandler base lists matched by request name; it resolves each entity's IEntity key and single DTO into an entity model, reporting DKCRUDGEN001-008 and skipping the entity when resolution fails, then emits the requests file, the handlers file, and the endpoints file when DKNet.AspCore.Extensions is referenced.

⚠️ Gotchas & limits