@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,483 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dknet-crud
|
|
3
|
+
description: Create commands (Create/Update/business transitions/Delete) at the AppServices layer for a DKNet.Minimal feature — request contracts, FluentValidation validators, and SlimMessageBus handlers, in both the hand-written and generator-driven (CrudCreate/CrudUpdate/CrudAction) shapes. Use after the domain entity and EF Core mapper exist. Queries and paged lists are the `dknet-queries-specs` skill; response DTOs and Mapster are `dknet-dto-mapping`. Invoke as `/dknet-crud <Feature> <Entity> [mode=manual|auto] [version=V1]` to scaffold it for a feature.
|
|
4
|
+
metadata:
|
|
5
|
+
kind: workflow
|
|
6
|
+
arguments: "<Feature> <Entity> [mode=manual|auto] [version=V1]"
|
|
7
|
+
allowed-tools: Read, Grep, Glob, Edit, Write, Bash, Agent
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Usage: `/dknet-crud <Feature> <Entity> [mode=manual|auto] [version=V1]`
|
|
11
|
+
|
|
12
|
+
# AppServices actions
|
|
13
|
+
|
|
14
|
+
Commands only: Create, Update, a business-rule transition, Delete, and the generator's
|
|
15
|
+
`[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]` equivalents. For query specs and paged lists, load
|
|
16
|
+
`dknet-queries-specs`. For DTO shape and Mapster wiring, load `dknet-dto-mapping`. For whether a
|
|
17
|
+
rule belongs on the entity or in a handler, load `dknet-ddd-principles` first.
|
|
18
|
+
|
|
19
|
+
## GlobalUsings already in Minimal.AppServices
|
|
20
|
+
|
|
21
|
+
`Minimal.AppServices/GlobalUsings.cs` gives every file in the project these without an explicit
|
|
22
|
+
`using`: `DKNet.AspCore.Extensions.ModelBinding` (`[FromClaim]`), `DKNet.SlimBus.Extensions`
|
|
23
|
+
(`Fluents.*`, `NotFoundError`), `System.ComponentModel.DataAnnotations`, `System.Security.Claims`
|
|
24
|
+
(`ClaimTypes`), `System.Text.Json.Serialization`, `FluentResults`, `Minimal.Domains.Services`,
|
|
25
|
+
`Microsoft.Extensions.DependencyInjection`, `FluentValidation`, `Mapster`, `MapsterMapper`,
|
|
26
|
+
`Minimal.AppServices.Extensions`, `DKNet.SlimBus.Extensions.LazyMapper`, `Minimal.AppServices.Share`
|
|
27
|
+
(`PreconditionCodes`, `IPrincipalProvider`). An action file typically adds only the entity's and its
|
|
28
|
+
own spec's namespace.
|
|
29
|
+
|
|
30
|
+
## Request and handler contracts
|
|
31
|
+
|
|
32
|
+
| Shape | Request | Handler | Return |
|
|
33
|
+
|---|---|---|---|
|
|
34
|
+
| Command returning a DTO | `Fluents.Requests.IWitResponse<TDto>` | `Fluents.Requests.IHandler<TReq, TDto>` | `Task<IResult<TDto>>` |
|
|
35
|
+
| Command with no response | `Fluents.Requests.INoResponse` | `Fluents.Requests.IHandler<TReq>` | `Task<IResultBase>` |
|
|
36
|
+
|
|
37
|
+
The handler method is `OnHandle(TRequest, CancellationToken)`, never `Handle`. Both are discovered
|
|
38
|
+
by assembly scan onto the in-memory bus — no per-message registration. Handlers never call
|
|
39
|
+
`SaveChanges`: the SlimBus EF Core interceptor auto-saves once, after the handler returns. A request
|
|
40
|
+
record is `public sealed record`; its validator and handler are `internal sealed class` — enforced
|
|
41
|
+
by `Minimal.App.Tests/Architecture/AppServiceTests.cs`.
|
|
42
|
+
|
|
43
|
+
File layout: one file per verb, request + validator + handler co-located —
|
|
44
|
+
`<Feature>/V1/Actions/<Verb>.cs` (`ManualSample/V1/Actions/Create.cs`, `Update.cs`, `Cancel.cs`,
|
|
45
|
+
`Delete.cs`).
|
|
46
|
+
|
|
47
|
+
## Acting user
|
|
48
|
+
|
|
49
|
+
`mode=manual`: a `[FromClaim(ClaimTypes.Name)] public string? ByUser { get; set; }` property.
|
|
50
|
+
`AddContextualRequestPopulation` (wired once in `Program.cs`) overwrites it from the caller's claim
|
|
51
|
+
before validation and before the handler runs — a value the caller put in the body is always
|
|
52
|
+
discarded, never trusted. The handler still guards it:
|
|
53
|
+
|
|
54
|
+
```csharp
|
|
55
|
+
if (string.IsNullOrEmpty(request.ByUser))
|
|
56
|
+
{
|
|
57
|
+
return Result.Fail<PurchaseOrderDto>("The caller is not authenticated.");
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The demo authentication provider (`FeatureManagement:EnableDemoAuthentication`) supplies this claim
|
|
62
|
+
locally and in tests, so the guard is reachable but rarely hit — an authenticated caller with a
|
|
63
|
+
missing name claim still has to fail it explicitly, there is no fallback to a system account.
|
|
64
|
+
|
|
65
|
+
`mode=auto`: a generated request never carries `[FromClaim]` — the generator forwards only
|
|
66
|
+
`System.ComponentModel.DataAnnotations` attributes, and `[FromClaim]` lives in
|
|
67
|
+
`DKNet.AspCore.Extensions.ModelBinding`. Instead `DataOwnerHook` stamps `OwnedBy` and the audit hook
|
|
68
|
+
stamps `CreatedBy`/`UpdatedBy`, both from `IPrincipalProvider`, wired once in
|
|
69
|
+
`ServiceConfigs.AddAllAppServices` (`.AddDataOwnerProvider<CoreDbContext, PrincipalProvider>()` /
|
|
70
|
+
`.AddCurrentUserProvider<CoreDbContext, PrincipalProvider>()`) and applying to every entity on
|
|
71
|
+
`CoreDbContext`, not just the one you're adding.
|
|
72
|
+
|
|
73
|
+
**The `[CrudAction]` acting-user pitfall.** A method parameter literally named `byUser` is still
|
|
74
|
+
just a constructor/method parameter to the generator — it becomes a caller-settable, body-bound
|
|
75
|
+
property:
|
|
76
|
+
|
|
77
|
+
```csharp
|
|
78
|
+
[CrudAction("approval")]
|
|
79
|
+
public void Approve(string byUser) => SetUpdatedBy(byUser);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
generates `public required string ByUser { get; init; }` on `ApproveProductRequest`, bound from the
|
|
83
|
+
JSON body (`{ "byUser": "alice" }`). Nothing stamps it from the caller's identity — whatever the
|
|
84
|
+
client sends is what `SetUpdatedBy` receives. Don't name a `[CrudCreate]`/`[CrudUpdate]`/
|
|
85
|
+
`[CrudAction]` parameter after an acting-user concept; if a method needs the real caller, stamp it
|
|
86
|
+
from `DataOwnerHook`/the audit hook instead, or hand-write the action.
|
|
87
|
+
|
|
88
|
+
## FluentValidation
|
|
89
|
+
|
|
90
|
+
A validator is `internal sealed class XValidator : AbstractValidator<TRequest>`, co-located with
|
|
91
|
+
its request. There is no per-route opt-in call: `Minimal.Api/Configs/FluentValidationConfig.cs`
|
|
92
|
+
registers every validator in the assembly —
|
|
93
|
+
|
|
94
|
+
```csharp
|
|
95
|
+
builder.Services.AddValidatorsFromAssembly(typeof(AppSetup).Assembly, includeInternalTypes: true);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
— and `UseEndpointConfigs` attaches `AddFluentValidationAutoValidation()` to **every** endpoint
|
|
99
|
+
group (`Program.cs`), so it runs on generated routes exactly as it runs on hand-mapped ones. A
|
|
100
|
+
request failing validation never reaches the handler; it short-circuits to `400` (or `409`, see
|
|
101
|
+
below) with no handler code involved. Two hard constraints: the validator class must live in
|
|
102
|
+
`Minimal.AppServices` (one in `Minimal.Api` is never registered), and it must be `internal sealed`.
|
|
103
|
+
|
|
104
|
+
**Ordinary input rules** stay inline (`RuleFor(a => a.Amount).GreaterThan(0)`). Keep `Id` out of an
|
|
105
|
+
update validator — an unknown/empty id is a `404` from the handler's spec lookup, not a `400` from
|
|
106
|
+
validation (`UpdatePurchaseOrderCommandValidator`'s comment says this explicitly).
|
|
107
|
+
|
|
108
|
+
**Precondition validators** read stored data through `IRepositorySpec` and refuse with a code:
|
|
109
|
+
|
|
110
|
+
```csharp
|
|
111
|
+
internal sealed class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
|
|
112
|
+
{
|
|
113
|
+
public CreateProductRequestValidator(IRepositorySpec repository)
|
|
114
|
+
{
|
|
115
|
+
RuleFor(r => r.Name)
|
|
116
|
+
.MustAsync(async (name, ct) => !await repository.AnyAsync(new SpecProductByName(name), ct))
|
|
117
|
+
.WithErrorCode(PreconditionCodes.ProductNameTaken)
|
|
118
|
+
.WithMessage(r => $"The product name '{r.Name}' is already taken.");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Any code prefixed `PreconditionCodes.Prefix` (`"precondition."`) makes
|
|
124
|
+
`FluentValidationConfig`'s `AddErrorResponses` answer `409` instead of `400`, with that code echoed
|
|
125
|
+
in the response body's `code` extension. This is a check, not a guarantee — the database's unique
|
|
126
|
+
index is what actually enforces it under a race.
|
|
127
|
+
|
|
128
|
+
**Keep 404 out of validators.** `DeleteProductRequestValidator` deliberately *passes* an unknown id:
|
|
129
|
+
|
|
130
|
+
```csharp
|
|
131
|
+
RuleFor(r => r.Id)
|
|
132
|
+
.MustAsync(async (id, ct) =>
|
|
133
|
+
{
|
|
134
|
+
var product = await repository.FirstOrDefaultAsync(new SpecGetProduct(id), ct);
|
|
135
|
+
return product is null || product.IsDiscontinued;
|
|
136
|
+
})
|
|
137
|
+
.WithErrorCode(PreconditionCodes.ProductDeleteWhileForSale)
|
|
138
|
+
.WithMessage("The product is still for sale and cannot be deleted.");
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
If it refused a missing id too, the route's own `404` would be hidden behind a `409`.
|
|
142
|
+
|
|
143
|
+
**Validators on generated requests are the supported way to add a business rule to a generated
|
|
144
|
+
route.** `CreateProductRequestValidator` and `DeleteProductRequestValidator` both target generated
|
|
145
|
+
request types (`Minimal.AppServices.Crud.CreateProductRequest` / `DeleteProductRequest`) — no
|
|
146
|
+
literal `Map*` call is needed for FluentValidation to reach them, only the `using
|
|
147
|
+
Minimal.AppServices.Crud;` and a constructor parameter naming the request type. This is the fix for
|
|
148
|
+
the one thing `[DataAnnotations]` cannot do on a generated route: `[Range]`/`[Required]`/etc.
|
|
149
|
+
forwarded onto a generated request property is **not evaluated**, because .NET's validation source
|
|
150
|
+
generator only recognizes literal `Map*(string, Delegate)` calls in the compiling project's own
|
|
151
|
+
source — true for `PurchaseOrderV1Endpoint`'s hand-mapped routes, false for anything routed through
|
|
152
|
+
`DKNet.AspCore.Extensions`'s generic `Map*<TRequest,TDto>` wrapper, which is every generated CRUD
|
|
153
|
+
route. `POST /v1/products` with `price: -1` returns `201`, not `400`, despite
|
|
154
|
+
`CreateProductRequest.Price` carrying `[Range(0.01, double.MaxValue)]`. Don't assume a
|
|
155
|
+
DataAnnotations attribute on a generated request is enforced; write a validator for any rule you
|
|
156
|
+
actually need.
|
|
157
|
+
|
|
158
|
+
## Handler patterns
|
|
159
|
+
|
|
160
|
+
**Create** (`ManualSample/V1/Actions/Create.cs`) — construct the aggregate through its own
|
|
161
|
+
constructor, add it, return a lazily-mapped result so the response carries the DB-generated `Id`:
|
|
162
|
+
|
|
163
|
+
```csharp
|
|
164
|
+
var order = new PurchaseOrder(request.CustomerName, request.Amount, request.ByUser);
|
|
165
|
+
await repository.AddAsync(order, cancellationToken);
|
|
166
|
+
return mapper.ResultOf<PurchaseOrderDto>(order);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Update / business transition** (`Update.cs`) — fetch via spec, `404` if missing, mutate through
|
|
170
|
+
a domain method, map eagerly since nothing on the DTO needs a value only `SaveChanges` produces:
|
|
171
|
+
|
|
172
|
+
```csharp
|
|
173
|
+
var order = await repository.FirstOrDefaultAsync(new SpecGetPurchaseOrder(request.Id), cancellationToken);
|
|
174
|
+
if (order is null)
|
|
175
|
+
return Result.Fail<PurchaseOrderDto>(new NotFoundError($"The purchase order {request.Id} was not found."));
|
|
176
|
+
order.ChangeAmount(request.Amount, request.ByUser);
|
|
177
|
+
return Result.Ok(mapper.Map<PurchaseOrderDto>(order));
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Rejected transition** (`Cancel.cs`) — a domain-specific `409`, distinct from the generic
|
|
181
|
+
`NotFoundError` `404`:
|
|
182
|
+
|
|
183
|
+
```csharp
|
|
184
|
+
if (order.Status == PurchaseOrderStatus.Cancelled)
|
|
185
|
+
return Result.Fail<PurchaseOrderDto>(
|
|
186
|
+
new Error($"The purchase order {request.Id} is already cancelled.")
|
|
187
|
+
.WithMetadata("Code", PreconditionCodes.PurchaseOrderAlreadyCancelled));
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`.WithMetadata("Code", ...)` is the handler-side equivalent of `.WithErrorCode(...)` in a
|
|
191
|
+
validator — same `PreconditionCodes.Prefix` check drives the same `409`.
|
|
192
|
+
|
|
193
|
+
**Delete** (`Delete.cs`, `INoResponse`) — fetch, `404` if missing, `repository.Delete(order)`,
|
|
194
|
+
`Result.Ok()`. No DTO, no mapper.
|
|
195
|
+
|
|
196
|
+
**Multi-aggregate command** (`AutomatedSample/V1/Actions/Discontinue.cs`) — one handler, two writes,
|
|
197
|
+
one `SaveChanges`:
|
|
198
|
+
|
|
199
|
+
```csharp
|
|
200
|
+
public sealed record DiscontinueProductCommand : Fluents.Requests.IWitResponse<ProductDto>
|
|
201
|
+
{
|
|
202
|
+
public Guid Id { get; init; } // not `required` — bound via `req with { Id = id }`
|
|
203
|
+
[Required, StringLength(150)] public string ReplacementName { get; init; } = null!;
|
|
204
|
+
[Range(0.01, double.MaxValue)] public decimal ReplacementPrice { get; init; }
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```csharp
|
|
209
|
+
product.Discontinue();
|
|
210
|
+
var replacement = new Product(request.ReplacementName, request.ReplacementPrice);
|
|
211
|
+
await repository.AddAsync(replacement, cancellationToken);
|
|
212
|
+
return mapper.ResultOf<ProductDto>(product);
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Named `...Command`, not `...Request`: `Product.Discontinue()` keeps its own `[CrudAction]`
|
|
216
|
+
declaration (so the generator still has a route to drop by name), which reserves the generated
|
|
217
|
+
`DiscontinueProductRequest`/`DiscontinueProductHandler` names — a second, hand-written type by
|
|
218
|
+
either name would collide. `Id` is deliberately not `required`: the request body from a client never
|
|
219
|
+
carries `id` (it comes from the route), and `required` would make System.Text.Json reject that body
|
|
220
|
+
outright; the endpoint supplies it with `req with { Id = id }`. This kind of command cannot be
|
|
221
|
+
generated at all — an operation writing more than one aggregate in one transaction is outside what
|
|
222
|
+
`[CrudUpdate]`/`[CrudAction]` can express — so it stays fully hand-written even though the entity is
|
|
223
|
+
otherwise generator-driven.
|
|
224
|
+
|
|
225
|
+
## Generated requests and handlers (`mode=auto`)
|
|
226
|
+
|
|
227
|
+
Naming, mechanical from the entity's own signatures: `Create<Entity>Request` (from `[CrudCreate]`),
|
|
228
|
+
`Change<Member><Entity>Request` (from a `[CrudUpdate]` method named `Change<Member>`),
|
|
229
|
+
`<Method><Entity>Request` (from `[CrudAction]`), `Delete<Entity>Request` (always). Handlers mirror
|
|
230
|
+
the name with `Handler` instead of `Request` — `CreateProductHandler`, `ChangePriceProductHandler`,
|
|
231
|
+
`ApproveProductHandler`, `DiscontinueProductHandler`, `AssignSupplierReferenceProductHandler` — all
|
|
232
|
+
in namespace `Minimal.AppServices.Crud`, emitted under
|
|
233
|
+
`obj/Generated/DKNet.SlimBus.Generators/.../<Entity>CrudRequests.g.cs` and `...Handlers.g.cs`. They
|
|
234
|
+
are compiler output, not files in the repo — inspect them after a build, not by searching source.
|
|
235
|
+
|
|
236
|
+
A generated update handler, verbatim (`ProductCrudHandlers.g.cs`):
|
|
237
|
+
|
|
238
|
+
```csharp
|
|
239
|
+
internal sealed class ChangePriceProductHandler(IRepositorySpec repository, IMapper mapper)
|
|
240
|
+
: Fluents.Requests.IHandler<ChangePriceProductRequest, ProductDto>
|
|
241
|
+
{
|
|
242
|
+
public async Task<IResult<ProductDto>> OnHandle(ChangePriceProductRequest request, CancellationToken cancellationToken)
|
|
243
|
+
{
|
|
244
|
+
var entity = await repository.FirstOrDefaultAsync(new ProductByIdCrudSpec(request.Id), cancellationToken);
|
|
245
|
+
if (entity is null)
|
|
246
|
+
return Result.Fail<ProductDto>(new NotFoundError($"Product '{request.Id}' was not found."));
|
|
247
|
+
entity.ChangePrice(request.Price);
|
|
248
|
+
return mapper.ResultOf<ProductDto>(entity);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Fetch by a private, generated `ProductByIdCrudSpec`, `404` if missing, call the one domain method,
|
|
254
|
+
`ResultOf`. This is exactly the shape of a hand-written Update handler above, generated for you.
|
|
255
|
+
|
|
256
|
+
**Replacing a generated handler.** The generated file's own doc comment says how: "Write a class
|
|
257
|
+
implementing the same IHandler to replace it." Add your own `internal sealed class` implementing
|
|
258
|
+
`Fluents.Requests.IHandler<ChangePriceProductRequest, ProductDto>` in `Minimal.AppServices` — DI
|
|
259
|
+
registration is by interface via assembly scan, so yours and the generated one cannot coexist; keep
|
|
260
|
+
only yours (there is no attribute to suppress generation of the handler alone — drop the whole
|
|
261
|
+
route with `CrudMapOptions.Exclude` if you also need to change the request shape).
|
|
262
|
+
|
|
263
|
+
**Adding a rule without leaving the generated route**: write a validator, as above.
|
|
264
|
+
|
|
265
|
+
**The generated request records are `partial`.** You may declare a member in your own partial
|
|
266
|
+
`record CreateProductRequest { ... }`, but the generated handler never reads it — it only
|
|
267
|
+
constructs the entity from the parameters the generator saw on `[CrudCreate]`. A partial addition is
|
|
268
|
+
for a validator or a Mapster customization to read, not a way to pass extra data into the entity
|
|
269
|
+
constructor.
|
|
270
|
+
|
|
271
|
+
## Error → HTTP mapping
|
|
272
|
+
|
|
273
|
+
| Outcome | Status | Body |
|
|
274
|
+
|---|---|---|
|
|
275
|
+
| `Result.Ok(dto)` | `200`, or `201` on a create route with `isCreated: true` | the DTO |
|
|
276
|
+
| `Result.Fail(new NotFoundError(...))` | `404` | `errors: [{ message }]` |
|
|
277
|
+
| `Result.Fail(...)`/validator error with a `precondition.`-prefixed code | `409` | `errors: [...]`, `code` = that code |
|
|
278
|
+
| Any other `Result.Fail(...)` or FluentValidation failure | `400` | `errors: [{ message, code?, field? }]` |
|
|
279
|
+
| Unhandled `OwnershipRequiredException` | `403` | `errors: [{ message }]` |
|
|
280
|
+
| Any other unhandled exception | `500` | generic message outside `Development`, `traceId` always present |
|
|
281
|
+
|
|
282
|
+
Every body shares `title: "Error"`, `status`, `type` (the status's name, e.g. `"Conflict"`), and
|
|
283
|
+
`traceId` (the current `Activity` id, falling back to `TraceIdentifier`). One registration answers
|
|
284
|
+
all three failure kinds — `Minimal.Api/Configs/FluentValidationConfig.cs`'s single
|
|
285
|
+
`AddErrorResponses(...)` call — there is no second place to configure this.
|
|
286
|
+
|
|
287
|
+
## Architecture rules enforced by tests
|
|
288
|
+
|
|
289
|
+
`Minimal.App.Tests/Architecture/AppServiceTests.cs` and `RecordArchitectureTests.cs` pin, exactly:
|
|
290
|
+
|
|
291
|
+
- Every class implementing `IRequestHandler<>`/`IRequestHandler<,>`/`IConsumer<>` must be
|
|
292
|
+
non-public and `sealed` (`AllHandlerClassesShouldBeInternalAndSealed`).
|
|
293
|
+
- Every non-abstract class inheriting `AbstractValidator<>` must be non-public and `sealed`
|
|
294
|
+
(`AllValidatorClassesShouldBeInternalAndSealed` — two hand-authored customer/order sample
|
|
295
|
+
validators are exempted by name; a new feature's validators are not).
|
|
296
|
+
- Any interface named `*Repo`/`*Repository` must inherit `IRepository<TEntity>`
|
|
297
|
+
(`AllRepoInterfaces_ShouldInheritFromIRepository`).
|
|
298
|
+
- A `[GenerateDto]` type must expose no property whose type (or generic argument) inherits
|
|
299
|
+
`DomainEntity` (`DtosWithGenerateDtoAttribute_ShouldNotHaveProperties_ThatAreDomainEntities`) —
|
|
300
|
+
the DTO boundary must not leak an entity.
|
|
301
|
+
- No record type in `Minimal.Domains` or `Minimal.AppServices` may declare a public property with a
|
|
302
|
+
**private** setter (`RecordTypes_ShouldNotContain_PrivateSetters_OnPublicProperties`) — AutoMapper
|
|
303
|
+
(Mapster's `MapToConstructor`/property assignment) cannot fill it.
|
|
304
|
+
|
|
305
|
+
## Step-by-step
|
|
306
|
+
|
|
307
|
+
**mode=manual** (mirror `ManualSample/PurchaseOrder`):
|
|
308
|
+
|
|
309
|
+
1. Add `<Feature>/V1/Actions/Create.cs`: request implementing `IWitResponse<TDto>` with
|
|
310
|
+
`[FromClaim(ClaimTypes.Name)] ByUser`, a co-located validator, a handler constructing the
|
|
311
|
+
aggregate and returning `mapper.ResultOf<TDto>(entity)`.
|
|
312
|
+
2. Add `Update.cs` / a named business-transition file: request with `Id` (route-bound) and the
|
|
313
|
+
mutable fields, a validator that skips `Id`, a handler that fetches via spec, 404s, calls a
|
|
314
|
+
domain method, returns `Result.Ok(mapper.Map<TDto>(entity))`.
|
|
315
|
+
3. Add `Delete.cs`: `INoResponse` request, handler fetches, 404s, `repository.Delete(entity)`,
|
|
316
|
+
`Result.Ok()`.
|
|
317
|
+
4. Wire each into `<Feature>V1Endpoint.cs` with a literal `Map*` call (`dknet-endpoint`) so
|
|
318
|
+
DataAnnotations validation, if any, is actually enforced.
|
|
319
|
+
|
|
320
|
+
**mode=auto** (mirror `AutomatedSample/Product`):
|
|
321
|
+
|
|
322
|
+
1. Confirm the entity already carries `[CrudCreate]` on its constructor and `[CrudUpdate]`/
|
|
323
|
+
`[CrudAction]` on its mutation methods (`dknet-entity`, `dknet-crud`).
|
|
324
|
+
2. Build once; read the generated request/handler/endpoint files to know the exact contract.
|
|
325
|
+
3. Add any business rule as a validator against the generated request type — never a hand-mapped
|
|
326
|
+
route just to enforce a `[Range]`/`[Required]` attribute.
|
|
327
|
+
4. For an operation that must write more than one aggregate, or must reject a repeat call as a
|
|
328
|
+
domain failure rather than a `200`, write a `...Command` (not `...Request`) and drop the
|
|
329
|
+
generated route for that member with `CrudMapOptions.Exclude("MethodName")` in the endpoint.
|
|
330
|
+
|
|
331
|
+
## Validation checklist
|
|
332
|
+
|
|
333
|
+
- Request is `public sealed record`; validator and handler are `internal sealed class`.
|
|
334
|
+
- `[FromClaim(ClaimTypes.Name)]` only on a hand-written request — never named onto a
|
|
335
|
+
`[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]` parameter.
|
|
336
|
+
- Handler guards `IsNullOrEmpty(request.ByUser)` (manual mode) before doing anything else.
|
|
337
|
+
- No `SaveChanges` call anywhere in the handler.
|
|
338
|
+
- A precondition rule uses `.WithErrorCode(PreconditionCodes.X)` (validator) or
|
|
339
|
+
`.WithMetadata("Code", PreconditionCodes.X)` (handler `Error`), not a bare `Result.Fail(string)`.
|
|
340
|
+
- A validator that must let an unknown id through to the route's own `404` does so explicitly.
|
|
341
|
+
- `mapper.ResultOf<TDto>(entity)` after a write whose DTO needs a DB-generated value;
|
|
342
|
+
`mapper.Map<TDto>(entity)` otherwise.
|
|
343
|
+
- A rule for a generated request lives in a validator, not a DataAnnotations attribute you're
|
|
344
|
+
hoping gets enforced.
|
|
345
|
+
|
|
346
|
+
## Common mistakes
|
|
347
|
+
|
|
348
|
+
- **What you might expect**: adding `[Range(0.01, double.MaxValue)]` to a `[CrudUpdate]` parameter
|
|
349
|
+
enforces it, since the generated request clearly carries the attribute.
|
|
350
|
+
**What actually happens**: `PUT` with a negative value still returns `200`.
|
|
351
|
+
**Why**: the generated route is mapped through a generic library wrapper the .NET validation
|
|
352
|
+
source generator cannot see into; only a validator runs on that route.
|
|
353
|
+
|
|
354
|
+
- **What you might expect**: a `[CrudAction]` method parameter called `byUser` is populated the
|
|
355
|
+
same way `[FromClaim]` populates a hand-written request.
|
|
356
|
+
**What actually happens**: the generated request exposes `ByUser` as an ordinary
|
|
357
|
+
caller-supplied, required body field.
|
|
358
|
+
**Why**: the generator forwards only `DataAnnotations` attributes; `[FromClaim]` is a different
|
|
359
|
+
namespace it never looks at.
|
|
360
|
+
|
|
361
|
+
- **What you might expect**: a validator refusing an unknown id in a delete/update route is the
|
|
362
|
+
safer default.
|
|
363
|
+
**What actually happens**: the route now answers `409` for a target that doesn't exist, instead
|
|
364
|
+
of the expected `404`.
|
|
365
|
+
**Why**: FluentValidation runs before the handler; a `MustAsync` that fails closed on "not found"
|
|
366
|
+
hides the handler's own 404 behind a validation-layer 409.
|
|
367
|
+
|
|
368
|
+
- **What you might expect**: replacing a generated handler means adding an attribute to suppress
|
|
369
|
+
generation.
|
|
370
|
+
**What actually happens**: there is no such attribute — both classes would be registered, and DI
|
|
371
|
+
resolution becomes ambiguous.
|
|
372
|
+
**Why**: the generator has no per-handler opt-out; write your own class implementing the same
|
|
373
|
+
`IHandler<TRequest, TDto>` and remove nothing from the entity, or drop the whole route with
|
|
374
|
+
`CrudMapOptions.Exclude` if the request shape itself must change.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
# Workflow: `/dknet-crud`
|
|
379
|
+
|
|
380
|
+
The procedure an agent follows when invoked with arguments. The reference sections above are the rules it applies.
|
|
381
|
+
|
|
382
|
+
You are scaffolding the **AppServices** layer for an aggregate that already has Domain + Infra wiring. Run `/dknet-entity` first if the entity does not exist yet.
|
|
383
|
+
|
|
384
|
+
The `mode` argument selects the path. It must match the mode `/dknet-entity` ran in — `auto` requires
|
|
385
|
+
the entity to already carry `[CrudCreate]`/`[CrudUpdate]`/`[RaisesEvent]`, and there is nothing to
|
|
386
|
+
generate without them. If `mode=` was not supplied, detect it: grep the entity for `[CrudCreate]`;
|
|
387
|
+
present it means `auto`, absent means `manual`. Say which you detected.
|
|
388
|
+
|
|
389
|
+
Pick one per aggregate, don't mix them for the same entity:
|
|
390
|
+
|
|
391
|
+
- **Hand-written** (below) — every request/validator/handler/spec/DTO is a file you write. Needed whenever the aggregate has a business rule beyond DataAnnotations, a filtered query, idempotent writes, or a DTO that must hide fields.
|
|
392
|
+
- **Declarative CRUD generation** (further down) — `[CrudCreate]`/`[CrudUpdate]`/`[GenerateDto]` on the entity itself; `DKNet.SlimBus.Generators` produces the request/handler/route types for you. Only for genuinely plain CRUD — read the validation-gap caveat before choosing it.
|
|
393
|
+
|
|
394
|
+
The `dknet-feature-lifecycle` skill §1 is the authoritative comparison between the two; use it to decide.
|
|
395
|
+
|
|
396
|
+
### Inputs
|
|
397
|
+
|
|
398
|
+
`$ARGUMENTS` — feature slice folder (e.g. `ManualSample`), entity (`PurchaseOrder`), optional
|
|
399
|
+
`mode=manual|auto`, optional API version (defaults to `V1`).
|
|
400
|
+
|
|
401
|
+
The `dknet-feature-lifecycle` skill §1 is the decision procedure if the mode is still open.
|
|
402
|
+
|
|
403
|
+
### Path 1: Hand-written CRUD (`mode=manual`)
|
|
404
|
+
|
|
405
|
+
#### Required reading
|
|
406
|
+
|
|
407
|
+
1. The reference sections above
|
|
408
|
+
2. `ApiEndpoints/Minimal.AppServices/ManualSample/V1/` (exemplar: `Actions/{Create,Update,Cancel,Delete}.cs`, `Specs/SpecGetPurchaseOrder.cs`, `Queries/{GetPurchaseOrderById,ListPurchaseOrders}.cs`, `Events/`, `PurchaseOrderDto.cs`)
|
|
409
|
+
|
|
410
|
+
#### Steps
|
|
411
|
+
|
|
412
|
+
1. Use the `dknet-implementer` subagent to execute Step 5 of the implementer protocol:
|
|
413
|
+
- Hand-written response DTO record — no `[GenerateDto]` — exposing exactly the fields the API should return (see `PurchaseOrderDto`).
|
|
414
|
+
- `Create<Entity>Request` (`Fluents.Requests.IWitResponse<TDto>`, `[FromClaim(ClaimTypes.Name)] ByUser` for the acting user — never trust a payload value) + `AbstractValidator` + `internal sealed` handler that constructs the aggregate (which raises its own event via `AddEvent(...)` in its constructor) and calls `IRepositorySpec.AddAsync`, returning `mapper.ResultOf<TDto>(entity)` (lazy mapping).
|
|
415
|
+
- `Update<Entity>Request` + handler that fetches via `SpecGet<Entity>` (404 via `NotFoundError` on miss) and calls the entity's mutation method.
|
|
416
|
+
- `Delete<Entity>Request` (`Fluents.Requests.INoResponse`) + handler, same fetch-then-404 shape.
|
|
417
|
+
- Any business-action request (e.g. `Cancel`) that rejects an invalid state transition with a `Result.Fail(...)` — mirror `CancelPurchaseOrderRequest`/`CancelPurchaseOrderCommandHandler` rejecting an already-cancelled order.
|
|
418
|
+
- `SpecGet<Entity>` query specification — remember an unfiltered predicate builder needs at least one `.And()`/`.Or()` call to avoid compiling to `WHERE FALSE` (see `SpecGetPurchaseOrder`'s explicit `predicator.And(_ => true)` fallback).
|
|
419
|
+
- Domain event record + in-memory event handler (only if the plan calls for one beyond what the constructor already raises).
|
|
420
|
+
2. Build: `dotnet build -c Release`. Fix any analyzer/warning errors before continuing.
|
|
421
|
+
3. Report the mode used, files created, and the next command (`/dknet-endpoint <Feature> <Entity> mode=manual`).
|
|
422
|
+
|
|
423
|
+
#### Constraints
|
|
424
|
+
|
|
425
|
+
- Handlers, validators, specs, event handlers MUST be `internal sealed`.
|
|
426
|
+
- Use `IRepositorySpec` — never introduce a custom repo interface.
|
|
427
|
+
- Auto-fields the client must not set: `[FromClaim(...)]` on the request property, always overwritten by the endpoint from the authenticated caller.
|
|
428
|
+
- Create handler returns `mapper.ResultOf<TDto>(entity)` (lazy mapping).
|
|
429
|
+
- Do not modify endpoint files in this command.
|
|
430
|
+
|
|
431
|
+
### Path 2: Declarative CRUD generation (`mode=auto`)
|
|
432
|
+
|
|
433
|
+
For a genuinely plain CRUD entity, declare the CRUD surface on the entity instead of writing it. Exemplar: `Product` (`ApiEndpoints/Minimal.Domains/Features/AutomatedSample/Entities/Product.cs`, `ApiEndpoints/Minimal.AppServices/AutomatedSample/V1/ProductDto.cs`).
|
|
434
|
+
|
|
435
|
+
Most of the entity-side attributes are `/dknet-entity mode=auto`'s job. This command's own output at
|
|
436
|
+
the AppServices layer is small on purpose: **one `[GenerateDto]` line, plus any hand-written event
|
|
437
|
+
consumer.** If you find yourself writing a request, validator, or handler here, the mode is wrong.
|
|
438
|
+
|
|
439
|
+
#### What you write
|
|
440
|
+
|
|
441
|
+
- `[RaisesEvent(EventOperations.Created, Include=[nameof(Id), ...])]` and/or `[RaisesEvent(EventOperations.Updated, nameof(Prop))]` at the class level — replaces a hand-written event record + `AddEvent(...)` call. The composed event-record name is `<Entity><NarrowingProps><Operation>Event` — e.g. `[RaisesEvent(EventOperations.Updated, nameof(Price))]` on `Product` composes `ProductPriceUpdatedEvent`, **not** `ProductUpdatedEvent`. Verify the exact composed name against the compiled assembly before wiring a consumer — there is no hand-written source file for it.
|
|
442
|
+
- `[CrudCreate]` on the entity's constructor — its parameter list becomes the generated create request's payload (DataAnnotations attributes on the parameters, e.g. `[Required][StringLength(150)]`, forward onto the generated request property).
|
|
443
|
+
- `[CrudUpdate]` on a mutation method — same forwarding for its parameter(s).
|
|
444
|
+
- `[CrudAction("segment")]` on a domain-action method — publishes a route at the by-id path plus a
|
|
445
|
+
segment, returning `200` + the entity DTO. The default verb is POST and the default segment is
|
|
446
|
+
derived from the method name; `[CrudAction(Verb = CrudActionVerb.Put)]` overrides the verb.
|
|
447
|
+
`Product.Approve` (segment override) and `Product.AssignSupplierReference` (both overridden) are
|
|
448
|
+
the shipped exemplars. A generated action's handler on its own is a no-op `200` when the entity is
|
|
449
|
+
already in that state — but the request is body-bound and passes the group-level
|
|
450
|
+
`AddFluentValidationAutoValidation()` filter, so a **pre-condition belongs in a FluentValidation
|
|
451
|
+
validator** on the generated request, which can read stored data and refuse (the product sample
|
|
452
|
+
answers `409` that way on create and delete). A pre-condition is therefore never a reason to leave
|
|
453
|
+
the generated map — hand-write the route only when the operation writes **more than one
|
|
454
|
+
aggregate in one transaction**, which the generator cannot express. Then drop that one route by
|
|
455
|
+
name (`o.Exclude("<MethodName>")` on `MapProductCrud`) and hand-write it below the generated call.
|
|
456
|
+
`Product.Discontinue` is the shipped case: it also creates the product's named replacement in the
|
|
457
|
+
same transaction. The rest of the entity's routes stay generated; there is no need to fall back to
|
|
458
|
+
Path 1 for the whole feature.
|
|
459
|
+
- `[GenerateDto(typeof(Entity))] public sealed partial record <Entity>Dto;` — one line, generates every audited property by default (`Exclude`/`Include` to narrow).
|
|
460
|
+
|
|
461
|
+
#### What gets generated
|
|
462
|
+
|
|
463
|
+
`DKNet.SlimBus.Generators` produces (namespace `Minimal.AppServices.Crud`, not committed — inspect via `dotnet build` then `obj/Generated/DKNet.SlimBus.Generators/`):
|
|
464
|
+
|
|
465
|
+
- `Create<Entity>Request` / `Change<Member><Entity>Request` (named after the `[CrudUpdate]` method, e.g. `ChangePriceProductRequest`) + matching `internal sealed` handlers (`Create<Entity>Handler` / `Change<Member><Entity>Handler`) — no hand-written request/validator/handler exists for these.
|
|
466
|
+
- `<Entity>CrudEndpointExtensions.Map<Entity>Crud()` — GetById/GetList/Delete map straight to `DKNet.AspCore.Extensions`'s generic `MapGetById<TEntity,TKey,TDto>`/`MapGetList`/`MapDeleteById`; Create/Update use the generated handlers above.
|
|
467
|
+
|
|
468
|
+
#### Constraints and the validation-gap caveat
|
|
469
|
+
|
|
470
|
+
- Do NOT hand-write a request or handler for a `[CrudCreate]`/`[CrudUpdate]` member while its route is still generated — that defeats the point of the generator. A **validator** is the exception and the supported shape: a FluentValidation validator for the generated request runs on the generated route through the group filter. Exclude a route by name (`CrudMapOptions.Exclude(string)`) and hand-write it below the `Map<Entity>Crud(...)` call only when the operation writes more than one aggregate in one transaction. Generated and hand-written routes coexisting in one endpoint is the shipped shape, not a smell — see `ProductV1Endpoint`.
|
|
471
|
+
- **Validation gap, confirmed live**: a `[Range]`/`[Required]` on a `[CrudCreate]`/`[CrudUpdate]` parameter *is* forwarded onto the generated request property, but it is **never enforced** under this template's endpoint-registration convention — the .NET 10 validation source generator only recognizes literal `Map*(string, Delegate)` calls, and the generated route goes through `DKNet.AspCore.Extensions`'s generic `MapPost<TRequest,TDto>` wrapper instead. `POST /v1/products` with a negative price returns `201`, not `400`. Do not present a DataAnnotations attribute on a generated request as enforced without checking the endpoint's mapping style.
|
|
472
|
+
- Acting-user attribution cannot use `[FromClaim]` on a generated request (the generator forwards only `System.ComponentModel.DataAnnotations` attributes) — it goes through `DKNet.EfCore.DataAuthorization`'s `DataOwnerHook` instead, wired once in `Minimal.Api/Configs/ServiceConfigs.cs`, not per-entity.
|
|
473
|
+
- No idempotency key support on the generated create route — see `/dknet-endpoint`'s "Alternative: generated CRUD route" section if the feature needs it.
|
|
474
|
+
|
|
475
|
+
#### Steps
|
|
476
|
+
|
|
477
|
+
1. Add the attributes to the entity and the one-line `[GenerateDto]` DTO (Domain + AppServices layers together — there's no separate scaffolding step).
|
|
478
|
+
2. Build: `dotnet build -c Release`, then inspect `obj/Generated/DKNet.SlimBus.Generators/` to confirm the expected types were produced.
|
|
479
|
+
3. Report the mode used, the generated type names, and the next command
|
|
480
|
+
(`/dknet-endpoint <Feature> <Entity> mode=auto`, which for this path is just `group.Map<Entity>Crud()`).
|
|
481
|
+
|
|
482
|
+
An empty `obj/Generated/DKNet.SlimBus.Generators/` after a green build means the attributes did not
|
|
483
|
+
take — STOP and fix the entity rather than hand-writing the missing pieces.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dknet-ddd-principles
|
|
3
|
+
description: DDD tactical judgment for this codebase — aggregate boundaries, entity vs. value object, invariant enforcement, when to use a domain event, avoiding anemic domain models. Use before dknet-entity and dknet-crud whenever the aggregate shape or business-rule placement isn't obvious.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: DDD Tactical Principles
|
|
7
|
+
|
|
8
|
+
This codebase already gives you the class shapes (`AggregateRoot`, `DomainEntity`, `AddEvent`) — see `dknet-entity`. This skill covers the judgment calls those shapes don't make for you: what belongs in one aggregate, what's an entity vs. a value object, where an invariant lives, and when a domain event is the right tool.
|
|
9
|
+
|
|
10
|
+
This template is a single microservice / single bounded context — these are tactical patterns, not strategic (bounded-context) design.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## When to Use
|
|
15
|
+
|
|
16
|
+
- Deciding what a new aggregate root should own vs. what belongs to a separate aggregate.
|
|
17
|
+
- Deciding whether a new type needs its own identity (entity) or is just a bag of values (owned/value object).
|
|
18
|
+
- Deciding whether a business rule belongs on the entity, in a domain service, or in the command handler.
|
|
19
|
+
- Deciding whether a mutation needs to publish a domain event or just needs a plain method call.
|
|
20
|
+
|
|
21
|
+
## Aggregate Boundaries
|
|
22
|
+
|
|
23
|
+
An aggregate is a transactional consistency boundary: everything inside it is saved together and its invariants are enforced together. The rule of thumb —
|
|
24
|
+
|
|
25
|
+
- If two pieces of data must always be consistent with each other *at the moment they're saved* (e.g. a `PurchaseOrder`'s `Amount` and its `Status` — cancelling and re-pricing an order in the same save must not leave those two fields disagreeing), they belong in the same aggregate. That's why `PurchaseOrder` owns both `Amount` and `Status` directly rather than splitting them into two persisted types.
|
|
26
|
+
- If two pieces of data can be consistent *eventually*, a moment apart, they belong in separate aggregates, coordinated through a domain event — not a direct object reference.
|
|
27
|
+
- Aggregates reference each other by ID (`Guid`), never by object reference. `PurchaseOrder` (`ApiEndpoints/Minimal.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`) does not hold a `Customer` object — it holds a plain `CustomerName` string, no navigation property back to another aggregate.
|
|
28
|
+
|
|
29
|
+
Keep aggregates small. A large aggregate means more contention (every mutation locks the whole thing) and usually signals a boundary was drawn around "things that seem related" rather than "things that must be consistent together."
|
|
30
|
+
|
|
31
|
+
## Entity vs. Value Object
|
|
32
|
+
|
|
33
|
+
- **Entity** (in this codebase: `AggregateRoot` for the root, `DomainEntity` for non-root entities): has identity and a lifecycle. Two entities with identical property values are still different entities if their `Id` differs. `PurchaseOrder` is an entity — two orders with the same customer name and amount are still two different orders.
|
|
34
|
+
- **Value object** (owned type, plain class with no `Id`): defined entirely by its values. Two value objects with identical properties are interchangeable. If a type never needs to be looked up or referenced independently of its parent entity, it's a value object — model it as a plain owned type (see `dknet-entity` "Step 3: Create Owned Value Objects"), not as another `DomainEntity`.
|
|
35
|
+
|
|
36
|
+
Ask: "Do I ever need to fetch or reference this thing on its own, independent of its parent?" Yes → entity. No → value object.
|
|
37
|
+
|
|
38
|
+
## Invariant Enforcement
|
|
39
|
+
|
|
40
|
+
An invariant is a rule that must always hold true for an entity (e.g. "amount is never negative," "a cancelled order stays cancelled"). In this codebase, invariants are enforced by construction and by the entity's own mutation methods — never by a public setter:
|
|
41
|
+
|
|
42
|
+
- Properties are `{ get; private set; }`. Nothing outside the entity can put it into an invalid state directly.
|
|
43
|
+
- The constructor establishes the invariant for a new entity. Named mutation methods (`ChangeAmount`, `Cancel` — see `PurchaseOrder` in `dknet-entity`) re-establish it for every mutation, and are the *only* path to changing mutable state.
|
|
44
|
+
- A rule that depends on the entity's **own current state** belongs on the entity, or right next to the fetch in the handler when it must read the stored row first. `PurchaseOrder.Cancel(string userId)` is the clearest example: `CancelPurchaseOrderCommandHandler` (`ApiEndpoints/Minimal.AppServices/ManualSample/V1/Actions/Cancel.cs`) checks `order.Status == PurchaseOrderStatus.Cancelled` and fails the request *before* calling `Cancel`; the transition itself still lives on the entity. Note the weakness this leaves — the guard is one call away from being bypassed by a second caller who skips it, so treat handler-side guards as a boundary check, not as the invariant's home.
|
|
45
|
+
- If a rule needs data external to the entity (e.g. "customer name must be unique across all orders"), that's not an entity invariant — it's a cross-entity business rule, and it belongs in the command handler as a duplicate-check `Specification` query (see `dknet-crud`), because the entity has no way to see other entities.
|
|
46
|
+
|
|
47
|
+
## When to Use a Domain Event
|
|
48
|
+
|
|
49
|
+
This codebase has two equally valid ways to raise the same kind of event — pick the one that matches how much control you need over the raise:
|
|
50
|
+
|
|
51
|
+
- **Hand-raised** (`PurchaseOrder`): the constructor calls `AddEvent(new PurchaseOrderCreatedEvent(Id, CustomerName, Amount))` directly, in application code you can step through in a debugger. You write the payload by hand. Use this when the event's payload, timing, or "did this actually happen" condition needs logic more specific than "a tracked property changed."
|
|
52
|
+
- **Declared** (`Product`): the class carries `[RaisesEvent(EventOperations.Created, Include = [nameof(Id), nameof(Name), nameof(Price)])]` and `[RaisesEvent(EventOperations.Updated, nameof(Price))]` — no line of application code calls `AddEvent` anywhere in `AutomatedSample/`. DKNet's EF Core save hook reads these declarations and raises the composed event records (`ProductCreatedEvent`, `ProductPriceUpdatedEvent`) after a successful `SaveChanges`, driven by the change tracker. Use this when the event is a straightforward "this property changed" notification and you're already using `[CrudCreate]`/`[CrudUpdate]` for the entity.
|
|
53
|
+
The trade-offs: you cannot single-step from "constructor ran" to "event raised" the way you can with `AddEvent`, and the payload's name and shape follow a fixed composition rule (`<Entity><NarrowingProps><Operation>Event`) rather than one you choose — verify the composed name against the compiled assembly before wiring a consumer to it.
|
|
54
|
+
|
|
55
|
+
Both styles are delivered identically afterward: `ApiEndpoints/Minimal.Infra/Services/EventPublisher.cs` forwards to `IMessageBus` regardless of which raised the event.
|
|
56
|
+
|
|
57
|
+
Reach for either one when **something outside this aggregate might care that this happened** — another aggregate needs to react, or an external system needs to be notified. `Product`'s declared `Created` event is consumed both in-process and over Azure Service Bus (`ProductCreatedNotificationHandler`), exactly as a hand-raised one would be.
|
|
58
|
+
|
|
59
|
+
Do NOT reach for an event when the effect is entirely local to this one request:
|
|
60
|
+
- Setting a computed field during the same handler → just do it in the handler or the entity method, no event needed.
|
|
61
|
+
- A validation failure → return `Result.Fail(...)`, don't publish an event.
|
|
62
|
+
|
|
63
|
+
If you can't name a concrete future subscriber (even a logging handler counts, but "just in case" doesn't), it's not an event yet — add it when a real consumer appears.
|
|
64
|
+
|
|
65
|
+
## Avoiding Anemic Domain Models
|
|
66
|
+
|
|
67
|
+
An anemic model is an entity that's just a property bag, with all the actual business logic living in command handlers. Symptoms to watch for:
|
|
68
|
+
|
|
69
|
+
- A handler reads several properties off an entity, computes something, then writes several properties back — that computation belongs in a named method on the entity (e.g. `entity.ChangeAmount(amount, userId)` or `entity.Cancel(userId)`), not in the handler.
|
|
70
|
+
- The handler is the only place an invariant is checked — meaning the entity could be constructed or mutated elsewhere into an invalid state.
|
|
71
|
+
|
|
72
|
+
The handler's job is orchestration: fetch the entity (via `IRepositorySpec` + a `Specification`), call one or more methods on it, persist, map to a DTO. The entity's job is protecting its own consistency and encoding what a valid state transition looks like.
|
|
73
|
+
|
|
74
|
+
## Decision Checklist
|
|
75
|
+
|
|
76
|
+
- [ ] Can I name a concrete reason this needs to be consistent with the parent in the same transaction? If no, it's a separate aggregate.
|
|
77
|
+
- [ ] Does this type ever get looked up independently of its parent? If no, it's a value object, not an entity.
|
|
78
|
+
- [ ] Does this rule only need data already on the entity? If yes, enforce it in the entity's constructor/`Update` method, not the handler.
|
|
79
|
+
- [ ] Can I name a real, current subscriber for this event? If no, skip the event for now.
|
|
80
|
+
- [ ] Is the handler computing business logic, or just orchestrating fetch → mutate → persist → map? If it's computing, move the logic onto the entity.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Next Steps
|
|
85
|
+
|
|
86
|
+
→ `dknet-entity` — apply these decisions to actual entity code
|
|
87
|
+
→ `dknet-crud` — apply the handler-orchestration boundary to CQRS actions
|