@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.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +38 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/agents/dknet-architect.md +49 -0
- package/agents/dknet-bdd-engineer.md +62 -0
- package/agents/dknet-implementer.md +75 -0
- package/package.json +52 -0
- package/plugin.json +26 -0
- package/skills/README.md +47 -0
- package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
- package/skills/dknet-bdd-tests/SKILL.md +355 -0
- package/skills/dknet-bdd-tests/checklist.md +39 -0
- package/skills/dknet-crud/SKILL.md +483 -0
- package/skills/dknet-ddd-principles/SKILL.md +87 -0
- package/skills/dknet-docs/SKILL.md +296 -0
- package/skills/dknet-docs/checklist.md +58 -0
- package/skills/dknet-docs/templates/README-template.md +68 -0
- package/skills/dknet-docs/templates/api-reference-template.md +275 -0
- package/skills/dknet-docs/templates/architecture-template.md +166 -0
- package/skills/dknet-docs/templates/data-model-template.md +99 -0
- package/skills/dknet-docs/templates/events-template.md +155 -0
- package/skills/dknet-dto-mapping/SKILL.md +278 -0
- package/skills/dknet-efcore-config/SKILL.md +379 -0
- package/skills/dknet-endpoint/SKILL.md +458 -0
- package/skills/dknet-entity/SKILL.md +483 -0
- package/skills/dknet-feature/SKILL.md +139 -0
- package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
- package/skills/dknet-feature-remove/SKILL.md +131 -0
- package/skills/dknet-messaging-events/SKILL.md +395 -0
- package/skills/dknet-package-adoption/SKILL.md +252 -0
- package/skills/dknet-platform-config/SKILL.md +342 -0
- package/skills/dknet-project-structure/SKILL.md +148 -0
- package/skills/dknet-queries-specs/SKILL.md +330 -0
- package/skills/dknet-scaffold/SKILL.md +209 -0
- 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.
|