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.
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].)
[CrudCreate] on the constructor — the constructor’s parameter list is the generated create
request’s payload. DataAnnotations attributes on each parameter ([Required], [StringLength],
[Range]) are forwarded 1:1 onto the generated request’s matching property.[CrudUpdate] on a method — same rule for the method’s parameter list. ChangePrice(decimal
price) can only ever produce a request that changes price. A method that needs to change two
unrelated fields together needs two [CrudUpdate] methods (two routes), not one request with two
fields.[CrudAction] on a method — the same parameter-list rule once more, but it publishes a
domain action instead of an update: a POST (by default) at the entity’s by-id route plus one
extra segment. Approve, Discontinue and AssignSupplierReference above are the sample’s
three.
Full walkthrough below.[GenerateDto(typeof(Product))] — Minimal.AppServices/AutomatedSample/V1/ProductDto.cs:
[GenerateDto(typeof(Product),
Exclude = [nameof(Product.OwnedBy), nameof(AuditedEntity<Guid>.LastModifiedBy), nameof(AuditedEntity<Guid>.LastModifiedOn)])]
public sealed partial record ProductDto { … }
The default is “every audited property”, not “only what I chose” — narrow with
Exclude/Include when a field shouldn’t ship. The sample narrows for two different reasons:
OwnedBy duplicates CreatedBy on the wire, while LastModifiedBy/LastModifiedOn are computed
conveniences with no mapped column, and leaving them on the DTO breaks the generated list route
(see generic-list-endpoint.md).
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.
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):
CreateProductRequest, ChangePriceProductRequest,
ApproveProductRequest, DiscontinueProductRequest, AssignSupplierReferenceProductRequest.Minimal.AppServices.Crud namespace: CreateProductHandler,
ChangePriceProductHandler, ApproveProductHandler, DiscontinueProductHandler,
AssignSupplierReferenceProductHandler.ProductCrudEndpointExtensions.MapProductCrud().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.
Exclude(params CrudOp[]) — CrudOp has six members: GetById, GetList, Create,
Update, Delete, Action. An excluded operation is skipped entirely, not merely hidden.Exclude(params string[] routeNames) — drops the one generated route carrying that member
name and leaves the entity’s other routes of the same kind published. This is the per-method
exclusion: Exclude("Discontinue") drops that one action and keeps Approve and
AssignSupplierReference.Configure(CrudOp, Action<RouteHandlerBuilder>) — runs a RouteHandlerBuilder setting
against every generated route of that operation kind.Configure(string routeName, Action<RouteHandlerBuilder>) — runs it against the one route
carrying that name.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.
[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; }
[SensitiveData("pricing")] — only an authenticated caller for whom IsInRole("pricing") is
true receives supplierCostPrice. Name several roles ([SensitiveData("pricing", "audit")]) and
holding any one of them is enough.[SensitiveData] with no role — any authenticated caller receives supplierReferenceCode.
Naming no role does not mean “everyone”: an unauthenticated caller is still refused.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.
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.
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.
[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.
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.
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.
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
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:
[CrudUpdate] — the caller supplies a value and the row ends up holding that value.
Sending it twice leaves the same result as sending it once. That is what PUT means, and
ChangePrice(decimal) is the honest case: price becomes 9.99 however many times you call it.[CrudAction] — a business operation whose repetition is not automatically safe. Approving
an already-approved order, re-issuing a refund, re-publishing a document: whether the second call
is harmless is a question about your domain, not one the shape of the request answers. POST
is the default verb precisely because it makes no promise the generator cannot keep.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 (
DataAnnotationson 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:CreateProductRequestValidatorrefuses a name already taken andDeleteProductRequestValidatorrefuses a product still for sale, both with409, with no hand-written route, request or handler. Write the rule as a validator; the manual sample’sCancel.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.
Discontinueis this sample’s one such route — discontinuing a product also creates its named replacement in the same transaction — soProductV1Endpointdrops it withExclude("Discontinue")and hand-writesPUT /v1/products/{id}/discontinuebelow the generated call. Its refusal of a second call comes free with being hand-written; it is not what qualified it.
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.
Archiveis an exercise for this page. It is not part of the shipped sample, which publishes exactly two generated actions —ApproveandAssignSupplierReference; its third,Discontinue, is excluded by name from the generated map and hand-written instead. AddArchiveto your own copy, not to the template’s.
| 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.
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:
Minimal.Infra/Extensions/InfraSetup.cs → AddInfraServices (the DI-registered context the
running app uses)Minimal.Infra/Extensions/InfraMigration.cs → MigrateDb (the startup-migration path)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.
[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.