DKNet.Templates

Manual vs. Automated: A Layer-by-Layer Comparison

The template ships two complete samples under src/ApiEndpoints/. Both implement the same shape of feature — entity, event, event handler, CRUD, queries, endpoint — but by different means:

Sample Location Approach
PurchaseOrder ManualSample/ Every layer is hand-written. No declarative event/CRUD/DTO-generation attribute is used anywhere.
Product AutomatedSample/ Every layer the DKNet 10.1.14 generators can produce is declared, not written: [RaisesEvent] for events, [CrudCreate]/[CrudUpdate]/[CrudAction] + [GenerateDto] for the request/handler/route/DTO shapes.

This document walks through that difference layer by layer and, wherever the automated sample produces a layer instead of writing it, explains what control or visibility you give up in exchange.

How these claims were verified. Every statement below is checked against the code on this branch. Generated type names come from the compiled output (Minimal.AppServices/obj/Generated/...). The two event record types that Minimal.Domains doesn’t emit to disk were confirmed by a strings scan of Minimal.Domains.dll, which shows both ProductCreatedEvent and ProductPriceUpdatedEvent as real compiled types.

At a glance: which one should I copy?

This section is the single statement of which sample to copy. Every other page links here rather than paraphrasing it — the per-sample READMEs and docs/ddd-implementation-guide.md both do.

Copy the manual sample (PurchaseOrder) when the feature needs any of the following. Each one maps to a trade-off explained later in this document:

A response shape you control is no longer a reason either. Hiding a field on a generated DTO is [GenerateDto(..., Exclude = [...])], and a value the convention cannot produce can be added to a generated response by hand — one property on the partial record plus a Mapster IRegister, with no route, request or handler leaving the generated set. The worked case is ProductDto.GrossMargin: see the automated sample and §3 below for what it costs on the list route’s query surface.

A rule that conditionally refuses an operation is not on that list. A FluentValidation validator written against a generated request runs on the generated route — the group-level AddFluentValidationAutoValidation() filter (Minimal.Api/Program.cs:50) applies to every route in the group, generated or hand-mapped — so the rule can read stored data and refuse before the handler runs. The automated sample does exactly that twice: POST /v1/products refuses a name already taken, and DELETE /v1/products/{id} refuses a product that is still for sale. Both answer 409, and neither has a hand-written route, request or handler. See §4 below.

Copy the automated sample (Product) for a genuinely plain CRUD entity — one where DataAnnotations can express every validation rule you care about (or you don’t need them enforced), and where exposing every audited field in the DTO is acceptable. In exchange you write an entity, one DTO line, and a handful of attributes instead of roughly 14 hand-written files, and you get a stronger acting-user guarantee.

That generated shape is not limited to plain create/update/list/delete: named domain actions come with it. [CrudAction] publishes a business operation — approve, assign a supplier reference — at the entity’s by-id route plus a segment, with the verb and segment under your control, and no hand-written request, handler or route registration. The trade-off is the one in the first list above: an attribute-declared rule on a generated action’s request is still never evaluated. Needing to refuse an action is not a trade-off any more — write the rule as a FluentValidation validator against the generated request, the way this sample’s create and delete rules are written.

The choice is not either/or, and the automated sample no longer pretends it is. CrudMapOptions lets one endpoint drop a single generated route by name and hand-write that one route below the generated call, so the generator keeps the routes it expresses well and you write only the ones it cannot. ProductV1Endpoint is exactly that composite: MapProductCrud(o => …) first, carrying the per-route scopes and Exclude("Discontinue"), then two hand-written routes — a discontinue that writes two aggregates in one transaction, and a summary query the generator has no shape for.

The rest of this document is the evidence behind that guidance.

How each request flows

Both samples run the same request through the same vertical slice. The difference is who authors each stage — you, a compile-time generator, or a generic library route with no per-entity code behind it.

Legend: blue = hand-written · amber = generated at compile time · purple = generic library route, no per-entity code.

flowchart TB
    classDef hand fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
    classDef gen fill:#fef3c7,stroke:#b45309,color:#78350f;
    classDef lib fill:#ede9fe,stroke:#6d28d9,color:#4c1d95;

    subgraph MANUAL["Manual — PurchaseOrder (every layer hand-written)"]
        direction TB
        Mreq["HTTP POST /v1/purchase-orders"]
        M1["Endpoint: literal MapPost/MapGet/...<br/>+ .RequiredIdempotentKey()"]:::hand
        M2["CreatePurchaseOrderRequest<br/>+ FluentValidation — enforced"]:::hand
        M3["CreatePurchaseOrderCommandHandler"]:::hand
        M4["PurchaseOrder aggregate<br/>ctor calls AddEvent(...)"]:::hand
        M5["EF Core mapper + SaveChanges"]:::hand
        M6["PurchaseOrderDto — 5 hand-picked fields"]:::hand
        Mev["PurchaseOrderCreatedEvent<br/>→ in-memory handler"]:::hand
        Mreq --> M1 --> M2 --> M3 --> M4 --> M5 --> M6
        M5 -. raised on save .-> Mev
    end

    subgraph AUTO["Automated — Product (declared, then generated)"]
        direction TB
        Areq["HTTP POST /v1/products"]
        A1["Endpoint: MapProductCrud(o => ...) call"]:::gen
        A2["CreateProductRequest — generated<br/>[Range] present, NOT enforced"]:::lib
        A3["CreateProductHandler — generated"]:::gen
        A4["Product aggregate<br/>[RaisesEvent] declared"]:::hand
        A5["EF Core mapper + SaveChanges"]:::hand
        A6["ProductDto — every audited field"]:::gen
        Aev["EF save hook reads [RaisesEvent]<br/>→ ProductCreatedEvent"]:::gen
        Ain["in-memory handler"]:::hand
        Aext["Azure topic →<br/>ProductCreatedNotificationHandler"]:::hand
        Areq --> A1 --> A2 --> A3 --> A4 --> A5 --> A6
        A5 -. raised on save .-> Aev
        Aev --> Ain
        Aev --> Aext
    end

Read the two lanes stage for stage. The amber and purple nodes in the automated lane are exactly the layers you no longer write — and exactly where the trade-offs below live. The blue nodes in both lanes are the layers no generator touches.

Layers both samples hand-write

No generator in this template produces these layers. The automated sample writes them by hand just like the manual one, so they carry no trade-off:

Layers the automated sample generates

Each of these is a layer Product declares (via an attribute) and the DKNet 10.1.14 generators emit at compile time — the amber and purple nodes in the diagram:

The trade-offs

Each trade-off below corresponds to an amber or purple node in the diagram. They are ordered sharpest first.

1. Request validation that looks wired but never runs (the sharpest gap)

What you’d expect: CreateProductRequest.Price carries [Range(0.01, double.MaxValue)] — attribute forwarding works exactly as documented (inspect ProductCrudRequests.g.cs) — so a negative price should be rejected.

What actually happens: nothing evaluates the attribute. Empirically, POST /v1/products with price: -1 returns 201 Created, not 400.

Why: .NET 10’s automatic minimal-API validation for complex-type parameters only activates through the Microsoft.Extensions.Validation.ValidationsGenerator source generator, which is gated on <EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator> (not set here). Even with that flag forced on, the generator only recognizes literal Map*(string, Delegate) calls in the compiling project’s own source. The manual sample writes exactly those literal calls. The automated sample maps through DKNet.AspCore.Extensions’s generic MapPost<TRequest,TResponse> — compiled inside a precompiled package the source generator cannot see through.

The gap is the attribute, not validation itself. FluentValidation reaches a generated route by a different road: UseEndpointConfigs applies AddFluentValidationAutoValidation() to every endpoint group (Minimal.Api/Program.cs:50), so a validator registered for a generated request type is run before the generated handler, with no literal Map* call needed. Writing one is the supported fix and the sample ships two of them — see §4. What remains true, and has no fix short of changing DKNet.AspCore.Extensions itself (a different repo): a DataAnnotations attribute forwarded onto a generated request is never evaluated, so price: -1 still returns 201. Express a rule you actually need enforced as a validator, not as an attribute.

2. No idempotency on POST

The manual create route calls .RequiredIdempotentKey(): a replayed X-Idempotency-Key returns the original response instead of creating a duplicate (confirmed live — same key twice returns the same id and body).

The generated MapPost<CreateProductRequest, ProductDto> adds no such filter, and nothing in ProductV1Endpoint adds one by hand. A duplicate submit or client retry on POST /v1/products silently creates two products. Adding protection means dropping the generated create route.

3. The DTO exposes every audited field by default

The generator’s default is “everything audited”, not “only what I chose”, so narrowing the shape takes an explicit Exclude/Include on [GenerateDto]. The sample does narrow — it excludes OwnedBy, LastModifiedBy and LastModifiedOn — but still ships CreatedBy, CreatedOn, UpdatedBy and UpdatedOn to every caller, because those are what “audited” means here. By contrast, the manual PurchaseOrderDto exposes exactly 5 fields — because nobody wrote the others into it.

On a generated slice the exclusion list is doing double duty: the DTO is also the filter, search and order surface of the free list route, so a field you exclude becomes unqueryable over HTTP, and a field whose entity counterpart is computed rather than mapped turns every ?search= into a 500. That second reason, not tidiness, is why LastModifiedBy/LastModifiedOn are excluded — see Generic List Endpoint.

The exclusion list only narrows. Widening works too: a value the convention cannot produce — anything derived from more than one column — can be added to a generated DTO by hand-writing the property on the partial record and a Mapster IRegister that maps it, with every other property still coming from the convention. ProductDto.GrossMargin is that case, worked end to end in the automated sample, including its one cost: a property with no entity column behind it cannot be filtered or ordered on over HTTP, so ?orderBy=grossMargin answers 400.

4. No filtered list, no custom get-by-id — and what a pre-condition actually costs

The generic routes take what the generator can express, and no more. Where that is not enough, the fix is per-route rather than per-entity: CrudMapOptions.Exclude(string) drops the one route by name and you hand-write it below the generated call — which is what ProductV1Endpoint does for Discontinue. What each generic route cannot do on its own:

5. Event names follow a convention, and requests can’t carry extra fields

The [RaisesEvent] convention composes event names as <Entity><Label?><NarrowingProps><Operation>Event — hence ProductPriceUpdatedEvent, not ProductUpdatedEvent. Two updates on different properties need distinct label segments to avoid a collision; you don’t get to hand-choose names. Adding a field the generator didn’t Include/infer means switching to the type-naming form ([RaisesEvent(typeof(SomeDto), ...)] against a [GenerateDto] record you own).

Likewise, a generated request is a mechanical 1:1 of the source signature: CreateProductRequest mirrors the constructor’s parameters, and ChangePriceProductRequest can only ever change price. You cannot add a request-only field (a captcha token, a client correlation id) without also adding it to the constructor. A method that needs to change two unrelated fields together needs two [CrudUpdate] methods (two routes) — or a hand-written one.

6. Acting-user attribution: you lose visibility, not safety

The generator forwards only System.ComponentModel.DataAnnotations attributes, so [FromClaim] (namespace DKNet.AspCore.Extensions.ModelBinding) can never reach a generated property — the [CrudCreate] constructor takes no acting-user parameter.

Instead, DKNet.EfCore.AuditLogs’s audit hook stamps CreatedBy/CreatedOn on insert and UpdatedBy/UpdatedOn on modify, reading the user from ICurrentUserProvider. It is wired once at the composition root (ServiceConfigs.cs), alongside DataOwnerHook, which stamps the OwnedBy tenant key from IDataOwnerProvider. A payload claiming "createdBy": "someone-else" has no property to land on, so the forgery guarantee is identical to the manual sample’s [FromClaim] population.

What you actually lose is the ability to see where attribution happens by reading AutomatedSample/ — it lives in a shared save hook. (The audit hook stamps on modify as well as insert — verified live over Product’s PUT route.)

7. The external-broker path is real but untested here

ServiceBusSetup.cs wires azb.Produce<ProductCreatedEvent>(...) / azb.Consume<ProductCreatedEvent>(...), and ProductCreatedNotificationHandler is the hand-written external subscriber. This proves a declaratively raised event still reaches an external topic exactly like a hand-raised one.

However, that handler registers only on the AzureBus child bus, which is wired only when FeatureManagement:EnableServiceBus is true and ConnectionStrings:AzureBus is non-empty — and neither the xUnit nor the BDD host ever sets the connection string. The in-memory bus the tests run against is a separate bus. ProductCreatedNotificationHandlerTests covers the handler’s own behaviour directly, but the full Produce → topic → Consume path against a real or emulated broker remains untested.

(The manual sample carries no external-broker wiring at all — a deliberate scope split, not a generator limitation. Static seeding is the mirror image of that split: the manual sample seeds 3 PurchaseOrder rows via UseAutoDataSeeding, while no Product seed file was written — though nothing about the generators would prevent one.)

Appendix: two real bugs the manual sample surfaced (and fixed)

Writing every layer by hand means bugs surface in code you wrote, where you can fix them directly. Both are recorded here because the comparison above assumes they are already fixed:

  1. Empty spec compiled to WHERE FALSE. SpecGetPurchaseOrder with no filter matched nothing, because the underlying predicate builder needs at least one .And()/.Or() call to “start”. Fixed by forcing a true predicate when neither byId nor byCustomerName is supplied (Minimal.AppServices/ManualSample/V1/Specs/SpecGetPurchaseOrder.cs).
  2. Validator ran before the route value arrived. UpdatePurchaseOrderRequest.Id was validated as “must not be empty” against the raw request body, but the route supplies Id from the URL, not the JSON body — and auto-validation ran before the route value was patched in. Fixed by dropping the redundant rule; an unknown or empty id now correctly returns 404 from the repository lookup instead of 400 from the validator.

Neither class of bug is possible in the automated sample’s create/update path, because there is no hand-written spec or validator for a developer to get wrong. That absence is part of what “generated” buys you — symmetric with the validation gap above costing you elsewhere.