DKNet.Templates

CRUD Attributes — the Generator-Driven Vertical Slice

How AutomatedSample/Product builds a full CRUD slice from four attributes instead of the hand-written files ManualSample/PurchaseOrder uses. Read docs/samples/manual-vs-automated.md first — it’s the layer-by-layer comparison of what each shape costs; this page only walks the mechanics of the generated shape.

Data-flow diagram: the Product entity's three Crud attributes feed DKNet.SlimBus.Generators, which emits the request records and handlers, while the GenerateDto attribute on ProductDto feeds DKNet.EfCore.DtoGenerator, which emits the DTO members; both converge on the single generated MapProductCrud() call that UseEndpointConfigs turns into the /v1/products routes. Two cards list what the generators emit and what stays hand-written.

The four attributes

Minimal.Domains/Features/AutomatedSample/Entities/Product.cs:

[RaisesEvent(EventOperations.Created, Include = [nameof(Id), nameof(Name), nameof(Price)])]
[RaisesEvent(EventOperations.Updated, nameof(Price))]
[RaisesEvent(EventOperations.Updated, nameof(IsDiscontinued))]
public class Product : AggregateRoot, IOwnedBy
{
    [CrudCreate]
    public Product(
        [Required, StringLength(150)] string name,
        [Range(0.01, double.MaxValue)] decimal price,
        decimal? supplierCostPrice = null)
    {
        Name = name;
        Price = price;
        SupplierCostPrice = supplierCostPrice;
    }

    public string Name { get; private set; } = null!;
    public decimal Price { get; private set; }
    public bool IsDiscontinued { get; private set; }

    [SensitiveData("pricing")] public decimal? SupplierCostPrice { get; private set; }
    [SensitiveData] public string? SupplierReferenceCode { get; private set; }

    // Stamped by DataOwnerHook from the tenant ownership key; makes the row subject to
    // DataOwnerAuthQuery's global read filter.
    public string OwnedBy { get; private set; } = string.Empty;

    [CrudUpdate]
    public void ChangePrice([Range(0.01, double.MaxValue)] decimal price) => Price = price;

    [CrudAction("approval")]
    public void Approve(string byUser) => SetUpdatedBy(byUser);

    [CrudAction(Verb = CrudActionVerb.Put)]
    public void Discontinue() => IsDiscontinued = true;

    [CrudAction("supplier-reference", Verb = CrudActionVerb.Put)]
    public void AssignSupplierReference([Required, StringLength(50)] string supplierReferenceCode)
        => SupplierReferenceCode = supplierReferenceCode;
}

([RaisesEvent] is covered in docs/efcore-events.md; this page focuses on [CrudCreate]/[CrudUpdate]/[CrudAction]/[GenerateDto].)

What the generated DTO holds

What is left after the exclusions — Product’s properties minus the three names in the Exclude list above: Name, Price, IsDiscontinued, SupplierCostPrice, SupplierReferenceCode, CreatedBy, CreatedOn, UpdatedBy, UpdatedOn, Id. The two supplier properties stay on the generated record and do reach the wire; what varies per caller is [SensitiveData], not the DTO shape — see Declaring a sensitive property below. This list is the one place the generated field set is written down; other pages link here rather than repeat it.

ProductDto additionally carries GrossMargin, which the generator did not produce: it is hand-written onto the partial record and mapped by a Mapster IRegister — see the automated sample.

Generated DTOs and [MapsFrom]-tagged hand-written DTOs both register with Mapster the same way. Minimal.AppServices/Extensions/MapsToExtensions.cs’s ScanMaps reflects over the assembly for either attribute and calls config.NewConfig(entityType, dtoType) for each hit. That scan runs from Minimal.AppServices/AppSetup.cs at startup; no per-feature mapping registration is needed for either style.

What the generators emit

DKNet.SlimBus.Generators (the package behind [CrudCreate]/[CrudUpdate]) emits three things per entity, under obj/Generated/ (not committed to source control — inspect the output after a build):

It never generates the IEntityTypeConfiguration<T> mapping, or any event consumer (see docs/efcore-events.md). Those stay hand-written for both samples.

Map<Entity>Crud() takes one optional argument — an Action<CrudMapOptions> with four overloads across two capabilities: dropping routes you would rather hand-write, and settings applied to the routes that stay.

group.MapProductCrud(o => o
    .Exclude(CrudOp.Delete)                                                     // a whole operation kind
    .Exclude("Discontinue")                                                     // one generated route, by name
    .Configure(CrudOp.GetById, b => b.WithTags("products-read"))                // every route of a kind
    .Configure("ChangePrice", b => b.WithTags("products-write")));              // one named route

For authorization scopes specifically, prefer [EndpointGroupScope(scope, EndpointHttpMethods.X)] on the endpoint class: it covers generated and hand-mapped routes in one declaration and is applied only when FeatureManagement:RequireAuthorization is on, so it needs no flag guard. Use Configure for a scope only when two routes share an HTTP method but not a scope — as Update and AssignSupplierReference do on Product.

A route’s name is GetById, GetList, Create or Delete for the four fixed operations, and the verbatim C# member name for each [CrudUpdate]/[CrudAction] member — never the kebab-cased route segment (ChangePrice, not change-price) and never the generated request record’s name. Both Configure overloads are additive and ordered: for any one route every operation-kind setting runs first, then every name setting, each in call order. A name the entity does not carry throws ArgumentException at start-up, so a typo fails the host rather than silently publishing an unprotected route.

Nothing is excluded by default. The shipped ProductV1Endpoint passes both kinds of option — a per-route scope on each generated route, and one Exclude("Discontinue") whose route it hand-writes below the generated call instead.

Declaring a sensitive property

[SensitiveData] (DKNet.EfCore.Abstractions) is the one attribute on this page that does not generate anything. It marks a property whose value not every caller may read, and the Product entity carries two declarations:

[SensitiveData("pricing")] public decimal? SupplierCostPrice { get; private set; }
[SensitiveData]            public string? SupplierReferenceCode { get; private set; }

The two properties are written by different routes, deliberately. supplierCostPrice is an optional trailing parameter of the [CrudCreate] constructor, so it can be supplied — or left out — when the product is created. supplierReferenceCode cannot be sent at create time at all: it is assigned afterwards through its own [CrudAction], AssignSupplierReference, which publishes PUT /v1/products/{id}/supplier-reference. A supplier reference is allocated by procurement once the product exists, not chosen by whoever files the product, and a named action says that where an optional create field would not. Read access is the same for both — that is what [SensitiveData] governs; write access is whatever the declaring member allows.

The attribute travels onto the generated DTO

You never re-declare it on ProductDto. DKNet.EfCore.DtoGenerator copies [SensitiveData], roles and all, from the entity property onto the matching property of the generated record — so obj/Generated/.../ProductDto.g.cs carries SupplierCostPrice and SupplierReferenceCode with their attributes already on them.

That is the point of declaring it on the entity. There is no second response model for privileged callers, no per-audience Mapster profile, and no if (user.IsInRole(...)) branch in a handler — the one declaration reaches every generated response model that includes the property.

Excluding the property instead (Exclude on [GenerateDto], above) is the blunter tool: it removes the property from the DTO for everyone, privileged callers included.

Declaring is not enforcing

On its own the attribute changes nothing about an API response — [SensitiveData("pricing")] behaves exactly like an undecorated property until the host opts its response serializer in. That opt-in is one start-up registration in Minimal.Api, described in docs/api-pipeline.md.

The same declaration is also what DKNet.EfCore.AuditLogs reads to redact the value in a captured audit entry (for every reader — audit redaction ignores the role names). That package is not wired by this template today, see docs/dknet-packages.md; the point is that a property is declared sensitive once and both consumers honour it.

Domain actions with [CrudAction]

[CrudCreate] and [CrudUpdate] cover “make one” and “change the value the caller sends”. A domain action is the third shape: a named business operation on a row that already exists — approve it, discontinue it, publish it. [CrudAction] on a public entity method generates the request, the handler and the route for that operation, so you write the method and nothing else.

Mechanically it behaves like [CrudUpdate]: the method’s parameter list is the generated request’s payload, and DataAnnotations attributes on those parameters are forwarded 1:1 onto the generated request’s properties. What differs is what gets published.

  [CrudUpdate] [CrudAction]
Verb PUT POST by default, overridable to PUT or PATCH
Route the entity’s by-id route the entity’s by-id route plus one segment
That segment — the method name kebab-cased, or the string you pass
Response 200 + the entity DTO 200 + the entity DTO — not 204
Unknown id 404 via NotFoundError 404 via NotFoundError

The attribute lives in DKNet.EfCore.Abstractions.Attributes — the same namespace as [CrudCreate] and [CrudUpdate], so an entity already using those needs no new using.

Declaring an action

Annotate a public method. That is the whole declaration:

[CrudAction]
public void Discontinue() => IsDiscontinued = true;

The method name kebab-cases into the route segment, so this publishes:

POST /v1/products/{id}/discontinue   →  200 + ProductDto

A parameterless action generates a request carrying only the route’s id. Give the method parameters and they become the body, exactly as they do for an update:

[CrudAction]
public void Approve(string byUser) => SetUpdatedBy(byUser);
POST /v1/products/{id}/approve
{ "byUser": "alice" }                →  200 + ProductDto

The generated request and handler follow the same <Method><Entity>Request / <Method><Entity>Handler convention as the update pair — ApproveProductRequest, ApproveProductHandler — in the Minimal.AppServices.Crud namespace. They are compiler output under obj/Generated/, not files in the repo; build once and read them there. Set [CrudAction(Name = "...")] if you need a different request type name.

Overriding the route segment

Pass the segment as the attribute’s constructor argument when the kebab-cased method name is not the noun you want in the URL:

[CrudAction("approval")]
public void Approve(string byUser) => SetUpdatedBy(byUser);
POST /v1/products/{id}/approval

This is what the sample does — the operation stays Approve in the domain, while the URL reads as the resource approval. Pass a bare segment ("approval"), not a path: it is appended to the entity’s by-id route, never substituted for it.

Overriding the verb

Set Verb:

[CrudAction(Verb = CrudActionVerb.Put)]
public void Discontinue() => IsDiscontinued = true;
PUT /v1/products/{id}/discontinue

CrudActionVerb offers exactly three members — Post (the default), Put and Patch. There is no Delete; if you are looking for one, the generated DELETE /{id} route from MapDeleteById is the only delete the generator publishes, and a “soft delete” is a domain action like any other (Discontinue above is precisely that). Segment and verb are independent — set either, both, or neither:

[CrudAction("archived", Verb = CrudActionVerb.Patch)]   // PATCH .../{id}/archived

Action or update? Pick by whether repeating the call is safe

Both annotations run a method on an existing row, so the choice is not about mechanics. It is about what you are promising the caller:

This sample is its own cautionary tale. Approve was originally declared [CrudUpdate], so the template published a business action as PUT /v1/products/{id} — advertising to every client generated against it that approval was safe to retry. Nothing about the code was wrong; the contract was. Moving it to [CrudAction("approval")] did not change what Approve does, only what the API promises about calling it twice.

Rule of thumb: if you would be uncomfortable with a client’s retry-on-timeout policy replaying the call unattended, it is an action, not an update.

What an action does not give you. Everything the generated create/update path gives up, an action gives up too — the validation gap (DataAnnotations on an action’s parameters are forwarded onto the request and never evaluated), no idempotency filter, and the DTO’s every-audited-field default.

A pre-condition is not on that list. A generated action’s request is body-bound and passes through the group-level AddFluentValidationAutoValidation() filter (Minimal.Api/Program.cs:50), so a FluentValidation validator registered for it runs before the generated handler and can read stored data and refuse. The automated sample proves it on the two generated routes that needed it: CreateProductRequestValidator refuses a name already taken and DeleteProductRequestValidator refuses a product still for sale, both with 409, with no hand-written route, request or handler. Write the rule as a validator; the manual sample’s Cancel.cs, which rejects an already-cancelled order with a domain-specific 400 from inside the handler, is the shape to copy only when the refusal has to come from the aggregate itself.

The bar for hand-writing a route is higher: the operation writes more than one aggregate in one transaction, which the generator cannot express at all. Discontinue is this sample’s one such route — discontinuing a product also creates its named replacement in the same transaction — so ProductV1Endpoint drops it with Exclude("Discontinue") and hand-writes PUT /v1/products/{id}/discontinue below the generated call. Its refusal of a second call comes free with being hand-written; it is not what qualified it.

Worked example: add your own action

Say the sample needs to archive a product — take it off the catalogue without deleting the row — and you want it published as PATCH /v1/products/{id}/archived. Everything needed is in Product.cs:

using System.ComponentModel.DataAnnotations;
using DKNet.EfCore.Abstractions.Attributes;   // [CrudCreate], [CrudUpdate], [CrudAction], CrudActionVerb

public class Product : AggregateRoot
{
    // ... existing members ...

    public bool IsArchived { get; private set; }
    public string? ArchiveReason { get; private set; }

    /// <summary>Takes the product off the catalogue.</summary>
    [CrudAction("archived", Verb = CrudActionVerb.Patch)]
    public void Archive([StringLength(500)] string reason)
    {
        IsArchived = true;
        ArchiveReason = reason;
    }
}

Build, and the generator has published:

   
Verb & route PATCH /v1/products/{id}/archived
Request body { "reason": "..." } — from the method’s parameter list
Response 200 + ProductDto
Unknown id 404
Generated types ArchiveProductRequest, ArchiveProductHandler (obj/Generated/)

Drop the "archived" argument and the route segment becomes archive. Drop Verb and it becomes a POST. No endpoint registration, no request record and no handler is written by hand — ProductV1Endpoint’s MapProductCrud(...) call already picks the new route up, and it stays picked up unless you exclude it by name.

Archive is an exercise for this page. It is not part of the shipped sample, which publishes exactly two generated actions — Approve and AssignSupplierReference; its third, Discontinue, is excluded by name from the generated map and hand-written instead. Add Archive to your own copy, not to the template’s.

End-to-end trace, both samples

Step ManualSample/PurchaseOrder AutomatedSample/Product
HTTP endpoint Minimal.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs — literal group.MapPost("/", ...) etc., one call per route Minimal.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs — composite: group.MapProductCrud(o => …) first, carrying the per-route scopes and one Exclude("Discontinue"), then the two hand-written routes below it
Validation CreatePurchaseOrderCommandValidator/UpdatePurchaseOrderCommandValidator (FluentValidation) run and are enforced on every hand-mapped route FluentValidation runs here too — the group filter reaches generated routes, so CreateProductRequestValidator/DeleteProductRequestValidator refuse with 409 before the generated handler. What is not evaluated is a forwarded [Range]/[Required] on the [CrudCreate]/[CrudUpdate] parameters: MapProductCrud routes through DKNet.AspCore.Extensions’s generic Map*<TRequest,TDto> wrapper, which the .NET 10 minimal-API validation source generator can’t see through. A negative price returns 201, not 400
Bus dispatch IMessageBus bus → bus.Send(req, ...) in the endpoint delegate Same IMessageBus, dispatched inside the generated route delegate
Handler CreatePurchaseOrderCommandHandler/UpdatePurchaseOrderCommandHandler (Minimal.AppServices/ManualSample/V1/Actions/) Generated CreateProductHandler/ChangePriceProductHandler (Minimal.AppServices.Crud namespace)
Domain entity method new PurchaseOrder(...) / order.ChangeAmount(...) new Product(request.Name, request.Price) / product.ChangePrice(request.Price)
Repository / SaveChanges IRepositorySpec.AddAsync/implicit update via change tracking, same CoreDbContext Same repository abstraction, same CoreDbContext
Event AddEvent(new PurchaseOrderCreatedEvent(...)) inside the constructor [RaisesEvent]-declared, raised by DKNet’s save hook — see docs/efcore-events.md
Response DTO Hand-written PurchaseOrderDto mapped via mapper.ResultOf<PurchaseOrderDto>(order) Generated ProductDto mapped the same way through the ScanMaps-registered Mapster config

The hand-written path’s explicit actions — Minimal.AppServices/ManualSample/V1/Actions/Create.cs, Update.cs, Cancel.cs, Delete.cs — are the contrast. Cancel.cs rejects an already-cancelled order with a domain-specific failure, and Delete.cs 404s via NotFoundError before deleting. A generic delete-by-id has no handler of its own to fail from, so a hand-written Delete.cs is still the only way to return a domain failure. A pre-delete rule, however, needs no hand-written route: a validator on the generated DeleteProductRequest refuses first — that is how the automated sample keeps a product that is still for sale from being deleted.

Data seeding

IDataSeedingConfiguration<T> implementations seed reference data on migration. The manual sample has one — Minimal.Infra/Features/ManualSample/StaticData/PurchaseOrderStaticData.cs:

internal sealed class PurchaseOrderStaticData : DataSeedingConfiguration<PurchaseOrder>
{
    protected override ValueTask<ICollection<PurchaseOrder>> GetDataAsync(CancellationToken cancellation = new())
        => ValueTask.FromResult<ICollection<PurchaseOrder>>([ /* three fixed-Guid rows */ ]);
}

UseAutoConfigModel/UseAutoDataSeeding pick up every such class (and every IEntityTypeConfiguration<T>) by assembly scan. This is wired in both places that build a CoreDbContext, and both must carry the call for seeding to actually run:

Missing the call in either one is a real bug this template already hit once: seeding worked from one path and silently not the other.

One caveat still stands: no test fixture in this repo wires .UseAutoDataSeeding(...). Neither the xUnit Support.ApiFixture nor the BDD BddApiFactory calls it. That wiring exists only in InfraMigration.MigrateDb and InfraSetup.AddInfraServices, the real app’s composition root. The one test that exercises seeded data for real, Minimal.App.Tests/Integration/ManualSample/V1/InfraMigrationSeedingTests.cs, calls InfraMigration.MigrateDb directly against an ephemeral Postgres container. Every other test fixture starts from an empty database.

Sequences

[SqlSequence] on an enum declares a set of named PostgreSQL sequences — Minimal.Domains/Share/Sequences.cs:

[SqlSequence]
public enum Sequences
{
    None = 0,

    [Sequence(typeof(int), FormatString = "T{DateTime:yyMMdd}{1:00000}", Max = 99999)]
    Membership = 1
}

ISequenceServices/its base SequenceService (Minimal.Infra/Services/SequenceService.cs) wrap one sequence member each. NextValueAsync() calls dbContext.NextSeqValueWithFormat(sequence) on Postgres, formatting the next value with the pattern above (e.g. T26082500001). It falls back to a plain Guid when the context isn’t Npgsql, so unit tests without a real Postgres don’t need one.

Neither PurchaseOrder nor Product uses a sequence today. This capability exists for a future generated entity that needs a human-readable, sequential identifier instead of a Guid. Declare a new Sequences member and a SequenceService subclass the same way IMembershipService/ MembershipService do for Sequences.Membership.