@drunkcoding/dknet-implementation-skills 0.1.0

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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +38 -0
  3. package/LICENSE +21 -0
  4. package/README.md +261 -0
  5. package/agents/dknet-architect.md +49 -0
  6. package/agents/dknet-bdd-engineer.md +62 -0
  7. package/agents/dknet-implementer.md +75 -0
  8. package/package.json +52 -0
  9. package/plugin.json +26 -0
  10. package/skills/README.md +47 -0
  11. package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
  12. package/skills/dknet-bdd-tests/SKILL.md +355 -0
  13. package/skills/dknet-bdd-tests/checklist.md +39 -0
  14. package/skills/dknet-crud/SKILL.md +483 -0
  15. package/skills/dknet-ddd-principles/SKILL.md +87 -0
  16. package/skills/dknet-docs/SKILL.md +296 -0
  17. package/skills/dknet-docs/checklist.md +58 -0
  18. package/skills/dknet-docs/templates/README-template.md +68 -0
  19. package/skills/dknet-docs/templates/api-reference-template.md +275 -0
  20. package/skills/dknet-docs/templates/architecture-template.md +166 -0
  21. package/skills/dknet-docs/templates/data-model-template.md +99 -0
  22. package/skills/dknet-docs/templates/events-template.md +155 -0
  23. package/skills/dknet-dto-mapping/SKILL.md +278 -0
  24. package/skills/dknet-efcore-config/SKILL.md +379 -0
  25. package/skills/dknet-endpoint/SKILL.md +458 -0
  26. package/skills/dknet-entity/SKILL.md +483 -0
  27. package/skills/dknet-feature/SKILL.md +139 -0
  28. package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
  29. package/skills/dknet-feature-remove/SKILL.md +131 -0
  30. package/skills/dknet-messaging-events/SKILL.md +395 -0
  31. package/skills/dknet-package-adoption/SKILL.md +252 -0
  32. package/skills/dknet-platform-config/SKILL.md +342 -0
  33. package/skills/dknet-project-structure/SKILL.md +148 -0
  34. package/skills/dknet-queries-specs/SKILL.md +330 -0
  35. package/skills/dknet-scaffold/SKILL.md +209 -0
  36. package/skills/dknet-unit-tests/SKILL.md +382 -0
@@ -0,0 +1,483 @@
1
+ ---
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 Minimal.Domains. Invoke as `/dknet-entity <Feature> <Entity> [mode=manual|auto] [props…]` to scaffold it for a feature.
4
+ metadata:
5
+ kind: workflow
6
+ arguments: "<Feature> <Entity> [mode=manual|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal"
7
+ allowed-tools: Read, Grep, Glob, Edit, Write, Bash, Agent
8
+ ---
9
+
10
+ Usage: `/dknet-entity <Feature> <Entity> [mode=manual|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal`
11
+
12
+ # Skill: Domain Entity Definition
13
+
14
+ Create domain entities that integrate with this project's DDD infrastructure — `AggregateRoot`,
15
+ `DomainEntity`, and (rarely) owned value objects — in either of the two shapes this template ships:
16
+ hand-written (`mode=manual`, mirrors `ManualSample/PurchaseOrder`) or generator-declared
17
+ (`mode=auto`, mirrors `AutomatedSample/Product`).
18
+
19
+ If the aggregate boundary, entity-vs-value-object choice, or invariant placement isn't obvious, read
20
+ `dknet-ddd-principles` first — this skill covers class mechanics, not those judgment calls. Event
21
+ **consumers** (handlers that react to a raised event) are covered by `dknet-messaging-events`, not
22
+ here — this skill only covers how an entity raises an event.
23
+
24
+ ## Hierarchy
25
+
26
+ ```
27
+ AuditedEntity<Guid> (DKNet.EfCore.Abstractions.Entities — Id, CreatedBy/On, UpdatedBy/On)
28
+ └── DomainEntity (Minimal.Domains/Share/DomainEntity.cs)
29
+ └── AggregateRoot (Minimal.Domains/Share/AggregateRoot.cs)
30
+ ```
31
+
32
+ `Minimal.Domains/Share/DomainEntity.cs`:
33
+
34
+ ```csharp
35
+ public abstract class DomainEntity : AuditedEntity<Guid>
36
+ {
37
+ protected DomainEntity(Guid id, string createdBy, DateTimeOffset? createdOn = null) : base(id)
38
+ {
39
+ SetCreatedBy(createdBy, createdOn);
40
+ }
41
+
42
+ protected DomainEntity()
43
+ {
44
+ }
45
+ }
46
+ ```
47
+
48
+ `Minimal.Domains/Share/AggregateRoot.cs`:
49
+
50
+ ```csharp
51
+ public abstract class AggregateRoot : DomainEntity
52
+ {
53
+ protected AggregateRoot(string createdBy, DateTimeOffset? createdOn = null)
54
+ : this(Guid.NewGuid(), createdBy, createdOn)
55
+ {
56
+ }
57
+
58
+ protected AggregateRoot(Guid id, string createdBy, DateTimeOffset? createdOn = null)
59
+ : base(id, createdBy, createdOn)
60
+ {
61
+ SetCreatedBy(createdBy, createdOn);
62
+ }
63
+
64
+ protected AggregateRoot()
65
+ {
66
+ }
67
+ }
68
+ ```
69
+
70
+ `AuditedEntity<Guid>` (`DKNet.EfCore.Abstractions`) supplies `Id`, `CreatedBy`, `CreatedOn`,
71
+ `UpdatedBy`, `UpdatedOn`, `LastModifiedBy`/`LastModifiedOn` (computed: `UpdatedBy ?? CreatedBy`, not
72
+ mapped columns), plus `protected SetCreatedBy(userName, createdOn?)` (no-op once `CreatedBy` is
73
+ already set) and `protected SetUpdatedBy(userName, updatedOn?)`. All four base classes are abstract
74
+ and every property setter is `private` — don't redeclare any of them.
75
+
76
+ **Two ways an aggregate ends up with an `Id`:**
77
+
78
+ - Call the `AggregateRoot(string createdBy)` overload (or `(Guid id, string createdBy)`) — this
79
+ eagerly assigns `Id = Guid.NewGuid()` (or the given `id`) and stamps `CreatedBy` in memory, before
80
+ `SaveChanges`. `PurchaseOrder`'s public constructor does this via `: base(byUser)`.
81
+ - Call neither — the parameterless `protected AggregateRoot()` runs, `Id` stays `Guid.Empty` and
82
+ `CreatedBy` stays unset until `SaveChanges`. EF Core's mapper base class
83
+ (`DefaultEntityTypeConfiguration<T>`) configures `Id` with `ValueGeneratedOnAdd().HasValueGenerator<GuidV7ValueGenerator>()`,
84
+ so a fresh time-ordered GUID is assigned there instead, and the audit/data-ownership hooks stamp
85
+ `CreatedBy`/`OwnedBy` from the authenticated caller in the same save (see `dknet-efcore-config` and
86
+ the auditing rules below). `Product`'s `[CrudCreate]` constructor does this — it deliberately never
87
+ calls `base(byUser)`, because that overload requires an acting-user string the generated request
88
+ must not carry (see Acting user, below).
89
+
90
+ ## mode=manual — hand-written (`ManualSample/PurchaseOrder`)
91
+
92
+ `Minimal.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`:
93
+
94
+ ```csharp
95
+ public enum PurchaseOrderStatus { Draft, Placed, Cancelled }
96
+
97
+ public sealed class PurchaseOrder : AggregateRoot
98
+ {
99
+ public PurchaseOrder(string customerName, decimal amount, string byUser)
100
+ : base(byUser)
101
+ {
102
+ CustomerName = customerName;
103
+ Amount = amount;
104
+ Status = PurchaseOrderStatus.Placed;
105
+
106
+ AddEvent(new PurchaseOrderCreatedEvent(Id, CustomerName, Amount));
107
+ }
108
+
109
+ // Rehydrates with a known identity (static seeding only); does not re-raise PurchaseOrderCreatedEvent.
110
+ internal PurchaseOrder(Guid id, string customerName, decimal amount, string byUser)
111
+ : base(id, byUser)
112
+ {
113
+ CustomerName = customerName;
114
+ Amount = amount;
115
+ Status = PurchaseOrderStatus.Placed;
116
+ }
117
+
118
+ private PurchaseOrder()
119
+ {
120
+ }
121
+
122
+ public string CustomerName { get; private set; } = null!;
123
+ public decimal Amount { get; private set; }
124
+ public PurchaseOrderStatus Status { get; private set; }
125
+
126
+ public void ChangeAmount(decimal amount, string userId)
127
+ {
128
+ Amount = amount;
129
+ SetUpdatedBy(userId);
130
+ }
131
+
132
+ public void Cancel(string userId)
133
+ {
134
+ Status = PurchaseOrderStatus.Cancelled;
135
+ SetUpdatedBy(userId);
136
+ }
137
+ }
138
+ ```
139
+
140
+ `Minimal.Domains/Features/ManualSample/Entities/PurchaseOrderCreatedEvent.cs` — the whole file:
141
+
142
+ ```csharp
143
+ public sealed record PurchaseOrderCreatedEvent(Guid Id, string CustomerName, decimal Amount);
144
+ ```
145
+
146
+ Rules this shape follows:
147
+
148
+ - **Three constructors.** A public one for a new entity (forwards to `base(byUser)`), an `internal`
149
+ rehydration one taking `Guid id` first (forwards to `base(id, byUser)`, does **not** re-raise the
150
+ created event), and a `private` parameterless one for EF Core materialization.
151
+ - `sealed` — `PurchaseOrder` is `sealed`, and sealed is the shape to default to; nothing about a
152
+ hand-written entity needs it to be open for inheritance.
153
+ - Every property is `{ get; private set; }`. Mutation happens only through named methods
154
+ (`ChangeAmount`, `Cancel`) — never a single generic `Update(...)`, and never a public setter.
155
+ - Every mutation method calls `SetUpdatedBy(userId)` at the end.
156
+ - The domain event is raised by hand: `AddEvent(new PurchaseOrderCreatedEvent(...))` inside the
157
+ 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.
159
+
160
+ ## mode=auto — generator-declared (`AutomatedSample/Product`)
161
+
162
+ `Minimal.Domains/Features/AutomatedSample/Entities/Product.cs` (XML docs trimmed):
163
+
164
+ ```csharp
165
+ [RaisesEvent(EventOperations.Created, Include = [nameof(Id), nameof(Name), nameof(Price)])]
166
+ [RaisesEvent(EventOperations.Updated, nameof(Price))]
167
+ [RaisesEvent(EventOperations.Updated, nameof(IsDiscontinued))]
168
+ public class Product : AggregateRoot, IOwnedBy
169
+ {
170
+ [CrudCreate]
171
+ public Product(
172
+ [Required, StringLength(150)] string name,
173
+ [Range(0.01, double.MaxValue)] decimal price,
174
+ decimal? supplierCostPrice = null)
175
+ {
176
+ Name = name;
177
+ Price = price;
178
+ SupplierCostPrice = supplierCostPrice;
179
+ }
180
+
181
+ protected Product()
182
+ {
183
+ }
184
+
185
+ public string Name { get; private set; } = null!;
186
+ public decimal Price { get; private set; }
187
+ public bool IsDiscontinued { get; private set; }
188
+
189
+ // Stamped by DataOwnerHook; makes the row subject to the global read filter.
190
+ public string OwnedBy { get; private set; } = string.Empty;
191
+
192
+ [SensitiveData("pricing")] public decimal? SupplierCostPrice { get; private set; }
193
+ [SensitiveData] public string? SupplierReferenceCode { get; private set; }
194
+
195
+ [CrudUpdate]
196
+ public void ChangePrice([Range(0.01, double.MaxValue)] decimal price) => Price = price;
197
+
198
+ [CrudAction("approval")]
199
+ public void Approve(string byUser) => SetUpdatedBy(byUser);
200
+
201
+ [CrudAction(Verb = CrudActionVerb.Put)]
202
+ public void Discontinue() => IsDiscontinued = true;
203
+
204
+ [CrudAction("supplier-reference", Verb = CrudActionVerb.Put)]
205
+ public void AssignSupplierReference([Required, StringLength(50)] string supplierReferenceCode) =>
206
+ SupplierReferenceCode = supplierReferenceCode;
207
+ }
208
+ ```
209
+
210
+ **`Product` is not `sealed`.** No architecture test enforces sealing on `Minimal.Domains` entities
211
+ (only on mappers/handlers/validators), and nothing subclasses it — an inconsistency, not a rule to
212
+ copy. Default to `sealed` unless you have a concrete reason not to, as `mode=manual` does.
213
+
214
+ ### Events: `[RaisesEvent]`
215
+
216
+ Class-level, repeatable. This template uses only the label-less convention form:
217
+ `[RaisesEvent(operations, params string[] properties)]`, with `Include`/`Exclude` narrowing the
218
+ auto-composed payload. Two other forms exist in the package (type-naming, label) but neither sample
219
+ uses them.
220
+
221
+ - `EventOperations`: `Created`, `Updated`, `Deleted`.
222
+ - `Include`/`Exclude` (mutually exclusive) shape the auto-composed payload record —
223
+ `Include = [nameof(Id), nameof(Name), nameof(Price)]` on the `Created` rule means
224
+ `ProductCreatedEvent` carries exactly those three properties.
225
+ - The `string[] properties` argument narrows an `Updated` rule: non-empty raises only when a listed
226
+ property's value actually changed (change-tracker original value, not whether the setter ran) —
227
+ calling `ChangePrice` with its current price does not raise `ProductPriceUpdatedEvent`. Empty means
228
+ any change qualifies. A property list on a `Created`-only rule is ignored with a build warning.
229
+ - **Composed name**: entity name + narrowing properties + operation + `Event`. On `Product`:
230
+ `[RaisesEvent(EventOperations.Created, Include = [...])]` → `ProductCreatedEvent`;
231
+ `[RaisesEvent(EventOperations.Updated, nameof(Price))]` → `ProductPriceUpdatedEvent`, **not**
232
+ `ProductUpdatedEvent`; `[RaisesEvent(EventOperations.Updated, nameof(IsDiscontinued))]` →
233
+ `ProductIsDiscontinuedUpdatedEvent`. This is what lets two `Updated` rules on one entity coexist.
234
+ - None of the three has a hand-written source file — they're compiled output. Verify a composed name
235
+ against the built assembly before wiring a consumer to it:
236
+ ```bash
237
+ strings ApiEndpoints/Minimal.Domains/bin/Release/net10.0/Minimal.Domains.dll | grep Event
238
+ ```
239
+ - `[RaisesEvent]` alone raises nothing — the host must register `DKNet.EfCore.Events`' save hook
240
+ (`AddSlimBusEfCoreInterceptor<CoreDbContext>()`, already wired in this template) before declared
241
+ events publish.
242
+ - Never both styles for the same logical change: pick `AddEvent` (mode=manual) or `[RaisesEvent]`
243
+ (mode=auto) for a given entity's events, not a mix on one property. Writing a consumer for a
244
+ raised event (either style) is covered by `dknet-messaging-events`, not this skill.
245
+
246
+ ### Generator attributes: `[CrudCreate]`, `[CrudUpdate]`, `[CrudAction]`
247
+
248
+ All three live in `DKNet.EfCore.Abstractions.Attributes`. Mechanically identical: the annotated
249
+ member's parameter list *is* the generated request's payload, and its `DataAnnotations`
250
+ (`[Required]`, `[StringLength]`, `[Range]`) forward 1:1 onto the generated request property — but see
251
+ `dknet-crud`/`dknet-endpoint` for whether that attribute is actually **enforced** on the mapped route.
252
+
253
+ | | `[CrudCreate]` | `[CrudUpdate]` | `[CrudAction]` |
254
+ |---|---|---|---|
255
+ | Placement | one constructor | a method | a method |
256
+ | Generates | `Create<Entity>Request` + handler | `Change<Member><Entity>Request` + handler, `PUT {id}` | `<Method><Entity>Request` + handler, `{Verb} {id}/{segment}` |
257
+ | Verb | `POST` (create route) | `PUT` | `POST` default, override to `Put`/`Patch` (`CrudActionVerb`) |
258
+ | Route segment | — | — | method name kebab-cased, or `[CrudAction("segment")]` |
259
+ | Response | `201` + DTO | `200` + DTO | `200` + DTO — never `204` |
260
+ | Acting-user param | never — see below | never | fine (see `Approve(string byUser)`), but see the pitfall below |
261
+
262
+ One `[CrudUpdate]` method changes exactly the fields in its own parameter list — a method needing to
263
+ change two independent fields together needs two `[CrudUpdate]` methods, not one bigger request.
264
+
265
+ **Action vs. update** — decide by whether repeating the call is safe. `[CrudUpdate]`: caller supplies
266
+ a value and the row ends up holding it, calling twice is the same as once (`ChangePrice(decimal)`).
267
+ `[CrudAction]`: repetition isn't automatically safe (approve, discontinue, re-issue) — `POST` is the
268
+ default because it promises nothing about retries. `Product.Approve` was originally `[CrudUpdate]`,
269
+ over-promising retry-safety; moving it to `[CrudAction("approval")]` fixed the contract without
270
+ changing the method body. Drop a generated action for a hand-written route only when the operation
271
+ writes more than one aggregate in one transaction (`Product.Discontinue`, which also creates a
272
+ replacement product — see `dknet-crud`/`dknet-endpoint`).
273
+
274
+ **`[CrudCreate]` never gets an acting-user parameter.** The generated create request's shape is the
275
+ constructor's parameter list, verbatim — a `createdBy`/`byUser` constructor parameter would become a
276
+ caller-settable body field. `Product`'s `[CrudCreate]` constructor takes only `name`, `price`,
277
+ `supplierCostPrice`; `CreatedBy`/`OwnedBy` are stamped at save time from the authenticated caller
278
+ instead (`DataOwnerHook`, `DKNet.EfCore.AuditLogs`' audit hook — see `dknet-efcore-config`).
279
+
280
+ **Pitfall — a `[CrudAction]` method's `byUser` parameter is caller-settable.** `Product.Approve(string
281
+ byUser)` becomes a generated `ApproveProductRequest` with a `required string ByUser` **body**
282
+ property — any caller can set it to any value; nothing populates it from the authenticated principal
283
+ the way `[FromClaim]` does for a hand-written request. Only use an action parameter named like an
284
+ acting-user field when that is genuinely intended as caller-supplied data, never to attribute the
285
+ call.
286
+
287
+ ### `[SensitiveData]`
288
+
289
+ `DKNet.EfCore.Abstractions.Attributes.SensitiveDataAttribute`, on a property. Two effects: unconditional
290
+ redaction in `DKNet.EfCore.AuditLogs` audit-log capture (not wired here), and role-gated JSON
291
+ serialization once the host opts in (`UseRoleAwareSensitiveData` — see `dknet-endpoint`).
292
+ `[SensitiveData("pricing")]` — only a caller in that role (any one of several named) gets the value
293
+ serialized; `[SensitiveData]` with no role — any *authenticated* caller gets it, unauthenticated is
294
+ refused. The attribute alone changes nothing until the serializer opts in. `DKNet.EfCore.DtoGenerator`
295
+ copies it onto the matching generated DTO property automatically — never re-declare it by hand.
296
+
297
+ ### `IOwnedBy` — row-level isolation
298
+
299
+ Implement `IOwnedBy { string OwnedBy { get; } }` (`DKNet.EfCore.DataAuthorization`) to subject an
300
+ entity to the global read filter — a caller sees only rows whose `OwnedBy` matches their own
301
+ ownership key. `DataOwnerHook` stamps it on insert from `IDataOwnerProvider.GetOwnershipKey()` and
302
+ refuses reassignment to a key the caller doesn't hold. The mapper must still size the column by hand
303
+ — `builder.Property(p => p.OwnedBy).HasMaxLength(500).IsRequired();` (see `dknet-efcore-config`) —
304
+ the interface alone doesn't do it.
305
+
306
+ ## Domain services, sequences, and `DomainSchemas`
307
+
308
+ `Minimal.Domains/Share/DomainSchemas.cs` holds named schema constants (`Migration = "migrate"`,
309
+ `Profile = "pro"`) for reuse across mappers — neither sample uses one; both pass a literal schema
310
+ string to `ToTable(...)` instead. Add a constant only when a schema name is reused by >1 entity.
311
+
312
+ `Minimal.Domains/Share/Sequences.cs` declares named PostgreSQL sequences:
313
+
314
+ ```csharp
315
+ [SqlSequence]
316
+ public enum Sequences
317
+ {
318
+ None = 0,
319
+
320
+ [Sequence(typeof(int), FormatString = "T{DateTime:yyMMdd}{1:00000}", Max = 99999)]
321
+ Membership = 1
322
+ }
323
+ ```
324
+
325
+ The domain-service contract pattern that wraps a sequence (`Minimal.Domains/Services/`):
326
+
327
+ ```csharp
328
+ public interface IDomainService; // marker, no members
329
+ public interface ISequenceServices : IDomainService
330
+ {
331
+ ValueTask<string> NextValueAsync();
332
+ }
333
+ public interface IMembershipService : ISequenceServices; // one sequence, one interface
334
+ ```
335
+
336
+ The `Minimal.Infra` implementation is a one-line primary-constructor subclass of an internal
337
+ `SequenceService` base (see `dknet-efcore-config`). Neither sample uses a sequence; add one the same
338
+ way `IMembershipService`/`MembershipService` do for `Sequences.Membership`, for entities needing a
339
+ human-readable sequential id instead of a `Guid`.
340
+
341
+ ## Owned value objects
342
+
343
+ Not exercised by either sample — no `OwnsOne`/`OwnsMany` call exists anywhere in this solution. If a
344
+ feature genuinely needs one, the minimal pattern is a plain class with no independent identity,
345
+ configured in the entity's own mapper (`dknet-efcore-config`):
346
+
347
+ ```csharp
348
+ public sealed class Address
349
+ {
350
+ public string Street { get; private set; } = null!;
351
+ public string City { get; private set; } = null!;
352
+ }
353
+
354
+ // in the entity's IEntityTypeConfiguration<T>.Configure, after base.Configure(builder):
355
+ builder.OwnsOne(e => e.ShippingAddress, owned =>
356
+ {
357
+ owned.Property(p => p.Street).HasMaxLength(200).IsRequired();
358
+ owned.Property(p => p.City).HasMaxLength(100).IsRequired();
359
+ });
360
+ ```
361
+
362
+ ## Architecture tests that constrain `Minimal.Domains`
363
+
364
+ `Architecture/RecordArchitectureTests.cs` scans `Minimal.Domains`/`Minimal.AppServices` for any
365
+ record with a public property that has a **private** setter, and fails — Mapster/AutoMapper can't
366
+ map those. A hand-written event record (`public sealed record XCreatedEvent(...)`) is positional and
367
+ passes by construction. `Architecture/SampleInvariantTests.cs` enforces the two-sample split:
368
+ `ManualSample_ShouldNotUseAnyDeclarativeGenerationAttribute` fails on any `ManualSample` file
369
+ containing `[RaisesEvent`, `[CrudCreate]`, `[CrudUpdate]`, `[CrudAction`, or `[GenerateDto`;
370
+ `AutomatedSample_ShouldNotRaiseEventsByHand` fails on any `AutomatedSample` file calling `AddEvent(`.
371
+ No architecture test enforces `sealed`, visibility, or constructor shape on an entity directly — the
372
+ conventions above are followed by convention, not test (unlike mapper/handler/validator sealing).
373
+
374
+ ## Unit tests to mirror
375
+
376
+ - `Minimal.App.Tests/Unit/ManualSample/PurchaseOrderTests.cs` — constructor sets properties and
377
+ raises `PurchaseOrderCreatedEvent` (asserted via `order.GetEvents()`); the rehydration constructor
378
+ does not raise it; mutation methods stamp `UpdatedBy`.
379
+ - `Minimal.App.Tests/Unit/AutomatedSample/ProductTests.cs` — constructor sets properties (no event
380
+ assertion — declared events don't raise outside a real `SaveChanges`, see
381
+ `dknet-messaging-events`); reflection asserts `[CrudCreate]`/`[CrudUpdate]` `DataAnnotations` are
382
+ present, the only thing a unit test can prove about a forwarded-but-unenforced attribute;
383
+ `Discontinue` called twice stays discontinued — the repeat-call refusal lives in the handler, not
384
+ the entity method.
385
+
386
+ ## Step-by-step
387
+
388
+ **mode=manual**
389
+
390
+ 1. Create `ApiEndpoints/Minimal.Domains/Features/<Feature>/Entities/<Entity>.cs` following the
391
+ three-constructor shape above, `AddEvent(new <Entity>CreatedEvent(...))` in the public one.
392
+ 2. Add `public sealed record <Entity>CreatedEvent(...)` next to the entity.
393
+ 3. Mark the class `sealed` unless you have a specific reason not to.
394
+ 4. Continue to `dknet-efcore-config` for the mapper.
395
+
396
+ **mode=auto**
397
+
398
+ 1. Create `ApiEndpoints/Minimal.Domains/Features/<Feature>/Entities/<Entity>.cs` following the
399
+ attribute shape above (`[RaisesEvent]`, `[CrudCreate]`, `[CrudUpdate]`, `[CrudAction]`, `IOwnedBy`
400
+ as needed).
401
+ 2. Build once, inspect `obj/Generated/DKNet.SlimBus.Generators/…` for request/handler names and
402
+ `strings …/Minimal.Domains.dll | grep Event` for composed event names.
403
+ 3. Continue to `dknet-efcore-config` for the mapper (unchanged by this mode).
404
+
405
+ ## mode=manual vs mode=auto — when to pick which
406
+
407
+ Full trade-off table lives in `dknet-feature-lifecycle`; short version: pick `mode=auto` for a plain
408
+ CRUD-shaped entity where built-in `Created`/`Updated`-on-change semantics and generated routes are
409
+ enough (a `FluentValidation` validator against a generated request still runs — see `dknet-crud`).
410
+ Pick `mode=manual` when construction/mutation needs logic beyond setting fields, an operation spans
411
+ more than one aggregate, or a domain method needs the acting user's identity as data.
412
+
413
+ ## Validation checklist
414
+
415
+ - [ ] Entity ultimately derives from `AggregateRoot` (or `DomainEntity` for a non-root entity)
416
+ - [ ] Every property is `{ get; private set; }` — no public setters
417
+ - [ ] mode=manual: three constructors (public, `internal` rehydration, private parameterless);
418
+ mode=auto: `[CrudCreate]` constructor with no acting-user parameter
419
+ - [ ] mode=manual: `AddEvent(...)` called in the constructor, event is a `public sealed record` next
420
+ to the entity; mode=auto: `[RaisesEvent]` declared at class level, composed name verified via
421
+ `strings` against the built DLL
422
+ - [ ] Every mutation method (mode=manual) or `[CrudUpdate]`/`[CrudAction]` method (mode=auto) that
423
+ changes state ends by stamping the acting user (`SetUpdatedBy` for manual; auto's hooks do this
424
+ at save time — don't call `SetUpdatedBy` yourself unless the method needs to record it as
425
+ domain data, as `Approve` does)
426
+ - [ ] `IOwnedBy` implemented only when the feature needs row-level isolation, and the mapper sizes
427
+ `OwnedBy` explicitly
428
+ - [ ] No mixing of `AddEvent` and `[RaisesEvent]` for the same entity
429
+ - [ ] `dotnet build -c Release` passes
430
+
431
+ ## Common mistakes
432
+
433
+ | Mistake | Fix |
434
+ |---|---|
435
+ | 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
+ | 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
+ | 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/Minimal.Domains/bin/Release/net10.0/Minimal.Domains.dll \| grep Event` after building — there's no source file to read. |
439
+ | 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 `Minimal.Domains` entities by any architecture test — don't cite one that doesn't exist. |
441
+
442
+ ---
443
+
444
+ # Workflow: `/dknet-entity`
445
+
446
+ The procedure an agent follows when invoked with arguments. The reference sections above are the rules it applies.
447
+
448
+ You are scaffolding the **Domain + Infra** layers of a vertical slice. Stop after the migration is generated and the solution builds — `/dknet-crud` will continue from there.
449
+
450
+ ### Inputs
451
+
452
+ `$ARGUMENTS` — feature folder (plural, PascalCase), aggregate name (singular, PascalCase), an optional
453
+ `mode=manual|auto` (defaults to `manual`), and an optional list of properties (`Name:Type`, append `?`
454
+ for nullable).
455
+
456
+ **The mode changes what this command writes onto the entity.** In `auto` the entity itself carries the
457
+ CRUD and event surface as attributes — skipping them here makes `/dknet-crud mode=auto` unreachable,
458
+ because the generator has nothing to read. If the mode was not supplied, apply
459
+ the `dknet-feature-lifecycle` skill §1 to choose one and say which you chose.
460
+
461
+ ### Required reading
462
+
463
+ 1. The reference sections above (mode=manual / mode=auto sections cover the exemplar shapes in full)
464
+ 2. the `dknet-efcore-config` skill
465
+ 3. `manual` exemplar — `ApiEndpoints/Minimal.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`; `auto` exemplar — `ApiEndpoints/Minimal.Domains/Features/AutomatedSample/Entities/Product.cs`
466
+ 4. `ApiEndpoints/Minimal.Infra/Features/ManualSample/Mappers/PurchaseOrderConfigs.cs` (exemplar mapper — hand-written in **both** modes; no generator produces this)
467
+
468
+ ### Steps
469
+
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 Minimal.Infra/Minimal.Infra.csproj` from `ApiEndpoints/`. Apply the mode=manual or mode=auto rules from the sections above verbatim — don't re-derive them.
471
+ 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/Minimal.Domains/bin/Release/net10.0/Minimal.Domains.dll | grep <Entity>`.
473
+ 4. Report:
474
+ - mode used,
475
+ - files created (relative paths),
476
+ - migration name + tables/indexes,
477
+ - for `auto`, the composed event-record names you verified,
478
+ - the exact next command (`/dknet-crud <Feature> <Entity> mode=<mode>`).
479
+
480
+ ### Constraints
481
+
482
+ - Do NOT touch `Minimal.AppServices` or `Minimal.Api` here — those belong to `/dknet-crud` and `/dknet-endpoint`.
483
+ - Entity/mapper/event rules: see Validation checklist above — verify against it, don't restate it.
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: dknet-feature
3
+ description: Drive an end-to-end DKNet vertical-slice feature from plan to merged tests in either the manual or automated flow — orchestrates entity, CRUD, endpoint, tests, BDD, and docs.
4
+ metadata:
5
+ kind: workflow
6
+ arguments: "<Feature> <Entity> [mode=manual|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal"
7
+ allowed-tools: Read, Grep, Glob, Edit, Write, Bash, Agent, TodoWrite
8
+ ---
9
+
10
+ Usage: `/dknet-feature <Feature> <Entity> [mode=manual|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal`
11
+
12
+ You are the **DKNet Feature Orchestrator**. You take a feature request and drive it across every layer of a DKNet.Minimal solution to a working, tested, documented vertical slice. You delegate aggressively to subagents and skill-bound slash commands; you do not personally write product code.
13
+
14
+ To retire a feature, use `/dknet-feature-remove <Feature>`.
15
+
16
+ ## Inputs
17
+
18
+ `$ARGUMENTS` — feature folder (plural PascalCase), aggregate name (singular PascalCase), an optional
19
+ `mode=manual|auto`, and an optional property list. Example:
20
+ ```
21
+ /dknet-feature Orders Order mode=manual Number:string Total:decimal CustomerId:Guid Status:OrderStatus
22
+ ```
23
+
24
+ If the input is ambiguous, STOP and ask before doing anything else.
25
+
26
+ ## Required reading
27
+
28
+ The `dknet-feature-lifecycle` skill — §1 flow selection, §2 footprint. Read before Phase 0.
29
+
30
+ ## Phase 0 — Flow selection
31
+
32
+ The **mode** decides what every later phase produces. The two flows generate a different set of
33
+ files, different validation behavior, and different acting-user attribution — they are not two styles
34
+ of the same output.
35
+
36
+ | Mode | Exemplar | Shape |
37
+ |---|---|---|
38
+ | `manual` | `ManualSample` / `PurchaseOrder` | Every request, validator, handler, spec, DTO, and route is a file you write. Enforced validation, `.RequiredIdempotentKey()` on create, `[FromClaim]` acting user. |
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
+
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` whenever the request mentions an operation that writes more
43
+ than one aggregate in one transaction, idempotent writes, a filtered query, a DTO that hides fields,
44
+ or attribute-declared validation that must return `400` — `auto` is a deliberate trade, not a
45
+ fallback. A business rule, state transition or duplicate check on its own does **not** force
46
+ `manual`: write it as a FluentValidation validator on the generated request, the way the product
47
+ sample refuses a duplicate name and a delete of a product still for sale.
48
+
49
+ When `auto` is selected, state the validation gap in your confirmation message: a `[Range]` on a
50
+ generated request property is forwarded but never enforced, so a `POST` with an invalid value returns
51
+ `201`, not `400`. The gap is the attribute only — FluentValidation still runs on generated routes. The user accepts that before Phase 2 starts.
52
+
53
+ Thread the resolved mode into every phase below and do not let it drift. Mixing flows on one
54
+ aggregate is out of scope for this command — if only one operation needs a rule, finish in `auto` and
55
+ report that operation as a follow-up to hand-write.
56
+
57
+ ## Workflow (do not skip phases, do not reorder)
58
+
59
+ For each phase: announce the phase, dispatch the work, wait for completion, then verify before moving on. Use `TodoWrite` to track phase status.
60
+
61
+ ### Phase 1 — Plan (read-only)
62
+
63
+ Dispatch the `dknet-architect` subagent with the feature request **and the resolved mode**. Print its plan and ask the user to confirm or amend before any code is written. If the user changes the plan, re-run the architect with the amendments.
64
+
65
+ If the architect's plan surfaces a rule that `auto` cannot enforce, say so and re-open Phase 0 rather than carrying the mismatch forward.
66
+
67
+ ### Phase 2 — Domain + Infra
68
+
69
+ Run `/dknet-entity <Feature> <Entity> mode=<mode> <props…>`. Verify in both modes:
70
+ - entity inherits `AggregateRoot`, properties `{ get; private set; }`,
71
+ - mapper is `internal sealed : DefaultEntityTypeConfiguration<T>`,
72
+ - `DomainSchemas.<Feature>` constant exists,
73
+ - migration was generated,
74
+ - `dotnet build` is green.
75
+
76
+ Additionally verify, by mode:
77
+ - `manual` — mutation methods raise events via `AddEvent(...)`; a hand-written event record exists.
78
+ - `auto` — class-level `[RaisesEvent(...)]`, a `[CrudCreate]` constructor, and at least one
79
+ `[CrudUpdate]` method are present. No `AddEvent` call anywhere in the slice.
80
+
81
+ ### Phase 3 — AppServices CRUD
82
+
83
+ Run `/dknet-crud <Feature> <Entity> mode=<mode>`. Verify:
84
+ - `manual` — Create/Update/Delete requests + validators + `internal sealed` handlers, `SpecGet<Entity>`, queries, hand-written DTO record, event handler.
85
+ - `auto` — exactly one `[GenerateDto(typeof(<Entity>))] public sealed partial record <Entity>Dto;` and any hand-written event *consumer*. Then `dotnet build` and confirm the expected types appeared under `obj/Generated/DKNet.SlimBus.Generators/`. An empty generated folder means the attributes did not take — STOP.
86
+
87
+ Build green either way.
88
+
89
+ ### Phase 4 — Endpoint
90
+
91
+ 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()`.
93
+ - `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
+
95
+ Build green.
96
+
97
+ ### Phase 5 — Unit/integration tests
98
+
99
+ Run `/dknet-unit-tests <Feature> <Entity> mode=<mode>`. Verify all tests pass and cover happy path, not-found, and domain events in both modes, plus by mode:
100
+ - `manual` — FluentValidation failures, duplicate detection, and any rejected state transition.
101
+ - `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
+
103
+ ### Phase 6 — BDD acceptance tests
104
+
105
+ Dispatch the `dknet-bdd-engineer` subagent with the feature scope. Verify `.feature` + step files exist with status + shape + key-field assertions and the BDD project passes.
106
+
107
+ ### Phase 7 — Feature documentation
108
+
109
+ Run `/dknet-docs <Feature>`. Verify README, architecture diagrams, data-model, and api-reference exist under `docs/features/<feature-kebab>/` (or `docs/<feature>/` if internal).
110
+
111
+ ### Phase 8 — Final gates
112
+
113
+ 1. `dotnet build -c Release` — zero warnings (warnings-as-errors).
114
+ 2. `dotnet test --settings coverage.runsettings` — all green.
115
+ 3. Print a final report:
116
+ - Mode used, and the one-line reason it was chosen.
117
+ - Files created/edited grouped by layer.
118
+ - Migration name + tables.
119
+ - Endpoints (route + verbs), flagging whether the create route is idempotent.
120
+ - Test counts (unit + BDD).
121
+ - Docs paths.
122
+ - For `auto`: the exact generated type names produced, and a plain statement that the forwarded
123
+ DataAnnotations validation on those routes is not enforced.
124
+ - `/dknet-feature-remove <Feature>` as the way to retire the slice.
125
+ - Suggested commit/PR title (do not commit unless the user asks).
126
+
127
+ ## Stop conditions
128
+
129
+ - Any phase produces a build or test failure that the implementer cannot trivially fix → STOP, summarize, ask the user.
130
+ - The architect surfaces an ambiguity → STOP at end of Phase 1, do not start Phase 2.
131
+ - Mode is unresolved or the user has not accepted the `auto` validation gap → STOP at Phase 0.
132
+ - `auto` was selected but the build produces no generated types → STOP; the attributes are wrong and every later phase would build on nothing.
133
+ - The user says "stop" or pivots → halt and report state.
134
+
135
+ ## Constraints
136
+
137
+ - Never skip the plan phase. Even when the user gives a clear request, surface the architect's plan for explicit confirmation before writing code.
138
+ - Never commit, push, or open PRs unless the user explicitly asks. The orchestrator's final output is a green test suite and a suggested commit message — not an actual commit.
139
+ - Never edit agent skill/plugin folders or `Directory.Packages.props` as part of a feature slice — those are template-level concerns.