@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,330 @@
1
+ ---
2
+ name: dknet-queries-specs
3
+ description: Write read/query logic for a DKNet feature — Specification<T> filters, hand-written query requests/handlers dispatched over IMessageBus, and the generic filter/search/order/page list route every generator-driven CRUD slice gets for free. Use after the domain entity and DTO exist, whenever a feature needs anything more than the default GET-by-id.
4
+ ---
5
+
6
+ # Skill: Queries and Specifications
7
+
8
+ Covers the read side of a feature: specifications, hand-written queries (`mode=manual`), and the
9
+ generic list route every `[CrudCreate]`-declared entity gets automatically (`mode=auto`).
10
+
11
+ ## 1. Specifications — why, and the WHERE-FALSE trap
12
+
13
+ Handlers never build a LINQ query against `CoreDbContext` directly. They ask `IRepositorySpec` for
14
+ `FirstOrDefaultAsync(spec, ct)`, `AnyAsync(spec, ct)`, `Query(spec)`, or
15
+ `ToPagedListAsync(spec, pageIndex, pageSize, ct)`, passing a `Specification<TEntity>`
16
+ (`DKNet.EfCore.Specifications.Definitions`). A spec is a reusable, named, unit-testable filter — the
17
+ same predicate isn't hand-rolled differently in every handler that needs it.
18
+
19
+ ```csharp
20
+ internal sealed class SpecGetPurchaseOrder : Specification<PurchaseOrder>
21
+ {
22
+ public SpecGetPurchaseOrder(Guid? byId = null, string? byCustomerName = null)
23
+ {
24
+ var predicator = CreatePredicate();
25
+
26
+ if (byId is not null)
27
+ predicator = predicator.And(a => a.Id == byId);
28
+
29
+ if (!string.IsNullOrEmpty(byCustomerName))
30
+ predicator = predicator.And(a => a.CustomerName == byCustomerName);
31
+
32
+ if (byId is null && string.IsNullOrEmpty(byCustomerName))
33
+ // An unstarted predicate builder compiles to WHERE FALSE — without this, "no filter"
34
+ // would silently match nothing instead of listing every order.
35
+ predicator = predicator.And(_ => true);
36
+
37
+ WithFilter(predicator);
38
+ }
39
+ }
40
+ ```
41
+
42
+ (`Minimal.AppServices/ManualSample/V1/Specs/SpecGetPurchaseOrder.cs`; the automated sample's
43
+ `SpecGetProduct` and `SpecProductByName` follow the same shape.)
44
+
45
+ - **`CreatePredicate()`** with no `.And(...)`/`.Or(...)` ever called compiles to `WHERE FALSE`. Any
46
+ spec whose constructor can be called with *no* filter argument at all needs an explicit
47
+ `.And(_ => true)` fallback in that branch, or an unfiltered "list everything" call silently returns
48
+ zero rows. `SpecGetProduct` hits the same trap with a plain `else` branch instead of a combined `if`
49
+ — same fix, either shape works.
50
+ - **`WithFilter(predicate)`** is what actually attaches the compiled predicate to the specification;
51
+ building `predicator` without ever calling it is a no-op spec.
52
+ - **Specs live in `<Feature>/V1/Specs/`, `internal sealed`** — `SpecGetPurchaseOrder`,
53
+ `SpecGetProduct`, `SpecProductByName` all follow this.
54
+ - **Specs are reused outside query handlers, too.** `SpecProductByName` (`Minimal.AppServices/
55
+ AutomatedSample/V1/Specs/SpecProductByName.cs`) exists purely so `CreateProductRequestValidator` can
56
+ check for a duplicate name before create — a spec is not only a query-handler concern.
57
+ - **Unit-test a spec without EF Core or a database**: compile `.FilterQuery!.Compile()` into a plain
58
+ `Func<TEntity, bool>` and assert against in-memory instances (`SpecGetPurchaseOrderTests.cs` — see
59
+ [Testing pointers](#5-testing-pointers)).
60
+
61
+ ## 2. Hand-written queries (`mode=manual`)
62
+
63
+ A query is a record implementing one of two `SlimBus.Extensions.Fluents.Queries` interfaces,
64
+ dispatched from the endpoint via `IMessageBus.Send(...)` exactly like a command.
65
+
66
+ ### Single result — `IWitResponse<TDto>` / `IHandler<TQuery,TDto>`
67
+
68
+ ```csharp
69
+ public sealed record GetPurchaseOrderByIdQuery : Fluents.Queries.IWitResponse<PurchaseOrderDto>
70
+ {
71
+ public required Guid Id { get; init; }
72
+ }
73
+
74
+ internal sealed class GetPurchaseOrderByIdQueryHandler(IRepositorySpec repository, IMapper mapper)
75
+ : Fluents.Queries.IHandler<GetPurchaseOrderByIdQuery, PurchaseOrderDto>
76
+ {
77
+ public async Task<PurchaseOrderDto?> OnHandle(GetPurchaseOrderByIdQuery request, CancellationToken cancellationToken)
78
+ {
79
+ var order = await repository.FirstOrDefaultAsync(new SpecGetPurchaseOrder(request.Id), cancellationToken);
80
+ return order is null ? null : mapper.Map<PurchaseOrderDto>(order);
81
+ }
82
+ }
83
+ ```
84
+
85
+ `OnHandle` returns `TDto?`, not a `Result`. A `null` return is the "not found" signal — the calling
86
+ endpoint turns it into `404` (`dto is null ? Results.NotFound() : Results.Ok(dto)`; see the
87
+ `dknet-endpoint` skill). There is no failure channel beyond that; a query handler doesn't
88
+ refuse with an error code the way a command handler does.
89
+
90
+ ### Paged list — `IWitPageResponse<TDto>` / `IPageHandler<TQuery,TDto>`
91
+
92
+ ```csharp
93
+ public sealed record ListPurchaseOrdersQuery : Fluents.Queries.IWitPageResponse<PurchaseOrderDto>
94
+ {
95
+ public const int DefaultPageIndex = 1;
96
+ public const int DefaultPageSize = 20;
97
+
98
+ // Nullable so [AsParameters] leaves these `null` (not the CLR default 0) when the caller never
99
+ // supplied the query parameter — distinguishing "not supplied" from an explicit pageSize=0.
100
+ public int? PageIndex { get; init; }
101
+ public int? PageSize { get; init; }
102
+ public string? CustomerName { get; init; }
103
+ }
104
+
105
+ internal sealed class ListPurchaseOrdersQueryValidator : AbstractValidator<ListPurchaseOrdersQuery>
106
+ {
107
+ public ListPurchaseOrdersQueryValidator()
108
+ {
109
+ RuleFor(a => a.PageSize).InclusiveBetween(1, 100).When(a => a.PageSize.HasValue);
110
+ RuleFor(a => a.PageIndex).GreaterThan(0).When(a => a.PageIndex.HasValue);
111
+ }
112
+ }
113
+
114
+ internal sealed class ListPurchaseOrdersQueryHandler(IRepositorySpec repository, IMapper mapper)
115
+ : Fluents.Queries.IPageHandler<ListPurchaseOrdersQuery, PurchaseOrderDto>
116
+ {
117
+ public async Task<IPagedList<PurchaseOrderDto>> OnHandle(ListPurchaseOrdersQuery request, CancellationToken cancellationToken)
118
+ {
119
+ var spec = new SpecGetPurchaseOrder(byCustomerName: request.CustomerName);
120
+ var pageIndex = request.PageIndex ?? ListPurchaseOrdersQuery.DefaultPageIndex;
121
+ var pageSize = request.PageSize ?? ListPurchaseOrdersQuery.DefaultPageSize;
122
+ var page = await repository.ToPagedListAsync(spec, pageIndex, pageSize, cancellationToken);
123
+ return new StaticPagedList<PurchaseOrderDto>(page.Select(mapper.Map<PurchaseOrderDto>), page);
124
+ }
125
+ }
126
+ ```
127
+
128
+ (`Minimal.AppServices/ManualSample/V1/Queries/GetPurchaseOrderById.cs`,
129
+ `ListPurchaseOrders.cs`.)
130
+
131
+ - **Nullable paging parameters + `[AsParameters]` + a co-located validator with `.When(x =>
132
+ x.PageSize.HasValue)`** is the pattern: it lets an omitted parameter fall back to the query's own
133
+ declared default (`DefaultPageIndex`/`DefaultPageSize`), while an explicit out-of-range value
134
+ (`pageSize=0`) still fails validation instead of silently falling back.
135
+ - **`DefaultPageSize`/the `1..100` bound are this query's own choices**, unrelated to the generic list
136
+ route's `1000`/`DKNet:ListQuery` defaults (§3) — a hand-written query keeps whatever numbers you
137
+ pick regardless of the package's own configuration.
138
+ - **`X.PagedList`'s `StaticPagedList<TDto>`** rewraps the projected DTO page so page metadata (total
139
+ count, page index, page size) survives the entity-to-DTO conversion — the handler pages the entity
140
+ query first, then projects, never the other way around.
141
+
142
+ ### Aggregate/summary queries over `repository.Query(spec)`
143
+
144
+ Not every read is "one row" or "a page of rows". `ProductPriceSummaryQuery`
145
+ (`Minimal.AppServices/AutomatedSample/V1/Queries/ProductPriceSummary.cs`) computes a count and an
146
+ average directly against the queryable a spec produces:
147
+
148
+ ```csharp
149
+ public sealed record ProductPriceSummaryDto(int ProductCount, decimal AveragePrice);
150
+ public sealed record ProductPriceSummaryQuery : Fluents.Queries.IWitResponse<ProductPriceSummaryDto>;
151
+
152
+ internal sealed class ProductPriceSummaryQueryHandler(IRepositorySpec repository)
153
+ : Fluents.Queries.IHandler<ProductPriceSummaryQuery, ProductPriceSummaryDto>
154
+ {
155
+ public async Task<ProductPriceSummaryDto?> OnHandle(ProductPriceSummaryQuery request, CancellationToken cancellationToken)
156
+ {
157
+ var products = repository.Query(new SpecGetProduct());
158
+ var count = await products.CountAsync(cancellationToken);
159
+ var averagePrice = count == 0 ? 0m : await products.AverageAsync(p => p.Price, cancellationToken);
160
+ return new ProductPriceSummaryDto(count, averagePrice);
161
+ }
162
+ }
163
+ ```
164
+
165
+ `repository.Query(spec)` hands back an `IQueryable<TEntity>` with the spec's filter already applied —
166
+ use it for `CountAsync`/`AverageAsync`/`GroupBy`/anything that isn't "one entity" or "one page of
167
+ entities". This query has no dedicated DTO-projection step because the response isn't shaped like the
168
+ entity at all.
169
+
170
+ ## 3. The generic list route (generator-driven, `mode=auto`)
171
+
172
+ Every entity with `[CrudCreate]`/`[CrudUpdate]` gets a `GET /` list route for free through
173
+ `Map{Entity}Crud()` — no handler, validator, or query object written by hand. It comes from
174
+ `MapGetList<TEntity,TKey,TDto>()` in `DKNet.AspCore.Extensions`; you never call it directly, the
175
+ generator emits the call.
176
+
177
+ **This is a separate contract from §2 — the two don't share defaults, limits, or behavior.**
178
+
179
+ | Parameter | Type | Default | Notes |
180
+ |---|---|---|---|
181
+ | `pageNumber` | int | `1` | `< 1` is clamped to `1`, never rejected. |
182
+ | `pageSize` | int | `1000` | `< 1` falls back to `1000`; ceiling `1000` (host-configurable) — clamped, not rejected. |
183
+ | `filter` | repeatable | none | `field:operation:value`, ANDed across repeats. Max 20. |
184
+ | `search` | string | none | Free-text `Contains`, OR'd across every string DTO field. Min 2 chars. |
185
+ | `orderBy` | string | none | One DTO field name; `Id` appended as a descending tie-break. |
186
+ | `desc` | bool | `false` | Reverses `orderBy`. |
187
+ | `fromDate`/`toDate` | ISO-8601 | none | Inclusive bounds on last-activity (`CreatedOn` or `UpdatedOn`); see below. |
188
+
189
+ ### Filter operations
190
+
191
+ `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Contains`,
192
+ `NotContains`, `StartsWith`, `EndsWith`, `In`, `NotIn` (comma-separated value list), `IsNull`,
193
+ `IsNotNull` (two-segment form, no value: `field:IsNull`). `Field` is normalized to PascalCase, matched
194
+ case-insensitively, and only the **first two** colons split the triple — a value may itself contain
195
+ colons (`filter=CreatedOn:GreaterThan:2026-01-31T00:00:00Z`).
196
+
197
+ ```
198
+ GET /v1/products?filter=Price:GreaterThan:100&filter=IsDiscontinued:Equal:false
199
+ ```
200
+
201
+ ### Recent-activity window
202
+
203
+ `fromDate`/`toDate` bound when a record was **last active** — either its creation or its last update
204
+ moment falling inside the window counts. Neither bound given, over an audited entity (every
205
+ `AggregateRoot` is): the listing covers the **last three months** by default, not all history. Naming
206
+ either bound replaces the default entirely (open-ended on the side you left out) —
207
+ `?fromDate=0001-01-01T00:00:00Z` is the documented way to ask for all history. `fromDate` later than
208
+ `toDate` is a `400`. Configurable via `DKNet:ListQuery:DefaultActivityWindowMonths` (`0` disables the
209
+ default window).
210
+
211
+ ### Configuring the defaults
212
+
213
+ `DKNet:ListQuery` config section: `DefaultPageSize` (`1000`), `MaxPageSize` (`1000`, the ceiling an
214
+ explicit `pageSize` clamps to and the cap on the default), `DefaultActivityWindowMonths` (`3`).
215
+
216
+ ### Response envelope
217
+
218
+ `200 OK` with `PagedResponse<TDto>{ Items, PageCount, PageNumber, PageSize, TotalItemCount,
219
+ HasNextPage, HasPreviousPage }`, built from `X.PagedList` via `ToPagedListAsync(...)` — `TotalItemCount`
220
+ is always the full unpaged count.
221
+
222
+ ### Error behavior
223
+
224
+ Everything malformed is `400`, never silently dropped or ignored: an unknown filter/order field, an
225
+ unparseable filter triple, a value that won't coerce to the field's CLR type, more than 20 filters, a
226
+ `search` under 2 characters, `fromDate` after `toDate`. Only paging is clamped instead of rejected.
227
+
228
+ ### The DTO is the boundary
229
+
230
+ Filter, search, and order fields resolve against the **DTO** (`TModel`), never the raw entity — a
231
+ deliberate security boundary. `ProductDto` excludes `OwnedBy`
232
+ (`[GenerateDto(..., Exclude = [nameof(Product.OwnedBy), ...])]`), so `?filter=ownedBy:Equal:x` is a
233
+ `400`, not a leak of a hidden column. Widen or narrow the query surface by changing the DTO's
234
+ `Exclude`/`Include`, never the endpoint — see the `dknet-dto-mapping` skill for how a DTO's shape is
235
+ declared.
236
+
237
+ **Trap: a queryable DTO field must map to a real column.** Filter/search/order build EF predicates by
238
+ property name against the *entity*. A DTO field the entity declares but does not map (`[NotMapped]`,
239
+ or a computed property like `AuditedEntity<TKey>.LastModifiedBy`/`LastModifiedOn`) passes the "is this
240
+ field on the DTO" check and then fails to translate — a **500**, not a `400`, and search hits it on
241
+ the very first `?search=` call since search touches every string field. A DTO field with *no* entity
242
+ counterpart at all (`ProductDto.GrossMargin`, hand-mapped via a Mapster `IRegister` — see the
243
+ `dknet-dto-mapping` skill) is caught earlier and refused with a clean `400` naming the field instead.
244
+ Either way: `Exclude` a computed/unmapped member from `[GenerateDto]` rather than let the list route
245
+ inherit a latent failure.
246
+
247
+ ### Behavioral spec: `ProductList.feature`
248
+
249
+ `Minimal.App.BDDTests/Features/Products/ProductList.feature` is the regression fence for this whole
250
+ contract — it exists specifically because nothing in the slice is hand-written, so a future package
251
+ bump could silently change behavior. Representative scenarios:
252
+
253
+ - paging envelope carries the *unpaged* `totalItemCount`; `pageSize` above the max is clamped to
254
+ `1000`, not rejected;
255
+ - `orderBy=price&desc=true` sorts descending; ascending is the default;
256
+ - `filter=price:GreaterThan:20` and two ANDed filters both narrow correctly; `filter=name:In:Apple,Cherry`
257
+ matches any listed value;
258
+ - `search=rico` matches `Apricot` (substring, not prefix); a one-character search is `400`;
259
+ - `filter=colour:Equal:red` (unknown field) and `filter=ownedBy:Equal:someone` (excluded field) are
260
+ both `400` — the second is the DTO-boundary regression fence specifically;
261
+ - a `fromDate` before the seeded rows still returns them; a `toDate` before them excludes all of them
262
+ (empty page, not `404`); `fromDate` after `toDate` is `400`;
263
+ `orderBy=grossMargin`/`filter=grossMargin:GreaterThan:0` are `400` naming the field, never a `500`.
264
+
265
+ ## 4. Status counts
266
+
267
+ `group.MapGetStatusCounts<TEntity>("status", new StatusPropertyInfo(nameof(X.Status), typeof(XStatus)))`
268
+ (`Minimal.Api/Configs/Endpoints/StatusCountsEndpointMapperExtensions.cs`) is template-local — not part
269
+ of the published `DKNet.AspCore.Extensions` package. It groups rows by an enum-backed property using
270
+ `ModelSpecStatusCounts<TEntity>` (`Minimal.AppServices/Share/Generics/ModelSpecGenericStatusCounts.cs`):
271
+
272
+ ```csharp
273
+ public class ModelSpecStatusCounts<TEntity> : Specification<TEntity> where TEntity : DomainEntity
274
+ {
275
+ public ModelSpecStatusCounts(GenericStatusCountsParameters parameters)
276
+ {
277
+ var predicate = CreatePredicate(x => true); // seeded non-empty; no WHERE-FALSE guard needed
278
+ if (parameters.From is { } from) predicate = predicate.And(x => x.CreatedOn >= from);
279
+ if (parameters.To is { } to) predicate = predicate.And(x => x.CreatedOn <= to);
280
+ WithFilter(predicate);
281
+ }
282
+ }
283
+ ```
284
+
285
+ `StatusPropertyInfo(string Name, Type EnumType)` names the property to group by and its enum type;
286
+ `GetStatusCounts<TEntity>` backfills every enum member with a zero count when the database has none,
287
+ so a caller always sees the full status set. `GenericStatusCountsParameters.From`/`To` are both
288
+ optional and **unbounded by default** — omitting both reports counts over all history, not a rolling
289
+ window; pass explicit bounds for something like "the last 30 days". No shipped endpoint config calls
290
+ this today; wire it into a `Map(RouteGroupBuilder)` like any other route (see the
291
+ `dknet-endpoint` skill) if a status breakdown is useful for your entity.
292
+
293
+ ## 5. Decision table — generic list vs. hand-written query
294
+
295
+ | Need | Use |
296
+ |---|---|
297
+ | Filter/sort/search only ever touches fields already on the generated DTO | Generic list route (§3) — free |
298
+ | A filter on a field the DTO must *not* expose (ownership key, an internal flag) | Neither — exclude it from the DTO; don't build a query to reach it |
299
+ | A join across two entities, or a projection the DTO can't express | Hand-written query (§2) |
300
+ | Custom paging defaults/limits different from `1000`/`DKNet:ListQuery` | Hand-written query (§2) — the generic route's defaults aren't overridable per-feature |
301
+ | An aggregate value (count, average, grouped totals) instead of a list of rows | Hand-written query over `repository.Query(spec)` (§2) |
302
+ | The query needs the acting user (e.g. "orders I created") | Hand-written query — the generic list route has no request shape to carry a `[FromClaim]` member |
303
+ | A field the DTO exposes only as a computed/unmapped value needs to be searchable | Neither works — searching/filtering an unmapped field 500s; add a real mapped column or drop the requirement |
304
+
305
+ ## 6. Testing pointers
306
+
307
+ - **Spec unit tests** — compile the spec's `.FilterQuery!.Compile()` and assert against
308
+ hand-constructed entities, no EF Core or database involved
309
+ (`Minimal.App.Tests/Unit/ManualSample/SpecGetPurchaseOrderTests.cs`): a "no filter matches
310
+ everything" case is the WHERE-FALSE regression guard, plus one case per optional filter argument and
311
+ one for combining them.
312
+ - **List/paging integration tests** — exercise the real HTTP route against `ApiFixture`
313
+ (`Minimal.App.Tests/Integration/ManualSample/V1/PurchaseOrderListPagingTests.cs`): the shape to copy
314
+ is asserting the *declared default* is served when a nullable paging parameter is omitted (not just
315
+ "200 OK"), and asserting an out-of-range value produces the same `ValidationProblemDetails` shape as
316
+ every other validation failure, not a `500`.
317
+ - Full test-writing guidance (fixtures, `IMessageBus` test dispatch, assertion style): load the
318
+ `dknet-unit-tests` skill.
319
+
320
+ ## Common mistakes
321
+
322
+ | What you might expect | What actually happens | Why |
323
+ |---|---|---|
324
+ | A spec with no filter argument supplied lists every row | It returns zero rows | `CreatePredicate()` with nothing ever `.And`'d compiles to `WHERE FALSE` — add an explicit `.And(_ => true)` fallback |
325
+ | The generic list route and a hand-written list query share page-size defaults/limits | They don't — `1000`/`DKNet:ListQuery` vs. whatever the hand-written query declares (often `20`/`1..100`) | Two independent contracts; see §2 vs §3 |
326
+ | Excluding a DTO field only hides it from JSON output | It also makes the field unqueryable through the generic list route | Filter/search/order resolve against the DTO, not the entity — this is a security boundary, not a display choice |
327
+ | A computed DTO property (`LastModifiedBy`, a hand-mapped derived field) is safe to leave on a `[GenerateDto]` | The first `?search=` against it throws a `500` (unmapped) or a clean `400` (no entity counterpart at all) | Filter/search/order build EF predicates by property name against the entity; `Exclude` computed members |
328
+ | Omitting `pageSize` on a hand-written list query falls back to `0` | It falls back to the query's own declared default, and an explicit `pageSize=0` still `400`s | Nullable paging properties + `[AsParameters]` + a validator gated on `.HasValue` — see §2 |
329
+ | `fromDate`/`toDate` narrow an *unaudited* entity's listing | They're silently ignored | The recent-activity window only applies to entities carrying `CreatedOn`/`UpdatedOn` |
330
+ | No `fromDate`/`toDate` means "all history" on the generic list route | It means "the last three months" (or whatever `DefaultActivityWindowMonths` is configured to) | The default window applies unless you explicitly widen it with `fromDate=0001-01-01T00:00:00Z` |
@@ -0,0 +1,209 @@
1
+ ---
2
+ name: dknet-scaffold
3
+ description: Scaffold a new solution from the DKNet.Minimal template and get it running — dotnet new install/new, the six template parameters, what the generated tree looks like, first build/run with or without Aspire, deleting the two shipped sample features, and installing this skill plugin into the generated repo. Use when starting a new DKNet.Minimal solution, or when orienting inside a freshly generated one.
4
+ ---
5
+
6
+ # Scaffolding a DKNet.Minimal solution
7
+
8
+ This skill covers going from nothing to a green, running solution you can add business features to.
9
+ Once you are there, `dknet-feature-lifecycle` takes over.
10
+
11
+ ## 1. Install and generate
12
+
13
+ ```bash
14
+ dotnet nuget add source --username <GITHUB_USERNAME> --password <GITHUB_PAT_WITH_READ_PACKAGES> \
15
+ --store-password-in-clear-text --name github "https://nuget.pkg.github.com/baoduy/index.json"
16
+ dotnet new install DKNet.Minimal.Template --nuget-source "https://nuget.pkg.github.com/baoduy/index.json"
17
+
18
+ dotnet new dknet-minimal -n <YourApp>
19
+ cd <YourApp>
20
+ ```
21
+
22
+ `-n <YourApp>` sets the solution's root name. A dotted name works (`-n DKNet.Accounts` generates a
23
+ solution that builds and tests green with no hand edits) — the template's `sourceName` is `Minimal`,
24
+ and `dotnet new` rewrites that identifier to `<YourApp>` in **every** file name, folder name,
25
+ namespace, project reference, and the text inside the shipped `AGENTS.md`. Never hand-edit a
26
+ namespace after generating; the rename is already complete and consistent.
27
+
28
+ **What does NOT get renamed:** the `ApiEndpoints/` folder itself, and any repo path that never
29
+ contained the literal string `Minimal`. Only the project folders and files *inside* `ApiEndpoints/`
30
+ pick up the new name — confirmed by generating: `-n DKNet.Accounts` produces
31
+ `ApiEndpoints/DKNet.Accounts.Api/`, `ApiEndpoints/DKNet.Accounts.Domains/`, and so on, with
32
+ `ApiEndpoints/` unchanged, and the solution file renamed to `DKNet.Accounts.sln`.
33
+
34
+ ### Parameters
35
+
36
+ All six are optional and every one has a working default, so `-n <YourApp>` alone generates a
37
+ solution that builds.
38
+
39
+ | Parameter | Default | What it sets |
40
+ |---|---|---|
41
+ | `--Framework` | `net10.0` | Target framework. `net10.0` is the only offered choice. |
42
+ | `--AuthorName` | `Steven Hoang` | `<Authors>` in `Directory.Packages.props`, inherited by every project. |
43
+ | `--CompanyUrl` | `https://drunkcoding.net` | `<Company>` in `Directory.Packages.props`. |
44
+ | `--RepositoryUrl` | `https://github.com/baoduy/DKNet` | `<PackageProjectUrl>`/`<RepositoryUrl>` in `Directory.Packages.props`. |
45
+ | `--TenantId` | all-zero GUID | `Authentication:Schemes:Bearer:MetadataAddress` + `:ValidIssuer` in `appsettings.json`. |
46
+ | `--ApiAudience` | `api://your-api` | The single entry in `Authentication:Schemes:Bearer:ValidAudiences`. |
47
+
48
+ ```bash
49
+ dotnet new dknet-minimal -n <YourApp> \
50
+ --AuthorName "Jane Smith" --CompanyUrl "https://example.com" \
51
+ --RepositoryUrl "https://github.com/example/order-service" \
52
+ --TenantId "11111111-2222-3333-4444-555555555555" --ApiAudience "api://order-service"
53
+ ```
54
+
55
+ **Replace `--TenantId` and `--ApiAudience` before enabling `FeatureManagement:RequireAuthorization`.**
56
+ Left as shipped, the OIDC metadata fetch runs against a tenant that does not exist and every token
57
+ is rejected on audience mismatch — see `dknet-auth-and-ownership` and `dknet-platform-config` for
58
+ the full flag interaction. These are replace-on-generate values, not settings you can change by
59
+ re-running the template; change them later by editing `Directory.Packages.props` (metadata) or
60
+ `appsettings.json` (the auth pair) directly.
61
+
62
+ ## 2. What you get
63
+
64
+ ```
65
+ <YourApp>/
66
+ ├── <YourApp>.sln ← the solution; dotnet build from here needs no path
67
+ ├── Directory.Packages.props ← ALL NuGet versions, centrally managed
68
+ ├── global.json ← SDK pinned to net10.0
69
+ ├── coverage.runsettings
70
+ ├── AGENTS.md ← architecture reference, rewritten to your project names
71
+ └── ApiEndpoints/ ← this folder name does NOT change
72
+ ├── <YourApp>.Api/ ← endpoints, auth, OpenAPI
73
+ ├── <YourApp>.AppServices/ ← CQRS handlers, validators, DTOs
74
+ ├── <YourApp>.Domains/ ← entities, aggregate roots
75
+ ├── <YourApp>.Infra/ ← EF Core, repos, event publisher
76
+ ├── <YourApp>.Share/ ← shared constants/options
77
+ ├── <YourApp>.AppHost/ ← Aspire orchestration
78
+ ├── <YourApp>.App.Tests/ ← xUnit + Shouldly
79
+ ├── <YourApp>.App.BDDTests/ ← Reqnroll + NUnit
80
+ └── <YourApp>.App.TestSupport/
81
+ ```
82
+
83
+ **Path convention:** every other skill in this plugin writes paths relative to the solution root
84
+ using the template's own placeholder names (`Minimal.Api`, `ApiEndpoints/Minimal.Domains/...`) —
85
+ substitute your own `<YourApp>.*` prefix mentally when working in a generated solution.
86
+
87
+ **No `.claude/`, `.github/`, or `docs/` reaches a generated solution.** Only `AGENTS.md`, the four
88
+ solution-level files above, and `ApiEndpoints/**` are packed (`DKNet.Minimal.Template.nuspec`) —
89
+ confirmed by generating and inspecting the output tree. Get this plugin's skills into a generated
90
+ repo through §6 below, not through `dotnet new`.
91
+
92
+ **There are no `*.sh` migration scripts** in a generated solution — the pack excludes them. Run the
93
+ commands directly, from `ApiEndpoints/`:
94
+
95
+ ```bash
96
+ cd ApiEndpoints
97
+ dotnet ef migrations add <Name> -c CoreDbContext -p <YourApp>.Infra/<YourApp>.Infra.csproj
98
+ dotnet ef migrations remove -c CoreDbContext -p <YourApp>.Infra/<YourApp>.Infra.csproj
99
+ ```
100
+
101
+ ## 3. Verify it is green before writing anything
102
+
103
+ ```bash
104
+ dotnet build -c Release # expect: 0 Warning(s), 0 Error(s)
105
+ dotnet test --settings coverage.runsettings
106
+ ```
107
+
108
+ Production projects run **warnings-as-errors** (`EnforceCodeStyleInBuild`, `AnalysisMode=All`). A
109
+ new warning fails the build; that is deliberate. Test projects opt out.
110
+
111
+ ## 4. First run
112
+
113
+ ```bash
114
+ # Full stack — Redis + PostgreSQL via Aspire. Requires Docker running.
115
+ dotnet run --project ApiEndpoints/<YourApp>.AppHost
116
+
117
+ # API only — no containers, fastest inner loop.
118
+ dotnet run --project ApiEndpoints/<YourApp>.Api
119
+ ```
120
+
121
+ The API-only path still needs a reachable database per `ConnectionStrings:AppDb`; the Aspire path
122
+ provisions one and also seeds it with 10,000 generated `Product`/`PurchaseOrder` rows each (tune or
123
+ disable via `SampleData:RecordsPerEntity` in the `AppHost`'s own `appsettings.json` — see
124
+ `dknet-platform-config`). Start with the AppHost unless Docker is unavailable.
125
+
126
+ Development defaults (`appsettings.Development.json`, applied when `ASPNETCORE_ENVIRONMENT=Development`,
127
+ which `dotnet run` uses by default): `RequireAuthorization=false`, `EnableDemoAuthentication=true`
128
+ (every request authenticates as a fixed demo identity), `EnableSwagger=true` (`/docs`),
129
+ `RunDbMigrationWhenAppStart=true`, `EnableHttps`/`EnableRateLimit`/`EnableSecurityHeaders`/
130
+ `EnableForwardedHeaders`/`EnableRequestBounds` all relaxed to `false`. None of this applies to a
131
+ deployed service — the base `appsettings.json` (what Production runs with, since no
132
+ `appsettings.Production.json` ships) keeps every security flag on. Full table in
133
+ `dknet-platform-config`.
134
+
135
+ Azure Service Bus stays off until `ConnectionStrings:AzureBus` is non-empty **and**
136
+ `FeatureManagement:EnableServiceBus` is on — an in-memory bus handles internal handlers either way,
137
+ so nothing extra is required for local development.
138
+
139
+ ## 5. The two sample features
140
+
141
+ The solution ships two complete worked examples of the same feature shape, built two different
142
+ ways. They exist to be read, then deleted:
143
+
144
+ | Feature folder | Entity | Flow |
145
+ |---|---|---|
146
+ | `ManualSample` | `PurchaseOrder` | Hand-written — enforced DataAnnotations validation, idempotent create, `[FromClaim]` acting user |
147
+ | `AutomatedSample` | `Product` | Generator-driven — `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]`/`[RaisesEvent]` attributes |
148
+
149
+ Read `dknet-feature-lifecycle` §1 before copying either — it has the full trade-off table, including
150
+ what the generated path gives up.
151
+
152
+ **Deleting them is the expected first step** once you have read them. Do not hand-delete: each
153
+ sample has out-of-folder touchpoints (`AutomatedSample` owns two `Produce`/`Consume` lines in
154
+ `ServiceBusSetup.cs` plus its own scopes class; `ManualSample` owns seed data). Use the command,
155
+ which handles those plus the drop migration:
156
+
157
+ ```
158
+ /dknet-feature-remove ManualSample
159
+ /dknet-feature-remove AutomatedSample
160
+ ```
161
+
162
+ Keep one of them until your first real feature works — a correct reference in-tree is worth more
163
+ than a tidy repo, and removal is one command whenever you want it.
164
+
165
+ ## 6. Installing this plugin into the generated repo
166
+
167
+ The skills and workflow commands that guide feature work do not travel with `dotnet new` (§2). Add
168
+ them to the generated repo separately, whichever tool you work in:
169
+
170
+ ```bash
171
+ # Claude Code
172
+ /plugin marketplace add baoduy/DKNet.Templates
173
+ /plugin install dknet-minimal@dknet-marketplace
174
+
175
+ # Any other agent (Codex, Cursor, Gemini CLI, ...)
176
+ npx skills add baoduy/DKNet.Templates
177
+ ```
178
+
179
+ GitHub Copilot: install through `npx skills add baoduy/DKNet.Templates -a github-copilot` (writes `.agents/skills/`, which Copilot reads) or `npm i -D @drunkcoding/dknet-implementation-skills` + `npx skills experimental_sync`
180
+ once either directory exists in the cloned repo (e.g. after one of the two commands above has run
181
+ in that repo, or after cloning a repo that already committed them).
182
+
183
+ ## 7. Where to go next
184
+
185
+ | Goal | Use |
186
+ |---|---|
187
+ | Choose manual vs auto for a new aggregate | `dknet-feature-lifecycle` §1 |
188
+ | Build a business feature end-to-end | `/dknet-feature <Feature> <Entity> mode=manual\|auto` |
189
+ | Remove a feature | `/dknet-feature-remove <Feature>` |
190
+ | Layer boundaries and auto-discovery wiring | `dknet-project-structure` |
191
+ | Start-up order, flags, config sections, jobs, Aspire, test hosts | `dknet-platform-config` |
192
+ | BDD scenarios | `dknet-bdd-tests`, `/dknet-bdd-tests` |
193
+
194
+ ## Gotchas
195
+
196
+ - **Never add `Version=` to a `.csproj`.** All NuGet versions live in `Directory.Packages.props`
197
+ (central package management); a version attribute on a `PackageReference` fails the build.
198
+ - **`FeatureManagement`, not `Features`,** is the config section backing `FeatureOptions`. Keys
199
+ match property names one-for-one, and `Get<FeatureOptions>()` ignores unknown keys — a misspelled
200
+ key silently no-ops instead of failing.
201
+ - **EF Core needs no `DbSet` declarations.** `UseAutoConfigModel` + `UseAutoDataSeeding` discover
202
+ mappers and seeders by assembly scan. If you add seed data, wire `UseAutoDataSeeding` into **both**
203
+ `InfraSetup.AddInfraServices` and `InfraMigration.MigrateDb` — missing the second is a real bug
204
+ this template hit once, and seed rows silently never appear over HTTP.
205
+ - **Register every new Infra service explicitly** in `InfraSetup.AddInfraServices` (one
206
+ `AddScoped<IService, Service>()` line) — there is no convention scan that finds it for you.
207
+ - **`--TenantId`/`--ApiAudience` are placeholders, not working values.** Enabling
208
+ `RequireAuthorization` before replacing them fails every request on signature/audience mismatch,
209
+ not just unauthenticated ones.