@drunkcoding/dknet-implementation-skills 14.1.0 → 14.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +37 -26
- package/package.json +2 -3
- package/{.claude-plugin → plugin/.claude-plugin}/plugin.json +1 -1
- package/{agents → plugin/agents}/dknet-architect.md +4 -4
- package/{agents → plugin/agents}/dknet-bdd-engineer.md +6 -6
- package/{agents → plugin/agents}/dknet-implementer.md +11 -11
- package/{skills → plugin/skills}/README.md +13 -12
- package/{skills → plugin/skills}/dknet-auth-and-ownership/SKILL.md +100 -36
- package/{skills → plugin/skills}/dknet-bdd-tests/SKILL.md +25 -16
- package/{skills → plugin/skills}/dknet-bdd-tests/checklist.md +1 -1
- package/{skills → plugin/skills}/dknet-crud/SKILL.md +34 -22
- package/{skills → plugin/skills}/dknet-ddd-principles/SKILL.md +62 -13
- package/{skills → plugin/skills}/dknet-docs/SKILL.md +73 -31
- package/{skills → plugin/skills}/dknet-docs/templates/README-template.md +8 -8
- package/{skills → plugin/skills}/dknet-docs/templates/api-reference-template.md +7 -0
- package/{skills → plugin/skills}/dknet-docs/templates/architecture-template.md +14 -8
- package/{skills → plugin/skills}/dknet-docs/templates/data-model-template.md +2 -2
- package/{skills → plugin/skills}/dknet-docs/templates/events-template.md +2 -2
- package/{skills → plugin/skills}/dknet-dto-mapping/SKILL.md +37 -29
- package/{skills → plugin/skills}/dknet-efcore-config/SKILL.md +38 -38
- package/{skills → plugin/skills}/dknet-endpoint/SKILL.md +70 -43
- package/{skills → plugin/skills}/dknet-entity/SKILL.md +43 -33
- package/{skills → plugin/skills}/dknet-feature/SKILL.md +34 -13
- package/{skills → plugin/skills}/dknet-feature-lifecycle/SKILL.md +48 -42
- package/{skills → plugin/skills}/dknet-feature-remove/SKILL.md +15 -15
- package/{skills → plugin/skills}/dknet-messaging-events/SKILL.md +36 -29
- package/{skills → plugin/skills}/dknet-package-adoption/SKILL.md +3 -3
- package/{skills → plugin/skills}/dknet-platform-config/SKILL.md +14 -14
- package/{skills → plugin/skills}/dknet-project-structure/SKILL.md +32 -32
- package/{skills → plugin/skills}/dknet-queries-specs/SKILL.md +21 -13
- package/{skills → plugin/skills}/dknet-scaffold/SKILL.md +3 -2
- package/{skills → plugin/skills}/dknet-unit-tests/SKILL.md +27 -22
- package/plugin.json +3 -3
- /package/{skills → plugin/skills}/dknet-docs/checklist.md +0 -0
|
@@ -12,9 +12,20 @@ Usage: `/dknet-endpoint <Feature> <Entity> [mode=manual|auto] [routePrefix] [ver
|
|
|
12
12
|
# Skill: Endpoint Configuration
|
|
13
13
|
|
|
14
14
|
Wires AppServices actions/queries — or an entity's `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]`
|
|
15
|
-
declarations — to HTTP routes via `IEndpointConfig`.
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
declarations — to HTTP routes via `IEndpointConfig`.
|
|
16
|
+
|
|
17
|
+
**Pick the highest rung that expresses the route. Do not start at the bottom.**
|
|
18
|
+
|
|
19
|
+
| Rung | Shape | Use it when |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| 1 | **Option B** — `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]` on the entity + one `group.Map{Entity}Crud()` call (§3) | The route is create, update, delete, get-by-id, list, or a single-aggregate business action. This is the default for a new feature — declare the operation on the entity and let the generator emit the request, handler and route. |
|
|
22
|
+
| 2 | **Option C** — the package's generic `MapGetById`/`MapGetList`/`MapPost<TRequest,TDto>`/… helpers (§4) | The entity carries no CRUD attributes (a read-mostly reference table, an entity you don't own), but the route is still a plain shape the helpers already implement. |
|
|
23
|
+
| 3 | **Option A** — a literal `group.MapPost/MapGet/MapPut/MapDelete(...)` call dispatching through `IMessageBus` (§2) | Nothing above can express it: multiple aggregates in one transaction, a response shape with no DTO behind it, or a create route that must enforce `.RequiredIdempotentKey()` or a DataAnnotations attribute (§5). |
|
|
24
|
+
|
|
25
|
+
Rungs mix freely inside one `Map(RouteGroupBuilder)` — `ProductV1Endpoint` maps its whole generated
|
|
26
|
+
slice, excludes one route by name, and hand-maps two below it. `mode=manual`/`mode=auto` says which
|
|
27
|
+
rung the feature's *actions* were built for; it does not license dropping to rung 3 for a route rung 1
|
|
28
|
+
would have covered.
|
|
18
29
|
|
|
19
30
|
## 1. The `IEndpointConfig` contract
|
|
20
31
|
|
|
@@ -28,7 +39,7 @@ internal sealed class {Entity}V1Endpoint : IEndpointConfig
|
|
|
28
39
|
```
|
|
29
40
|
|
|
30
41
|
- **Discovery**: every non-abstract `IEndpointConfig` in the API assembly is found by
|
|
31
|
-
`UseEndpointConfigs(...)`, called once from
|
|
42
|
+
`UseEndpointConfigs(...)`, called once from `<YourApp>.Api/Program.cs`. Never register a route group
|
|
32
43
|
by hand.
|
|
33
44
|
- **Versioning**: `EnableVersioning` (`FeatureManagement`, default `true`) turns `GroupEndpoint` into
|
|
34
45
|
`/v{version}/{route}` — `"/products"` at `Version => 1` becomes `/v1/products`. Off, the group
|
|
@@ -56,11 +67,11 @@ MapDelete(...)` call, dispatching through `IMessageBus` by hand:
|
|
|
56
67
|
```csharp
|
|
57
68
|
using DKNet.AspCore.Extensions.Responses;
|
|
58
69
|
using DKNet.AspCore.Idempotency;
|
|
59
|
-
using
|
|
60
|
-
using
|
|
61
|
-
using PurchaseOrderDto =
|
|
70
|
+
using <YourApp>.AppServices.ManualSample.V1.Actions;
|
|
71
|
+
using <YourApp>.AppServices.ManualSample.V1.Queries;
|
|
72
|
+
using PurchaseOrderDto = <YourApp>.AppServices.ManualSample.V1.PurchaseOrderDto;
|
|
62
73
|
|
|
63
|
-
namespace
|
|
74
|
+
namespace <YourApp>.Api.ApiEndpoints.ManualSample;
|
|
64
75
|
|
|
65
76
|
internal sealed class PurchaseOrderV1Endpoint : IEndpointConfig
|
|
66
77
|
{
|
|
@@ -80,10 +91,6 @@ internal sealed class PurchaseOrderV1Endpoint : IEndpointConfig
|
|
|
80
91
|
"Create purchase order. <br/><br/> Note: Idempotency key is required in the header. <br/>" +
|
|
81
92
|
"X-Idempotency-Key: {IdempotencyKey} <br/>");
|
|
82
93
|
|
|
83
|
-
group.MapGet("/", async ([AsParameters] ListPurchaseOrdersQuery query, IMessageBus bus, CancellationToken ct) =>
|
|
84
|
-
Results.Ok(await bus.Send(query, cancellationToken: ct)))
|
|
85
|
-
.WithDescription("Get purchase orders (paged, optionally filtered by customer name).");
|
|
86
|
-
|
|
87
94
|
group.MapGet("{id:guid}", async (Guid id, IMessageBus bus, CancellationToken ct) =>
|
|
88
95
|
{
|
|
89
96
|
var dto = await bus.Send(new GetPurchaseOrderByIdQuery { Id = id }, cancellationToken: ct);
|
|
@@ -144,12 +151,12 @@ registers the entire generated CRUD surface; hand-mapped routes for what the gen
|
|
|
144
151
|
go below it.
|
|
145
152
|
|
|
146
153
|
```csharp
|
|
147
|
-
using
|
|
148
|
-
using
|
|
149
|
-
using
|
|
150
|
-
using
|
|
154
|
+
using <YourApp>.AppServices.AutomatedSample.V1;
|
|
155
|
+
using <YourApp>.AppServices.AutomatedSample.V1.Actions;
|
|
156
|
+
using <YourApp>.AppServices.AutomatedSample.V1.Queries;
|
|
157
|
+
using <YourApp>.AppServices.Crud;
|
|
151
158
|
|
|
152
|
-
namespace
|
|
159
|
+
namespace <YourApp>.Api.ApiEndpoints.AutomatedSample;
|
|
153
160
|
|
|
154
161
|
internal sealed class ProductV1Endpoint : IEndpointConfig
|
|
155
162
|
{
|
|
@@ -197,7 +204,7 @@ internal sealed class ProductV1Endpoint : IEndpointConfig
|
|
|
197
204
|
}
|
|
198
205
|
```
|
|
199
206
|
|
|
200
|
-
`ProductScopes` (
|
|
207
|
+
`ProductScopes` (`<YourApp>.Api/ApiEndpoints/AutomatedSample/ProductScopes.cs`) is a plain
|
|
201
208
|
`internal static class` of policy-name constants — one per authorization scope the routes above
|
|
202
209
|
require, registered as authorization policies by `AddAuthConfig()` only when `RequireAuthorization`
|
|
203
210
|
is on:
|
|
@@ -229,7 +236,7 @@ PUT {id} (per update request), DELETE {id} and each generated domain-action endp
|
|
|
229
236
|
### Where the generated code lives
|
|
230
237
|
|
|
231
238
|
`DKNet.SlimBus.Generators` emits, per entity, under
|
|
232
|
-
`ApiEndpoints
|
|
239
|
+
`ApiEndpoints/<YourApp>.AppServices/obj/Generated/DKNet.SlimBus.Generators/.../` — not committed, so
|
|
233
240
|
build once (`dotnet build`) and read it there for the exact shape:
|
|
234
241
|
|
|
235
242
|
- `{Entity}CrudRequests.g.cs` — one `sealed partial record` per route (`Create{Entity}Request`,
|
|
@@ -305,8 +312,9 @@ group.MapParameterlessActionById<TRequest, TKey, TDto>("{id}/x", "PUT"); // any
|
|
|
305
312
|
already resolve `ErrorResponseOptions` via `[FromServices]` — calling these helpers directly gets
|
|
306
313
|
the same behavior.
|
|
307
314
|
|
|
308
|
-
Reach for Option C
|
|
309
|
-
|
|
315
|
+
Reach for Option C when the entity carries no CRUD attributes but the route is still one of these
|
|
316
|
+
stock shapes — it is a rung above hand-mapping, not a last resort. Drop to Option A only for a route
|
|
317
|
+
none of these helpers can express.
|
|
310
318
|
|
|
311
319
|
## 5. Cross-cutting behavior
|
|
312
320
|
|
|
@@ -333,12 +341,34 @@ Only Option A's literal `group.MapPost("/", async (CreateXRequest req, ...) => .
|
|
|
333
341
|
attribute enforced. If a create/update rule must be enforced, either hand-map that one route (Option
|
|
334
342
|
A) or write it as a FluentValidation validator instead of a DataAnnotations attribute.
|
|
335
343
|
|
|
336
|
-
**Authorization scopes
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
344
|
+
**Authorization scopes: declare them on the class with `[EndpointGroupScope]` first.**
|
|
345
|
+
`EndpointGroupScopeAttribute` (`DKNet.AspCore.Extensions.Endpoints`, **13.0.0 or newer**) sits above
|
|
346
|
+
the `IEndpointConfig` class and names the scope one or more HTTP methods require:
|
|
347
|
+
|
|
348
|
+
```csharp
|
|
349
|
+
[EndpointGroupScope(ProductScopes.Read, EndpointHttpMethods.Get)]
|
|
350
|
+
[EndpointGroupScope(ProductScopes.Write, EndpointHttpMethods.Post, EndpointHttpMethods.Put,
|
|
351
|
+
EndpointHttpMethods.Delete)]
|
|
352
|
+
internal sealed class ProductV1Endpoint : IEndpointConfig { /* ... */ }
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
It is stackable; a declaration naming no method becomes the group's default, and a per-method
|
|
356
|
+
declaration beats that default for its own method. It applies to generated and hand-mapped routes
|
|
357
|
+
alike, skips any route that already names its own policy or allows anonymous access, and **fails
|
|
358
|
+
closed** — once one declaration exists, a served method with neither a declaration nor a group
|
|
359
|
+
default is refused when the group's endpoints are built. Above all, it is applied only when
|
|
360
|
+
`EndpointRegistrationOptions.RequireAuthorization` is `true` (the value `Program.cs` assigns from
|
|
361
|
+
`FeatureOptions.RequireAuthorization`), so it needs no flag check of its own.
|
|
362
|
+
|
|
363
|
+
Fall back to `o.Configure(CrudOp.X, …)`, `o.Configure("RouteName", …)`, or
|
|
364
|
+
`.RequireAuthorization(scope)` on a `RouteHandlerBuilder` **only when two routes sharing one HTTP
|
|
365
|
+
method need different scopes** — `Product`'s `Update` and `AssignSupplierReference` are both `PUT`,
|
|
366
|
+
and only the latter may demand `products.supplier`. Those three forms do *not* self-gate: with the
|
|
367
|
+
flag off no policies were registered and the call throws at request time, so each must sit behind the
|
|
368
|
+
`IOptions<FeatureOptions>` guard shown in §3. The shipped `ProductV1Endpoint` is written exactly this
|
|
369
|
+
way: two class-level declarations, and a guarded per-route override for only the two `PUT` routes
|
|
370
|
+
(`AssignSupplierReference`, `discontinue`) that need their own scope. Full treatment: the
|
|
371
|
+
`dknet-auth-and-ownership` skill.
|
|
342
372
|
|
|
343
373
|
**Status-counts helper.** `group.MapGetStatusCounts<TEntity>("status", new StatusPropertyInfo(nameof(X.Status), typeof(XStatus)))`
|
|
344
374
|
is a template-local extension (not part of the published package) that groups an entity's rows by an
|
|
@@ -365,9 +395,10 @@ skill.
|
|
|
365
395
|
|
|
366
396
|
### `mode=manual`
|
|
367
397
|
|
|
368
|
-
1. Confirm
|
|
369
|
-
`
|
|
370
|
-
|
|
398
|
+
1. Confirm rung 3 is actually needed — that the route is not one `[CrudCreate]`/`[CrudUpdate]`/
|
|
399
|
+
`[CrudAction]` (rung 1) or a generic helper (rung 2) would have covered — and that the feature's
|
|
400
|
+
`AppServices` layer exposes hand-written request/query records (see the `dknet-crud` skill).
|
|
401
|
+
2. Create `ApiEndpoints/<YourApp>.Api/ApiEndpoints/{Feature}/{Entity}V1Endpoint.cs`, `internal sealed`,
|
|
371
402
|
implementing `IEndpointConfig`.
|
|
372
403
|
3. Map every route as a literal `group.MapPost/MapGet/MapPut/MapDelete(...)` call per §2, dispatching
|
|
373
404
|
through `IMessageBus`.
|
|
@@ -379,25 +410,21 @@ skill.
|
|
|
379
410
|
1. Confirm the entity declares `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]` (see `/dknet-entity`)
|
|
380
411
|
and its DTO is `[GenerateDto(typeof(Entity))]`.
|
|
381
412
|
2. Build once so the generated `Map{Entity}Crud()` extension exists.
|
|
382
|
-
3. Create `ApiEndpoints
|
|
383
|
-
`group.Map{Entity}Crud()` — bare, or with an `Action<CrudMapOptions>` per §3 for
|
|
384
|
-
|
|
413
|
+
3. Create `ApiEndpoints/<YourApp>.Api/ApiEndpoints/{Feature}/{Entity}V1Endpoint.cs`, calling
|
|
414
|
+
`group.Map{Entity}Crud()` — bare, or with an `Action<CrudMapOptions>` per §3 for exclusions.
|
|
415
|
+
Declare authorization scopes with `[EndpointGroupScope]` on the class (§5); use
|
|
416
|
+
`o.Configure(...)` only for a route whose scope its HTTP method cannot decide.
|
|
385
417
|
4. For anything the generator can't express, hand-map it below the composite call, dropping the
|
|
386
418
|
generated route it replaces via `o.Exclude(...)` when one exists.
|
|
387
419
|
|
|
388
420
|
## Verification
|
|
389
421
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
dotnet run --project ApiEndpoints/Minimal.Api
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
Then exercise the route via `/docs` (Scalar, when `EnableSwagger` is on) or curl:
|
|
422
|
+
`dotnet build -c Release`, then `dotnet run --project ApiEndpoints/<YourApp>.Api` and exercise the
|
|
423
|
+
route via `/docs` (Scalar, when `EnableSwagger` is on) or curl:
|
|
396
424
|
|
|
397
425
|
```bash
|
|
398
426
|
curl -X POST https://localhost:5001/v1/{route} \
|
|
399
427
|
-H "Content-Type: application/json" -H "X-Idempotency-Key: $(uuidgen)" -d '{...}'
|
|
400
|
-
curl https://localhost:5001/v1/{route}/{id}
|
|
401
428
|
```
|
|
402
429
|
|
|
403
430
|
## Common mistakes
|
|
@@ -406,7 +433,7 @@ curl https://localhost:5001/v1/{route}/{id}
|
|
|
406
433
|
|---|---|---|
|
|
407
434
|
| A `[Range]`/`[Required]` on a `[CrudCreate]`/`[CrudUpdate]` parameter is enforced | A generated route accepts the out-of-range value and returns `201`/`200` | The .NET validation source generator can't see through the package's generic `Map*<TRequest,TDto>` wrapper — see §5 |
|
|
408
435
|
| `.RequiredIdempotentKey()` works the same on a generated create route | There's no route to call it on unless you exclude `"Create"` and hand-map it | The generated extension's calls are compiler output, not source you can chain onto |
|
|
409
|
-
| `RequireAuthorization(scope)` is safe to call unconditionally | It throws at request time when `RequireAuthorization` is off | No authorization middleware is added unless the flag is on — gate every call on `IOptions<FeatureOptions
|
|
436
|
+
| `RequireAuthorization(scope)` is safe to call unconditionally | It throws at request time when `RequireAuthorization` is off | No authorization middleware is added unless the flag is on — gate every call on `IOptions<FeatureOptions>`, or declare the scope with `[EndpointGroupScope]`, which is applied only when the flag is on |
|
|
410
437
|
| A `[CrudAction]` method parameter named `byUser` is the acting-user stamp | It becomes a caller-settable, body-bound `required string ByUser` on the generated request | Generated requests carry no `[FromClaim]`; the automated sample's acting-user attribution goes through `DataOwnerHook`/`AddCurrentUserProvider` instead — never name a generated action parameter after the acting user |
|
|
411
438
|
| Excluding a route by a typo'd name is silently ignored | The host throws `ArgumentException` at start-up | `ValidateRouteNames` checks every `Exclude`/`Configure` name against the entity's real route names before the app can serve traffic |
|
|
412
439
|
| A hand-mapped endpoint can call the generic `MapPost<TRequest,TDto>` helper directly for convenience | It works, but bypasses the DataAnnotations-enforcement your Option A route would otherwise get | Only a literal `group.MapPost("/", async (...) => ...)` — not a call to the generic wrapper — is visible to the validation source generator |
|
|
@@ -432,7 +459,7 @@ not apply to it. If `mode=` was not supplied, detect it: a `[CrudCreate]` on the
|
|
|
432
459
|
### Required reading
|
|
433
460
|
|
|
434
461
|
1. The reference sections above
|
|
435
|
-
2. `ApiEndpoints
|
|
462
|
+
2. `ApiEndpoints/<YourApp>.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs` (exemplar — every route is a literal `group.MapPost/MapGet/MapPut/MapDelete(...)` call against the raw minimal-API surface, base route `/v1/purchase-orders`)
|
|
436
463
|
3. The `dknet-feature-lifecycle` skill §1 — read the endpoint-registration and request-idempotency rows before choosing a mapping style
|
|
437
464
|
|
|
438
465
|
### Steps (`mode=manual`)
|
|
@@ -455,4 +482,4 @@ not apply to it. If `mode=` was not supplied, detect it: a `[CrudCreate]` on the
|
|
|
455
482
|
|
|
456
483
|
### Alternative: generated CRUD route
|
|
457
484
|
|
|
458
|
-
If the entity is plain CRUD with `[CrudCreate]`/`[CrudUpdate]`/`[GenerateDto]` already in place (see `Product`), skip hand-mapping entirely — the generator emits a `Map<Entity>Crud()` extension (namespace
|
|
485
|
+
If the entity is plain CRUD with `[CrudCreate]`/`[CrudUpdate]`/`[GenerateDto]` already in place (see `Product`), skip hand-mapping entirely — the generator emits a `Map<Entity>Crud()` extension (namespace `<YourApp>.AppServices.Crud`) that wires GetById/GetList/Create/Update/Delete in one call. `ProductV1Endpoint` is the exemplar: `group.MapProductCrud(o => …)` carrying the per-route scopes and one `Exclude("Discontinue")`, plus a `.WithDescription`, with the two routes the generator cannot express hand-mapped below it. This path does **not** get `.RequiredIdempotentKey()` and its DataAnnotations validation is not enforced (the .NET 10 validation source generator can't see through the generic `Map*<TRequest,TDto>` wrapper the generated route uses) — confirmed live: `POST /v1/products` with a negative price returns `201`. Only use this path when idempotency and enforced validation are not required.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dknet-entity
|
|
3
|
-
description: Create DDD domain entities following this project's AggregateRoot/DomainEntity inheritance pattern, either hand-written (mode=manual) or generator-declared (mode=auto). Use when adding a new domain entity or owned type to
|
|
3
|
+
description: Create DDD domain entities following this project's AggregateRoot/DomainEntity inheritance pattern, either hand-written (mode=manual) or generator-declared (mode=auto). Use when adding a new domain entity or owned type to <YourApp>.Domains. Invoke as `/dknet-entity <Feature> <Entity> [mode=manual|auto] [props…]` to scaffold it for a feature.
|
|
4
4
|
metadata:
|
|
5
5
|
kind: workflow
|
|
6
6
|
arguments: "<Feature> <Entity> [mode=manual|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal"
|
|
@@ -13,8 +13,14 @@ Usage: `/dknet-entity <Feature> <Entity> [mode=manual|auto] [props…] e.g. Orde
|
|
|
13
13
|
|
|
14
14
|
Create domain entities that integrate with this project's DDD infrastructure — `AggregateRoot`,
|
|
15
15
|
`DomainEntity`, and (rarely) owned value objects — in either of the two shapes this template ships:
|
|
16
|
-
|
|
17
|
-
(`mode=
|
|
16
|
+
generator-declared (`mode=auto`, mirrors `AutomatedSample/Product`) or hand-written
|
|
17
|
+
(`mode=manual`, mirrors `ManualSample/PurchaseOrder`).
|
|
18
|
+
|
|
19
|
+
**`mode=auto` is the default.** Declare the CRUD surface with `[CrudCreate]`/`[CrudUpdate]`/
|
|
20
|
+
`[CrudAction]` and the events with `[RaisesEvent]`; hand-write an entity's operations only for the
|
|
21
|
+
reasons `dknet-feature-lifecycle` §1 lists. Within a hand-written entity, raise events in the same
|
|
22
|
+
order of preference: `[RaisesEvent]`, then `AddEvent<TEvent>()` (payload projected from the entity
|
|
23
|
+
by `IMapper`), then `AddEvent(new …)` with a hand-built payload.
|
|
18
24
|
|
|
19
25
|
If the aggregate boundary, entity-vs-value-object choice, or invariant placement isn't obvious, read
|
|
20
26
|
`dknet-ddd-principles` first — this skill covers class mechanics, not those judgment calls. Event
|
|
@@ -25,11 +31,11 @@ here — this skill only covers how an entity raises an event.
|
|
|
25
31
|
|
|
26
32
|
```
|
|
27
33
|
AuditedEntity<Guid> (DKNet.EfCore.Abstractions.Entities — Id, CreatedBy/On, UpdatedBy/On)
|
|
28
|
-
└── DomainEntity (
|
|
29
|
-
└── AggregateRoot (
|
|
34
|
+
└── DomainEntity (<YourApp>.Domains/Share/DomainEntity.cs)
|
|
35
|
+
└── AggregateRoot (<YourApp>.Domains/Share/AggregateRoot.cs)
|
|
30
36
|
```
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
`<YourApp>.Domains/Share/DomainEntity.cs`:
|
|
33
39
|
|
|
34
40
|
```csharp
|
|
35
41
|
public abstract class DomainEntity : AuditedEntity<Guid>
|
|
@@ -45,7 +51,7 @@ public abstract class DomainEntity : AuditedEntity<Guid>
|
|
|
45
51
|
}
|
|
46
52
|
```
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
`<YourApp>.Domains/Share/AggregateRoot.cs`:
|
|
49
55
|
|
|
50
56
|
```csharp
|
|
51
57
|
public abstract class AggregateRoot : DomainEntity
|
|
@@ -89,7 +95,7 @@ and every property setter is `private` — don't redeclare any of them.
|
|
|
89
95
|
|
|
90
96
|
## mode=manual — hand-written (`ManualSample/PurchaseOrder`)
|
|
91
97
|
|
|
92
|
-
|
|
98
|
+
`<YourApp>.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`:
|
|
93
99
|
|
|
94
100
|
```csharp
|
|
95
101
|
public enum PurchaseOrderStatus { Draft, Placed, Cancelled }
|
|
@@ -137,7 +143,7 @@ public sealed class PurchaseOrder : AggregateRoot
|
|
|
137
143
|
}
|
|
138
144
|
```
|
|
139
145
|
|
|
140
|
-
|
|
146
|
+
`<YourApp>.Domains/Features/ManualSample/Entities/PurchaseOrderCreatedEvent.cs` — the whole file:
|
|
141
147
|
|
|
142
148
|
```csharp
|
|
143
149
|
public sealed record PurchaseOrderCreatedEvent(Guid Id, string CustomerName, decimal Amount);
|
|
@@ -155,11 +161,14 @@ Rules this shape follows:
|
|
|
155
161
|
- Every mutation method calls `SetUpdatedBy(userId)` at the end.
|
|
156
162
|
- The domain event is raised by hand: `AddEvent(new PurchaseOrderCreatedEvent(...))` inside the
|
|
157
163
|
constructor, right where the aggregate becomes valid. The event itself is a plain
|
|
158
|
-
`public sealed record` next to the entity, in the same file or the same folder.
|
|
164
|
+
`public sealed record` next to the entity, in the same file or the same folder. This is the
|
|
165
|
+
lowest rung — a hand-written entity can still carry `[RaisesEvent]`, or call
|
|
166
|
+
`AddEvent<TEvent>()` and let `IMapper` project the payload; reach for a hand-built payload only
|
|
167
|
+
when it is not a projection of the entity's own state.
|
|
159
168
|
|
|
160
169
|
## mode=auto — generator-declared (`AutomatedSample/Product`)
|
|
161
170
|
|
|
162
|
-
|
|
171
|
+
`<YourApp>.Domains/Features/AutomatedSample/Entities/Product.cs` (XML docs trimmed):
|
|
163
172
|
|
|
164
173
|
```csharp
|
|
165
174
|
[RaisesEvent(EventOperations.Created, Include = [nameof(Id), nameof(Name), nameof(Price)])]
|
|
@@ -207,7 +216,7 @@ public class Product : AggregateRoot, IOwnedBy
|
|
|
207
216
|
}
|
|
208
217
|
```
|
|
209
218
|
|
|
210
|
-
**`Product` is not `sealed`.** No architecture test enforces sealing on
|
|
219
|
+
**`Product` is not `sealed`.** No architecture test enforces sealing on `<YourApp>.Domains` entities
|
|
211
220
|
(only on mappers/handlers/validators), and nothing subclasses it — an inconsistency, not a rule to
|
|
212
221
|
copy. Default to `sealed` unless you have a concrete reason not to, as `mode=manual` does.
|
|
213
222
|
|
|
@@ -234,13 +243,14 @@ uses them.
|
|
|
234
243
|
- None of the three has a hand-written source file — they're compiled output. Verify a composed name
|
|
235
244
|
against the built assembly before wiring a consumer to it:
|
|
236
245
|
```bash
|
|
237
|
-
strings ApiEndpoints
|
|
246
|
+
strings ApiEndpoints/<YourApp>.Domains/bin/Release/net10.0/<YourApp>.Domains.dll | grep Event
|
|
238
247
|
```
|
|
239
248
|
- `[RaisesEvent]` alone raises nothing — the host must register `DKNet.EfCore.Events`' save hook
|
|
240
249
|
(`AddSlimBusEfCoreInterceptor<CoreDbContext>()`, already wired in this template) before declared
|
|
241
250
|
events publish.
|
|
242
|
-
- Never
|
|
243
|
-
(
|
|
251
|
+
- Never two raise styles for the same logical change: one property's event comes from
|
|
252
|
+
`[RaisesEvent]`, `AddEvent<TEvent>()`, or `AddEvent(new …)` — never a mix on one property. Prefer
|
|
253
|
+
them in that order; see `dknet-ddd-principles` for when each rung is the right one. Writing a consumer for a
|
|
244
254
|
raised event (either style) is covered by `dknet-messaging-events`, not this skill.
|
|
245
255
|
|
|
246
256
|
### Generator attributes: `[CrudCreate]`, `[CrudUpdate]`, `[CrudAction]`
|
|
@@ -305,11 +315,11 @@ the interface alone doesn't do it.
|
|
|
305
315
|
|
|
306
316
|
## Domain services, sequences, and `DomainSchemas`
|
|
307
317
|
|
|
308
|
-
|
|
318
|
+
`<YourApp>.Domains/Share/DomainSchemas.cs` holds named schema constants (`Migration = "migrate"`,
|
|
309
319
|
`Profile = "pro"`) for reuse across mappers — neither sample uses one; both pass a literal schema
|
|
310
320
|
string to `ToTable(...)` instead. Add a constant only when a schema name is reused by >1 entity.
|
|
311
321
|
|
|
312
|
-
|
|
322
|
+
`<YourApp>.Domains/Share/Sequences.cs` declares named PostgreSQL sequences:
|
|
313
323
|
|
|
314
324
|
```csharp
|
|
315
325
|
[SqlSequence]
|
|
@@ -322,7 +332,7 @@ public enum Sequences
|
|
|
322
332
|
}
|
|
323
333
|
```
|
|
324
334
|
|
|
325
|
-
The domain-service contract pattern that wraps a sequence (
|
|
335
|
+
The domain-service contract pattern that wraps a sequence (`<YourApp>.Domains/Services/`):
|
|
326
336
|
|
|
327
337
|
```csharp
|
|
328
338
|
public interface IDomainService; // marker, no members
|
|
@@ -333,7 +343,7 @@ public interface ISequenceServices : IDomainService
|
|
|
333
343
|
public interface IMembershipService : ISequenceServices; // one sequence, one interface
|
|
334
344
|
```
|
|
335
345
|
|
|
336
|
-
The
|
|
346
|
+
The `<YourApp>.Infra` implementation is a one-line primary-constructor subclass of an internal
|
|
337
347
|
`SequenceService` base (see `dknet-efcore-config`). Neither sample uses a sequence; add one the same
|
|
338
348
|
way `IMembershipService`/`MembershipService` do for `Sequences.Membership`, for entities needing a
|
|
339
349
|
human-readable sequential id instead of a `Guid`.
|
|
@@ -359,9 +369,9 @@ builder.OwnsOne(e => e.ShippingAddress, owned =>
|
|
|
359
369
|
});
|
|
360
370
|
```
|
|
361
371
|
|
|
362
|
-
## Architecture tests that constrain
|
|
372
|
+
## Architecture tests that constrain `<YourApp>.Domains`
|
|
363
373
|
|
|
364
|
-
`Architecture/RecordArchitectureTests.cs` scans
|
|
374
|
+
`Architecture/RecordArchitectureTests.cs` scans `<YourApp>.Domains`/`<YourApp>.AppServices` for any
|
|
365
375
|
record with a public property that has a **private** setter, and fails — Mapster/AutoMapper can't
|
|
366
376
|
map those. A hand-written event record (`public sealed record XCreatedEvent(...)`) is positional and
|
|
367
377
|
passes by construction. `Architecture/SampleInvariantTests.cs` enforces the two-sample split:
|
|
@@ -373,10 +383,10 @@ conventions above are followed by convention, not test (unlike mapper/handler/va
|
|
|
373
383
|
|
|
374
384
|
## Unit tests to mirror
|
|
375
385
|
|
|
376
|
-
-
|
|
386
|
+
- `<YourApp>.App.Tests/Unit/ManualSample/PurchaseOrderTests.cs` — constructor sets properties and
|
|
377
387
|
raises `PurchaseOrderCreatedEvent` (asserted via `order.GetEvents()`); the rehydration constructor
|
|
378
388
|
does not raise it; mutation methods stamp `UpdatedBy`.
|
|
379
|
-
-
|
|
389
|
+
- `<YourApp>.App.Tests/Unit/AutomatedSample/ProductTests.cs` — constructor sets properties (no event
|
|
380
390
|
assertion — declared events don't raise outside a real `SaveChanges`, see
|
|
381
391
|
`dknet-messaging-events`); reflection asserts `[CrudCreate]`/`[CrudUpdate]` `DataAnnotations` are
|
|
382
392
|
present, the only thing a unit test can prove about a forwarded-but-unenforced attribute;
|
|
@@ -387,7 +397,7 @@ conventions above are followed by convention, not test (unlike mapper/handler/va
|
|
|
387
397
|
|
|
388
398
|
**mode=manual**
|
|
389
399
|
|
|
390
|
-
1. Create `ApiEndpoints
|
|
400
|
+
1. Create `ApiEndpoints/<YourApp>.Domains/Features/<Feature>/Entities/<Entity>.cs` following the
|
|
391
401
|
three-constructor shape above, `AddEvent(new <Entity>CreatedEvent(...))` in the public one.
|
|
392
402
|
2. Add `public sealed record <Entity>CreatedEvent(...)` next to the entity.
|
|
393
403
|
3. Mark the class `sealed` unless you have a specific reason not to.
|
|
@@ -395,11 +405,11 @@ conventions above are followed by convention, not test (unlike mapper/handler/va
|
|
|
395
405
|
|
|
396
406
|
**mode=auto**
|
|
397
407
|
|
|
398
|
-
1. Create `ApiEndpoints
|
|
408
|
+
1. Create `ApiEndpoints/<YourApp>.Domains/Features/<Feature>/Entities/<Entity>.cs` following the
|
|
399
409
|
attribute shape above (`[RaisesEvent]`, `[CrudCreate]`, `[CrudUpdate]`, `[CrudAction]`, `IOwnedBy`
|
|
400
410
|
as needed).
|
|
401
411
|
2. Build once, inspect `obj/Generated/DKNet.SlimBus.Generators/…` for request/handler names and
|
|
402
|
-
`strings
|
|
412
|
+
`strings …/<YourApp>.Domains.dll | grep Event` for composed event names.
|
|
403
413
|
3. Continue to `dknet-efcore-config` for the mapper (unchanged by this mode).
|
|
404
414
|
|
|
405
415
|
## mode=manual vs mode=auto — when to pick which
|
|
@@ -435,9 +445,9 @@ more than one aggregate, or a domain method needs the acting user's identity as
|
|
|
435
445
|
| Adding a `createdBy`/`byUser` constructor parameter to a `[CrudCreate]` constructor | Never — it becomes a caller-settable field on the generated create request. Stamp the acting user from the authenticated caller at save time instead. |
|
|
436
446
|
| Naming a `[CrudAction]` parameter `byUser` and expecting it to carry the authenticated caller | **What you might expect:** it's populated like `[FromClaim]`. **What actually happens:** it's an ordinary caller-settable body field on the generated request. **Why:** the generator forwards only the parameter list and its `DataAnnotations`; it has no concept of `[FromClaim]`. |
|
|
437
447
|
| Assuming `ChangePrice`'s `[Range(0.01, double.MaxValue)]` is enforced because it's on the entity | It's forwarded onto the generated request but only *enforced* when the route is a literal `Map*(string, Delegate)` call — generated CRUD routes aren't. See `dknet-crud`. |
|
|
438
|
-
| Guessing a composed `[RaisesEvent]` name from the pattern alone | Always verify with `strings ApiEndpoints
|
|
448
|
+
| Guessing a composed `[RaisesEvent]` name from the pattern alone | Always verify with `strings ApiEndpoints/<YourApp>.Domains/bin/Release/net10.0/<YourApp>.Domains.dll \| grep Event` after building — there's no source file to read. |
|
|
439
449
|
| Expecting an `Updated` rule to fire because a setter ran | It only fires when the named property's value actually changed relative to the change tracker's original value on that save. |
|
|
440
|
-
| Sealing `Product`-style logic because "the manual sample is sealed" | `sealed` is a good default but is not enforced on
|
|
450
|
+
| Sealing `Product`-style logic because "the manual sample is sealed" | `sealed` is a good default but is not enforced on `<YourApp>.Domains` entities by any architecture test — don't cite one that doesn't exist. |
|
|
441
451
|
|
|
442
452
|
---
|
|
443
453
|
|
|
@@ -462,14 +472,14 @@ the `dknet-feature-lifecycle` skill §1 to choose one and say which you chose.
|
|
|
462
472
|
|
|
463
473
|
1. The reference sections above (mode=manual / mode=auto sections cover the exemplar shapes in full)
|
|
464
474
|
2. the `dknet-efcore-config` skill
|
|
465
|
-
3. `manual` exemplar — `ApiEndpoints
|
|
466
|
-
4. `ApiEndpoints
|
|
475
|
+
3. `manual` exemplar — `ApiEndpoints/<YourApp>.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`; `auto` exemplar — `ApiEndpoints/<YourApp>.Domains/Features/AutomatedSample/Entities/Product.cs`
|
|
476
|
+
4. `ApiEndpoints/<YourApp>.Infra/Features/ManualSample/Mappers/PurchaseOrderConfigs.cs` (exemplar mapper — hand-written in **both** modes; no generator produces this)
|
|
467
477
|
|
|
468
478
|
### Steps
|
|
469
479
|
|
|
470
|
-
1. Use the `dknet-implementer` subagent (via the Agent tool) to execute Steps 1–4 of the implementer protocol: domain entity, schema constant, owned types, EF Core mapper, optional sequence/seed data, then `dotnet ef migrations add <Name> -c CoreDbContext -p
|
|
480
|
+
1. Use the `dknet-implementer` subagent (via the Agent tool) to execute Steps 1–4 of the implementer protocol: domain entity, schema constant, owned types, EF Core mapper, optional sequence/seed data, then `dotnet ef migrations add <Name> -c CoreDbContext -p <YourApp>.Infra/<YourApp>.Infra.csproj` from `ApiEndpoints/`. Apply the mode=manual or mode=auto rules from the sections above verbatim — don't re-derive them.
|
|
471
481
|
2. Run `dotnet build -c Release` and stop on first error.
|
|
472
|
-
3. **`auto` only** — verify composed event-record names against the built DLL (see "Events: `[RaisesEvent]`" above) before wiring a consumer: `strings ApiEndpoints
|
|
482
|
+
3. **`auto` only** — verify composed event-record names against the built DLL (see "Events: `[RaisesEvent]`" above) before wiring a consumer: `strings ApiEndpoints/<YourApp>.Domains/bin/Release/net10.0/<YourApp>.Domains.dll | grep <Entity>`.
|
|
473
483
|
4. Report:
|
|
474
484
|
- mode used,
|
|
475
485
|
- files created (relative paths),
|
|
@@ -479,5 +489,5 @@ the `dknet-feature-lifecycle` skill §1 to choose one and say which you chose.
|
|
|
479
489
|
|
|
480
490
|
### Constraints
|
|
481
491
|
|
|
482
|
-
- Do NOT touch
|
|
492
|
+
- Do NOT touch `<YourApp>.AppServices` or `<YourApp>.Api` here — those belong to `/dknet-crud` and `/dknet-endpoint`.
|
|
483
493
|
- Entity/mapper/event rules: see Validation checklist above — verify against it, don't restate it.
|
|
@@ -39,12 +39,15 @@ of the same output.
|
|
|
39
39
|
| `auto` | `AutomatedSample` / `Product` | `[RaisesEvent]` / `[CrudCreate]` / `[CrudUpdate]` / `[CrudAction]` on the entity plus a one-line `[GenerateDto]`. Requests, handlers, and routes are generated. No idempotency, **forwarded DataAnnotations not enforced** (a FluentValidation validator on a generated request *is*), acting user via `DataOwnerHook`. |
|
|
40
40
|
|
|
41
41
|
If `mode=` was not supplied, apply §1 of the lifecycle skill, **recommend one with a reason**, and ask
|
|
42
|
-
the user to confirm. Default to `manual`
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
the user to confirm. **Default to `auto`.** Recommend `manual` only when the request actually needs
|
|
43
|
+
one of the five things the generator cannot express: an operation writing more than one aggregate in
|
|
44
|
+
one transaction, idempotent writes, a query beyond the generic list route's
|
|
45
|
+
`filter`/`search`/`orderBy` contract, `[FromClaim]` acting-user attribution, or attribute-declared
|
|
46
|
+
validation that must return `400` and cannot be restated as a FluentValidation rule. A business
|
|
47
|
+
rule, state transition, duplicate check or a DTO that hides fields does **not** force `manual`:
|
|
48
|
+
write the rule as a FluentValidation validator on the generated request (as the product sample
|
|
49
|
+
refuses a duplicate name and a delete of a product still for sale) and narrow the DTO with
|
|
50
|
+
`[GenerateDto(..., Exclude = [...])]`.
|
|
48
51
|
|
|
49
52
|
When `auto` is selected, state the validation gap in your confirmation message: a `[Range]` on a
|
|
50
53
|
generated request property is forwarded but never enforced, so a `POST` with an invalid value returns
|
|
@@ -74,7 +77,9 @@ Run `/dknet-entity <Feature> <Entity> mode=<mode> <props…>`. Verify in both mo
|
|
|
74
77
|
- `dotnet build` is green.
|
|
75
78
|
|
|
76
79
|
Additionally verify, by mode:
|
|
77
|
-
- `manual` —
|
|
80
|
+
- `manual` — every event is raised at the highest rung that fits: `[RaisesEvent]` where the event is
|
|
81
|
+
"created" or "these properties changed", else `AddEvent<TEvent>()` with the payload projected from
|
|
82
|
+
the entity, and a hand-built `AddEvent(new …)` only where the payload is not a projection.
|
|
78
83
|
- `auto` — class-level `[RaisesEvent(...)]`, a `[CrudCreate]` constructor, and at least one
|
|
79
84
|
`[CrudUpdate]` method are present. No `AddEvent` call anywhere in the slice.
|
|
80
85
|
|
|
@@ -89,24 +94,40 @@ Build green either way.
|
|
|
89
94
|
### Phase 4 — Endpoint
|
|
90
95
|
|
|
91
96
|
Run `/dknet-endpoint <Feature> <Entity> mode=<mode>`. Verify the new `*V1Endpoint : IEndpointConfig` exists and:
|
|
92
|
-
- `manual` — every route is a literal `group.MapPost/MapGet/MapPut/MapDelete(...)` call, and the create route chains `.RequiredIdempotentKey()`.
|
|
97
|
+
- `manual` — every route is a literal `group.MapPost/MapGet/MapPut/MapDelete(...)` call, and the create route chains `.RequiredIdempotentKey()`. Confirm each hand-mapped route is one no CRUD attribute and no generic `Map*` helper could have covered; report any that could have been generated.
|
|
93
98
|
- `auto` — the body is a single `group.Map<Entity>Crud()` call. There is no `.RequiredIdempotentKey()` on this path; do not add one, it will not compile onto the generated route.
|
|
94
99
|
|
|
100
|
+
Authorization scopes, either mode: declared with `[EndpointGroupScope]` on the endpoint class where
|
|
101
|
+
the package version supports it, dropping to `o.Configure(...)`/`.RequireAuthorization(...)` — each
|
|
102
|
+
behind the `FeatureOptions.RequireAuthorization` guard — only for a route whose HTTP method cannot
|
|
103
|
+
decide its scope.
|
|
104
|
+
|
|
95
105
|
Build green.
|
|
96
106
|
|
|
97
107
|
### Phase 5 — Unit/integration tests
|
|
98
108
|
|
|
99
|
-
Run `/dknet-unit-tests <Feature> <Entity> mode=<mode>`.
|
|
100
|
-
|
|
109
|
+
Run `/dknet-unit-tests <Feature> <Entity> mode=<mode>`. xUnit here covers only what a BDD scenario
|
|
110
|
+
cannot: architecture/convention rules, pure functional tests (entity methods, validators, specs),
|
|
111
|
+
EF model/schema shape, and a `Result`-level assertion where the HTTP response cannot tell two
|
|
112
|
+
failures apart. Every business rule reachable over HTTP is Phase 6's job — do not write it twice.
|
|
113
|
+
- `manual` — validator rules and rejected state transitions asserted at the unit level.
|
|
101
114
|
- `auto` — entity-method behavior directly. Do **not** write a test asserting a `400` from a forwarded DataAnnotations attribute; it will return `201` and the test would encode the gap as expected behavior.
|
|
102
115
|
|
|
103
116
|
### Phase 6 — BDD acceptance tests
|
|
104
117
|
|
|
105
|
-
Dispatch the `dknet-bdd-engineer` subagent with the feature scope.
|
|
118
|
+
Dispatch the `dknet-bdd-engineer` subagent with the feature scope. **This is where the feature's
|
|
119
|
+
business rules are specified** — happy path, every refusal, every state transition, and the
|
|
120
|
+
domain-event side effects visible in captured logs. Verify `.feature` + step files exist with
|
|
121
|
+
status + shape + key-field assertions, that every route from Phase 4 appears in at least one
|
|
122
|
+
scenario, and that the BDD project passes.
|
|
106
123
|
|
|
107
124
|
### Phase 7 — Feature documentation
|
|
108
125
|
|
|
109
|
-
Run `/dknet-docs <Feature>`. Verify README, architecture diagrams, data-model, and api-reference
|
|
126
|
+
Run `/dknet-docs <Feature>`. Verify README, architecture diagrams, data-model, and api-reference
|
|
127
|
+
exist under `docs/features/<feature-kebab>/` (or `docs/<feature>/` if internal), that
|
|
128
|
+
`api-reference.md` has a section for **every** route Phase 4 published, and that the architecture,
|
|
129
|
+
sequence and event diagrams are archify renders committed with their JSON sources under
|
|
130
|
+
`diagrams/` (Mermaid only if archify could not be installed — the report must say so).
|
|
110
131
|
|
|
111
132
|
### Phase 8 — Final gates
|
|
112
133
|
|
|
@@ -118,7 +139,7 @@ Run `/dknet-docs <Feature>`. Verify README, architecture diagrams, data-model, a
|
|
|
118
139
|
- Migration name + tables.
|
|
119
140
|
- Endpoints (route + verbs), flagging whether the create route is idempotent.
|
|
120
141
|
- Test counts (unit + BDD).
|
|
121
|
-
- Docs paths.
|
|
142
|
+
- Docs paths, and whether the diagrams are archify renders or Mermaid fallbacks.
|
|
122
143
|
- For `auto`: the exact generated type names produced, and a plain statement that the forwarded
|
|
123
144
|
DataAnnotations validation on those routes is not enforced.
|
|
124
145
|
- `/dknet-feature-remove <Feature>` as the way to retire the slice.
|