@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,278 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dknet-dto-mapping
|
|
3
|
+
description: Design response DTOs and Mapster mapping for a DKNet.Minimal feature — hand-written vs [GenerateDto] shapes, custom Mapster IRegister mappings for values the generator's convention can't produce, LazyMapper, and the JSON/sensitive-data contract. Use after (or alongside) dknet-crud when a handler needs a DTO to return.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# DTO and Mapster mapping
|
|
7
|
+
|
|
8
|
+
Response DTO shape and how it gets filled from the entity. For request contracts, validators and
|
|
9
|
+
handlers, load `dknet-crud`. For paged query projections, load `dknet-queries-specs`.
|
|
10
|
+
|
|
11
|
+
## Two DTO shapes
|
|
12
|
+
|
|
13
|
+
**Hand-written** — a plain record, exactly the fields you list:
|
|
14
|
+
|
|
15
|
+
```csharp
|
|
16
|
+
// ManualSample/V1/PurchaseOrderDto.cs
|
|
17
|
+
public sealed record PurchaseOrderDto
|
|
18
|
+
{
|
|
19
|
+
public Guid Id { get; init; }
|
|
20
|
+
public string CustomerName { get; init; } = null!;
|
|
21
|
+
public decimal Amount { get; init; }
|
|
22
|
+
public PurchaseOrderStatus Status { get; init; }
|
|
23
|
+
public string CreatedBy { get; init; } = null!;
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This carries **no attribute at all**, yet `mapper.Map<PurchaseOrderDto>(order)` and
|
|
28
|
+
`mapper.ResultOf<PurchaseOrderDto>(order)` both work correctly. Mapster's global convention
|
|
29
|
+
(`TypeAdapterConfig.GlobalSettings.Default`, configured once in `AppSetup.cs`) maps any two types by
|
|
30
|
+
flexible name matching with no registration required — a hand-written DTO whose property names
|
|
31
|
+
match the entity's needs nothing further.
|
|
32
|
+
|
|
33
|
+
**Generated** — one attribute on an empty `partial record`:
|
|
34
|
+
|
|
35
|
+
```csharp
|
|
36
|
+
[GenerateDto(typeof(Product),
|
|
37
|
+
Exclude = [nameof(Product.OwnedBy), nameof(AuditedEntity<Guid>.LastModifiedBy), nameof(AuditedEntity<Guid>.LastModifiedOn)])]
|
|
38
|
+
public sealed partial record ProductDto
|
|
39
|
+
{
|
|
40
|
+
[SensitiveData("pricing")]
|
|
41
|
+
public decimal? GrossMargin { get; init; }
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`DKNet.EfCore.DtoGenerator` emits every remaining audited property at compile time. What that
|
|
46
|
+
produces here, verbatim (`obj/Generated/DKNet.EfCore.DtoGenerator/.../ProductDto.g.cs`):
|
|
47
|
+
|
|
48
|
+
```csharp
|
|
49
|
+
public partial record ProductDto
|
|
50
|
+
{
|
|
51
|
+
public required string Name { get; init; }
|
|
52
|
+
public decimal Price { get; init; }
|
|
53
|
+
public bool IsDiscontinued { get; init; }
|
|
54
|
+
[SensitiveData("pricing")] public decimal? SupplierCostPrice { get; init; }
|
|
55
|
+
[SensitiveData] public string? SupplierReferenceCode { get; init; }
|
|
56
|
+
[MaxLength(500)] public required string CreatedBy { get; init; }
|
|
57
|
+
public DateTimeOffset CreatedOn { get; init; }
|
|
58
|
+
[MaxLength(500)] public string? UpdatedBy { get; init; }
|
|
59
|
+
public DateTimeOffset? UpdatedOn { get; init; }
|
|
60
|
+
public Guid Id { get; init; }
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`[SensitiveData]` and `[MaxLength]` on the entity property travel onto the generated one unchanged —
|
|
65
|
+
that's how `SupplierCostPrice`/`SupplierReferenceCode` stay gated per caller (see
|
|
66
|
+
`docs`'s role-aware filtering, summarized below) without being declared twice. The DTO's own
|
|
67
|
+
`GrossMargin` is the file's one hand-written addition, on the *other*, non-generated partial
|
|
68
|
+
declaration — the generator's output file is never edited directly.
|
|
69
|
+
|
|
70
|
+
## `[GenerateDto]` options
|
|
71
|
+
|
|
72
|
+
```csharp
|
|
73
|
+
public GenerateDtoAttribute(Type entityType)
|
|
74
|
+
public string[] Exclude { get; set; } = []; // mutually exclusive with Include
|
|
75
|
+
public string[] Include { get; set; } = []; // only these properties, if set
|
|
76
|
+
public bool IgnoreComplexType { get; set; } // default true: navigation properties dropped
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`Include` and `Exclude` are mutually exclusive — set one or the other, never both. `Product`'s DTO
|
|
80
|
+
uses `Exclude` because it wants "everything except three names"; reach for `Include` when you want
|
|
81
|
+
"only these few" instead. `IgnoreComplexType` defaults to `true` (falls through to the MSBuild
|
|
82
|
+
property `DtoGeneratorIgnoreComplexType` if set, else `true`): navigation properties to other
|
|
83
|
+
entities are dropped automatically unless the target is `[Owned]`. Set it `false` per-DTO to include
|
|
84
|
+
navigation properties.
|
|
85
|
+
|
|
86
|
+
**Why `OwnedBy`, `LastModifiedBy`, `LastModifiedOn` are excluded.** `OwnedBy` duplicates
|
|
87
|
+
`CreatedBy` as the same ownership key — excluding it avoids exposing the tenant key twice, and
|
|
88
|
+
means it is unqueryable via the generic list route by construction (you cannot filter by ownership
|
|
89
|
+
key over HTTP even though the column exists). `LastModifiedBy`/`LastModifiedOn` on
|
|
90
|
+
`AuditedEntity<TKey>` are *computed* conveniences (the updated value, or the created one if never
|
|
91
|
+
modified) — not mapped columns. Left on a generated DTO, they'd resolve on `GetById` (read once, in
|
|
92
|
+
memory) but break the **list** route: its filter/search/order build EF predicates against the entity
|
|
93
|
+
by property name, and an unmapped member makes the whole query fail to translate at the database —
|
|
94
|
+
a `500`, not a `400`, the first time `?search=` touches it. `UpdatedBy`/`UpdatedOn` stay — they are
|
|
95
|
+
real mapped columns covering the same intent.
|
|
96
|
+
|
|
97
|
+
## Mapster global configuration
|
|
98
|
+
|
|
99
|
+
`Minimal.AppServices/AppSetup.cs`, run once at startup:
|
|
100
|
+
|
|
101
|
+
```csharp
|
|
102
|
+
TypeAdapterConfig.GlobalSettings.Default.NameMatchingStrategy(NameMatchingStrategy.Flexible);
|
|
103
|
+
TypeAdapterConfig.GlobalSettings.Default.MapToConstructor(true);
|
|
104
|
+
TypeAdapterConfig.GlobalSettings.Default.PreserveReference(true);
|
|
105
|
+
TypeAdapterConfig.GlobalSettings.ScanMaps();
|
|
106
|
+
TypeAdapterConfig.GlobalSettings.Compile();
|
|
107
|
+
|
|
108
|
+
services.AddSingleton(TypeAdapterConfig.GlobalSettings)
|
|
109
|
+
.AddScoped<IMapper, ServiceMapper>();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`ScanMaps()` (`Minimal.AppServices/Extensions/MapsToExtensions.cs`) reflects over the assembly for
|
|
113
|
+
every type carrying `[MapsFrom(typeof(Entity))]` or `[GenerateDto(typeof(Entity))]` and calls
|
|
114
|
+
`config.NewConfig(entityType, dtoType)` for each — this is what makes a generated/`[MapsFrom]`-typed
|
|
115
|
+
pair eagerly compiled and validated at startup, rather than resolved lazily by the `Default`
|
|
116
|
+
fallback rule on first use. **Ordering matters**: only *after* that loop does it call
|
|
117
|
+
`config.Scan(assembly)`, which discovers `IRegister` classes (like `ProductMappingRegister`) and
|
|
118
|
+
merges their `ForType` customizations onto the config the loop just built. Reversing the order would
|
|
119
|
+
have the convention's `NewConfig` wipe out the `IRegister`'s merge.
|
|
120
|
+
|
|
121
|
+
`[MapsFrom]` on a hand-written DTO (`Minimal.AppServices.Extensions.MapsFromAttribute`) is **not
|
|
122
|
+
required for the mapping to work** — `PurchaseOrderDto` proves that with zero attributes and zero
|
|
123
|
+
registration. Reach for it only when you also want to attach a Mapster `IRegister` customization
|
|
124
|
+
(a computed property, a rename) to that specific entity/DTO pair: `[MapsFrom]` puts the hand-written
|
|
125
|
+
pair through the same `ScanMaps()` → `NewConfig` → `IRegister`-merge pipeline `[GenerateDto]` already
|
|
126
|
+
gets, so a custom `ForType` call has a config to merge onto instead of relying on the untyped
|
|
127
|
+
`Default` fallback.
|
|
128
|
+
|
|
129
|
+
## Custom response mapping
|
|
130
|
+
|
|
131
|
+
A value the naming convention can't derive — anything computed from more than one column — is a
|
|
132
|
+
hand-written property on the DTO's `partial record` plus an `IRegister`:
|
|
133
|
+
|
|
134
|
+
```csharp
|
|
135
|
+
internal sealed class ProductMappingRegister : IRegister
|
|
136
|
+
{
|
|
137
|
+
public void Register(TypeAdapterConfig config) =>
|
|
138
|
+
config.ForType<Product, ProductDto>()
|
|
139
|
+
.Map(d => d.GrossMargin, s => s.Price - s.SupplierCostPrice);
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`ForType<TSource, TDest>()` **merges** onto whatever config already exists for that pair — every
|
|
144
|
+
convention-mapped property (`Name`, `Price`, the audit columns) stays untouched; only `GrossMargin`
|
|
145
|
+
is affected. `NewConfig` would **replace** the pair's whole config instead, silently discarding the
|
|
146
|
+
convention mapping — never use `NewConfig` inside an `IRegister` meant to *add* one property.
|
|
147
|
+
|
|
148
|
+
Keep the mapping expression a plain, EF-translatable expression (`s.Price - s.SupplierCostPrice`,
|
|
149
|
+
not a method call or branch Entity Framework can't turn into SQL): on a generated CRUD entity, the
|
|
150
|
+
list route projects the DTO directly over `IQueryable<TEntity>`, so every property's mapping
|
|
151
|
+
expression must compile to SQL, not just to CLR code. A hand-mapped property is **response-only** —
|
|
152
|
+
it has no entity counterpart, so the generic list route refuses to filter or order on it
|
|
153
|
+
(`?orderBy=grossMargin` and `?filter=grossMargin:GreaterThan:0` both answer `400` naming the field,
|
|
154
|
+
not `500` — the field-existence check catches it before a query is built). Mirror any
|
|
155
|
+
`[SensitiveData]` restriction from the source columns onto the derived property if it discloses the
|
|
156
|
+
same information — `GrossMargin` carries `[SensitiveData("pricing")]` because it discloses the same
|
|
157
|
+
confidential number as `SupplierCostPrice`.
|
|
158
|
+
|
|
159
|
+
Two tests pin this shape: `Architecture/ProductGrossMarginStructureTests.cs` asserts no hand-written
|
|
160
|
+
route, request, or handler type exists anywhere with "GrossMargin" in its name — proving the
|
|
161
|
+
customization stayed a mapping concern, not a routing one — and
|
|
162
|
+
`Integration/AutomatedSample/V1/ProductGrossMarginTests.cs` proves the value appears correctly on
|
|
163
|
+
every generated route's response (create, get, list, each `[CrudUpdate]`/`[CrudAction]`), is `null`
|
|
164
|
+
(not omitted) for a caller in the required role when the source is undisclosed, is omitted entirely
|
|
165
|
+
for a caller outside that role, and that ordering/filtering by it is refused with `400` while
|
|
166
|
+
ordinary fields keep working.
|
|
167
|
+
|
|
168
|
+
## Other customizations
|
|
169
|
+
|
|
170
|
+
Still inside an `IRegister`'s `ForType<TSource, TDest>()` chain:
|
|
171
|
+
|
|
172
|
+
- **Ignore a member**: `.Ignore(d => d.SomeField)` — the DTO keeps the property but Mapster never
|
|
173
|
+
writes it (default value stays). Prefer `Exclude`/`Include` on `[GenerateDto]` when the property
|
|
174
|
+
shouldn't exist on the DTO at all.
|
|
175
|
+
- **Rename**: `.Map(d => d.NewName, s => s.OldName)` — same mechanism as `GrossMargin`, one column
|
|
176
|
+
instead of a computed expression; stays EF-translatable.
|
|
177
|
+
- **Enums and strings**: convention mapping handles same-named enum-to-enum and enum-to-string by
|
|
178
|
+
member name already; only add a `.Map(...)` when you need a different textual representation than
|
|
179
|
+
the default.
|
|
180
|
+
- **Nested/owned types**: convention mapping recurses into an owned type's own properties by name;
|
|
181
|
+
`[GenerateDto(..., IgnoreComplexType = false)]` is what lets a navigation property reach the DTO at
|
|
182
|
+
all.
|
|
183
|
+
- **`AfterMapping`**: `.AfterMapping((src, dest) => ...)` runs CLR code after the projection — safe
|
|
184
|
+
for a hand-written DTO read one row at a time (`mapper.Map<TDto>(entity)`), but **not**
|
|
185
|
+
EF-translatable, so it cannot appear in a mapping used by the generic list route's `IQueryable`
|
|
186
|
+
projection. Treat any `AfterMapping` customization as reads-only, never wired to a
|
|
187
|
+
`[GenerateDto]` type that a list route also projects.
|
|
188
|
+
|
|
189
|
+
## LazyMapper
|
|
190
|
+
|
|
191
|
+
`DKNet.SlimBus.Extensions.LazyMapper` (namespace already in `GlobalUsings.cs`) gives two extension
|
|
192
|
+
methods on `IMapper`:
|
|
193
|
+
|
|
194
|
+
- `mapper.ResultOf<TDto>(entity)` — wraps `entity` in a **successful** `IResult<TDto>`, mapped only
|
|
195
|
+
when its `.Value` is first read. Use this as a handler's return value right after a write whose
|
|
196
|
+
DTO needs something only `SaveChanges` produces — a database-generated `Id`, `CreatedOn`,
|
|
197
|
+
`CreatedBy`/`OwnedBy` stamped by a save hook. Because the SlimBus EF Core interceptor runs
|
|
198
|
+
synchronously right after `OnHandle` returns and before the framework reads the `Result`'s value
|
|
199
|
+
to build the HTTP response, those values already exist by the time the lazy mapping actually runs.
|
|
200
|
+
- `mapper.LazyMap<TValue>(entity)` — the same deferred mapping, without the `IResult` wrapper, for a
|
|
201
|
+
context that isn't returning a `FluentResults` result directly.
|
|
202
|
+
|
|
203
|
+
For an ordinary read, or a write where nothing on the DTO depends on a post-save value (an amount
|
|
204
|
+
change, a status flip), map eagerly instead: `Result.Ok(mapper.Map<TDto>(entity))`.
|
|
205
|
+
|
|
206
|
+
## Paged projection
|
|
207
|
+
|
|
208
|
+
```csharp
|
|
209
|
+
new StaticPagedList<TDto>(page.Select(mapper.Map<TDto>), page)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`page` is the `IPagedList<TEntity>` from `repository.ToPagedListAsync(spec, pageIndex, pageSize, ct)`
|
|
213
|
+
(`X.PagedList`); wrapping the mapped items in `StaticPagedList<TDto>` alongside the original page
|
|
214
|
+
carries its paging metadata (page number, total count) forward onto the DTO-typed result.
|
|
215
|
+
|
|
216
|
+
## JSON contract
|
|
217
|
+
|
|
218
|
+
`Minimal.Share/SharedConsts.JsonSerializerOptions` is the one source of truth for serialization
|
|
219
|
+
shape — camelCase property names, nulls omitted (`DefaultIgnoreCondition.WhenWritingNull`), enums as
|
|
220
|
+
camelCase strings (`JsonStringEnumConverter(JsonNamingPolicy.CamelCase)`). `ServiceConfigs.AddOptions`
|
|
221
|
+
copies these settings onto ASP.NET Core's own `JsonOptions` via `ConfigureHttpJsonOptions`, so a
|
|
222
|
+
minimal-API response and this constant never drift apart. The same call site also opts the app into
|
|
223
|
+
role-aware `[SensitiveData]` filtering (`UseRoleAwareSensitiveData`, needing
|
|
224
|
+
`ISensitiveDataPrincipalAccessor` from DI) — the full caller/role decision table is a
|
|
225
|
+
`dknet-auth-and-ownership` concern, not this skill's; what matters here is that it is response-side
|
|
226
|
+
JSON serialization only, layered on top of whatever DTO shape and Mapster mapping you built, never a
|
|
227
|
+
change to the DTO type itself.
|
|
228
|
+
|
|
229
|
+
## Decision table
|
|
230
|
+
|
|
231
|
+
| | Hand-written record | `[GenerateDto]` |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| Response shape | Exactly what you list | Every audited property, minus `Exclude` |
|
|
234
|
+
| New entity property | Invisible until you add it | Appears automatically on next build |
|
|
235
|
+
| Query surface (generated list route) | N/A — no generated list route without `[CrudCreate]`/`[GenerateDto]` together | DTO fields are the filter/search/order surface |
|
|
236
|
+
| Derived/computed value | Any C# expression, `AfterMapping` included | Only via a hand-written partial member + `IRegister`, and only if EF-translatable |
|
|
237
|
+
| Needs `[MapsFrom]`? | Only to attach an `IRegister` customization | N/A — `[GenerateDto]` already opts in |
|
|
238
|
+
|
|
239
|
+
Pick hand-written when the response should intentionally expose less than the entity, or when a
|
|
240
|
+
value needs CLR-only computation (`AfterMapping`, external lookups) and will never back a generated
|
|
241
|
+
list route. Pick `[GenerateDto]` when the entity already carries `[CrudCreate]`/`[CrudUpdate]` and
|
|
242
|
+
the DTO doubles as the generic CRUD/list contract.
|
|
243
|
+
|
|
244
|
+
## Common mistakes
|
|
245
|
+
|
|
246
|
+
- **What you might expect**: adding `NewConfig(typeof(Product), typeof(ProductDto))` inside a new
|
|
247
|
+
`IRegister` is a normal way to add one more mapping rule.
|
|
248
|
+
**What actually happens**: every property `[GenerateDto]`'s convention was already mapping for
|
|
249
|
+
that pair stops working — only what the new `NewConfig` explicitly re-declares survives.
|
|
250
|
+
**Why**: `NewConfig` replaces a pair's whole configuration; `ForType` merges onto it. Inside an
|
|
251
|
+
`IRegister` meant to add to an existing generated or `[MapsFrom]` pair, always use `ForType`.
|
|
252
|
+
|
|
253
|
+
- **What you might expect**: a DTO member with no matching entity property is harmless as long as
|
|
254
|
+
it's nullable and never populated by a create/update request.
|
|
255
|
+
**What actually happens**: `GET`/`?search=` on the generic list route throws or 500s the first
|
|
256
|
+
time that field participates in a query, or (if the field genuinely doesn't exist on the entity at
|
|
257
|
+
all) the route answers `400` naming it as unsupported.
|
|
258
|
+
**Why**: list-route filter/search/order resolve DTO fields against mapped entity columns; a
|
|
259
|
+
property that's declared-but-unmapped fails to translate (500), and one with no entity
|
|
260
|
+
counterpart at all fails the existence check first (400) — see `LastModifiedBy`/`LastModifiedOn`
|
|
261
|
+
vs `GrossMargin` above for the two cases.
|
|
262
|
+
|
|
263
|
+
- **What you might expect**: forgetting `partial` on a `[GenerateDto]` record is a harmless typo the
|
|
264
|
+
compiler will catch immediately with a clear message.
|
|
265
|
+
**What actually happens**: it does fail to compile, but as a "partial declarations must have
|
|
266
|
+
matching partial modifiers" error against the generator's own output file, not against your file
|
|
267
|
+
— easy to mis-diagnose as a generator bug.
|
|
268
|
+
**Why**: `[GenerateDto]` emits a second `partial record` declaration under the same name; without
|
|
269
|
+
`partial` on your declaration the two can't merge.
|
|
270
|
+
|
|
271
|
+
- **What you might expect**: putting an acting-user field (`ByUser`, `CreatedBy`) on a DTO record
|
|
272
|
+
is fine since the entity already carries it.
|
|
273
|
+
**What actually happens**: it maps through unchanged on a **read**, which is fine, but on a DTO
|
|
274
|
+
used as part of a create/update request shape (rare, but seen when a hand-written request and its
|
|
275
|
+
response DTO are conflated) it becomes caller-settable.
|
|
276
|
+
**Why**: DTOs and requests are different concerns — a DTO is response-only; acting-user
|
|
277
|
+
attribution belongs on the request (`dknet-crud`) or a save hook, never inferred
|
|
278
|
+
from a response type reused as input.
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dknet-efcore-config
|
|
3
|
+
description: Create EF Core entity type configurations (mappers), static data seeders, CoreDbContext wiring, and infra domain-service implementations following this project's assembly-scan auto-discovery conventions. Use after creating a domain entity, for both mode=manual and mode=auto entities.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: EF Core Configuration
|
|
7
|
+
|
|
8
|
+
Create the persistence-layer configuration for a domain entity — mapper, optional static seed data,
|
|
9
|
+
and (rarely) an infra domain-service implementation. This layer is **hand-written identically for
|
|
10
|
+
both entity shapes** (`dknet-entity`'s `mode=manual`/`mode=auto`): no generator in this
|
|
11
|
+
template — not `[RaisesEvent]`, not `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]`, not
|
|
12
|
+
`[GenerateDto]` — touches `IEntityTypeConfiguration<T>`. `ProductConfigs` (for the generator-driven
|
|
13
|
+
`Product`) is exactly as hand-written as `PurchaseOrderConfigs`.
|
|
14
|
+
|
|
15
|
+
## Mapper
|
|
16
|
+
|
|
17
|
+
`Minimal.Infra/Features/ManualSample/Mappers/PurchaseOrderConfigs.cs` — the whole file:
|
|
18
|
+
|
|
19
|
+
```csharp
|
|
20
|
+
internal sealed class PurchaseOrderConfigs : DefaultEntityTypeConfiguration<PurchaseOrder>
|
|
21
|
+
{
|
|
22
|
+
public override void Configure(EntityTypeBuilder<PurchaseOrder> builder)
|
|
23
|
+
{
|
|
24
|
+
base.Configure(builder);
|
|
25
|
+
|
|
26
|
+
builder.HasIndex(p => p.CustomerName);
|
|
27
|
+
builder.Property(p => p.CustomerName).HasMaxLength(200).IsRequired();
|
|
28
|
+
builder.Property(p => p.Amount).HasPrecision(18, 2);
|
|
29
|
+
builder.Property(p => p.Status).HasConversion<string>();
|
|
30
|
+
builder.ToTable("PurchaseOrders", "manual_sample");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`Minimal.Infra/Features/AutomatedSample/Mappers/ProductConfigs.cs` — the whole file:
|
|
36
|
+
|
|
37
|
+
```csharp
|
|
38
|
+
internal sealed class ProductConfigs : DefaultEntityTypeConfiguration<Product>
|
|
39
|
+
{
|
|
40
|
+
public override void Configure(EntityTypeBuilder<Product> builder)
|
|
41
|
+
{
|
|
42
|
+
base.Configure(builder);
|
|
43
|
+
|
|
44
|
+
builder.Property(p => p.Name).HasMaxLength(150).IsRequired();
|
|
45
|
+
builder.HasIndex(p => p.Name).IsUnique();
|
|
46
|
+
builder.Property(p => p.Price).HasPrecision(18, 2);
|
|
47
|
+
builder.Property(p => p.OwnedBy).HasMaxLength(500).IsRequired();
|
|
48
|
+
builder.Property(p => p.SupplierCostPrice).HasPrecision(18, 2);
|
|
49
|
+
builder.Property(p => p.SupplierReferenceCode).HasMaxLength(50);
|
|
50
|
+
builder.ToTable("Products", "sample");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Location: `Minimal.Infra/Features/<Feature>/Mappers/<Entity>Configs.cs`. Discovered by
|
|
56
|
+
`UseAutoConfigModel([typeof(CoreDbContext).Assembly, typeof(Sequences).Assembly])` — an **assembly
|
|
57
|
+
scan for every `IEntityTypeConfiguration<T>`**, not a Scrutor convention scan (see Infra services,
|
|
58
|
+
below, for what Scrutor actually is and isn't used for here). This call is wired in **both**
|
|
59
|
+
`InfraSetup.AddInfraServices` and `InfraMigration.MigrateDb`.
|
|
60
|
+
|
|
61
|
+
**Always call `base.Configure(builder)` first.** `DefaultEntityTypeConfiguration<TEntity>`
|
|
62
|
+
(`DKNet.EfCore.Extensions`) does three things when it applies:
|
|
63
|
+
|
|
64
|
+
- If an `Id` property exists: `HasKey("Id")`, plus a value generator — `ValueGeneratedOnAdd()` for a
|
|
65
|
+
numeric key, or `ValueGeneratedOnAdd().HasValueGenerator<GuidV7ValueGenerator>()` for a `Guid` key
|
|
66
|
+
(a time-ordered GUID assigned at insert, distinct from the plain `Guid.NewGuid()` an
|
|
67
|
+
`AggregateRoot(string createdBy)` constructor assigns eagerly in memory — see
|
|
68
|
+
`dknet-entity`).
|
|
69
|
+
- If the entity implements `IAuditedProperties`: `CreatedBy`/`CreatedOn` required, `CreatedBy`
|
|
70
|
+
max length 255, both locked against change after insert (`SetAfterSaveBehavior`); `UpdatedBy` max
|
|
71
|
+
length 255, nullable; `UpdatedOn` nullable.
|
|
72
|
+
- If the entity implements `IConcurrencyEntity<T>`: a `RowVersion` concurrency token,
|
|
73
|
+
`ValueGeneratedOnAddOrUpdate()`. Neither sample uses this.
|
|
74
|
+
|
|
75
|
+
There is no soft-delete / `IsDeleted` convention anywhere in this base class or in either sample —
|
|
76
|
+
don't assume one exists.
|
|
77
|
+
|
|
78
|
+
Beyond `base.Configure`, everything is ordinary EF Core Fluent API: `HasIndex` (plain, or
|
|
79
|
+
`.IsUnique()` for a real business constraint — `Product.Name`'s uniqueness is enforced by the
|
|
80
|
+
database index, not the `CreateProductRequestValidator`, which only *checks* first — two concurrent
|
|
81
|
+
callers can still both pass the check), `HasMaxLength`/`HasPrecision` on every column, and
|
|
82
|
+
`ToTable("Name", "schema")` with a literal schema string or a `DomainSchemas` constant (both samples
|
|
83
|
+
use a literal). Store every mapped enum with `HasConversion<string>()` —
|
|
84
|
+
`InfraTests.AllEnumProperties_StoringToDb_ShouldHaveStringConversion` enforces this for every enum
|
|
85
|
+
property in the model, and `PurchaseOrder.Status` is the shipped example. Give every mapped `string`
|
|
86
|
+
an explicit `HasMaxLength` — `InfraTests.NoEntityString_ShouldBe_ConfiguredAs_Max` fails on any
|
|
87
|
+
unconstrained string, which on PostgreSQL would otherwise map to unbounded `text`.
|
|
88
|
+
|
|
89
|
+
**Owned types (`OwnsOne`/`OwnsMany`).** Not exercised by either shipped sample — no `OwnsOne` call
|
|
90
|
+
exists anywhere under `ApiEndpoints`. Configure one inside the owning entity's own
|
|
91
|
+
`Configure(builder)`, after `base.Configure(builder)`, the same as any other Fluent API call:
|
|
92
|
+
|
|
93
|
+
```csharp
|
|
94
|
+
builder.OwnsOne(e => e.ShippingAddress, owned =>
|
|
95
|
+
{
|
|
96
|
+
owned.Property(p => p.Street).HasMaxLength(200).IsRequired();
|
|
97
|
+
owned.Property(p => p.City).HasMaxLength(100).IsRequired();
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
There is no separate `OwnedDataContext` or similar file in this template — an owned type is
|
|
102
|
+
configured where its owner is, not in a shared file.
|
|
103
|
+
|
|
104
|
+
## Static data seeding
|
|
105
|
+
|
|
106
|
+
`Minimal.Infra/Features/ManualSample/StaticData/PurchaseOrderStaticData.cs` — the whole file:
|
|
107
|
+
|
|
108
|
+
```csharp
|
|
109
|
+
internal sealed class PurchaseOrderStaticData : DataSeedingConfiguration<PurchaseOrder>
|
|
110
|
+
{
|
|
111
|
+
protected override ValueTask<ICollection<PurchaseOrder>> GetDataAsync(
|
|
112
|
+
CancellationToken cancellation = new())
|
|
113
|
+
{
|
|
114
|
+
return ValueTask.FromResult<ICollection<PurchaseOrder>>(
|
|
115
|
+
[
|
|
116
|
+
new PurchaseOrder(
|
|
117
|
+
new Guid("6E6F4D3C-1B7E-4C7A-9F1D-8A2B5C6D7E01"),
|
|
118
|
+
"Acme Pte Ltd", 1250.00m, SharedConsts.SystemAccount),
|
|
119
|
+
new PurchaseOrder(
|
|
120
|
+
new Guid("6E6F4D3C-1B7E-4C7A-9F1D-8A2B5C6D7E02"),
|
|
121
|
+
"Globex Corporation", 875.50m, SharedConsts.SystemAccount),
|
|
122
|
+
new PurchaseOrder(
|
|
123
|
+
new Guid("6E6F4D3C-1B7E-4C7A-9F1D-8A2B5C6D7E03"),
|
|
124
|
+
"Initech LLC", 430.25m, SharedConsts.SystemAccount)
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Location: `Minimal.Infra/Features/<Feature>/StaticData/<Entity>StaticData.cs`. Inherit the
|
|
131
|
+
**base class** `DataSeedingConfiguration<T>` (`DKNet.EfCore.Extensions.Configurations`) — not an
|
|
132
|
+
`IDataSeedingConfiguration<T>` interface. Override the `protected` `GetDataAsync(CancellationToken)`
|
|
133
|
+
and return the fixed rows via the entity's `internal` rehydration constructor (known `Guid`s,
|
|
134
|
+
`SharedConsts.SystemAccount` as `createdBy`) — never the public constructor, so seeding never
|
|
135
|
+
re-raises the entity's created event. Class must be `internal sealed`
|
|
136
|
+
(`InfraTests.AllSeedingDataClassesShouldBeInternalAndSealed`, checked against
|
|
137
|
+
`IDataSeedingConfiguration` implementers).
|
|
138
|
+
|
|
139
|
+
**Re-run semantics** (from `DataSeedingConfiguration<T>`'s own doc comments): the base class's
|
|
140
|
+
`GetMissingAsync` filters the rows `GetDataAsync` returns down to the ones **not already present by
|
|
141
|
+
primary key** before inserting — comparing key columns read from the database, not entity equality
|
|
142
|
+
(a plain reference type has no `Equals` override, so entity-equality comparison would never match
|
|
143
|
+
freshly materialized rows and would re-insert every candidate on every run). This means seeding is
|
|
144
|
+
**insert-if-missing only**: it inserts a row whose fixed `Guid` isn't in the table yet, and never
|
|
145
|
+
updates a row that's already there, even if `GetDataAsync`'s in-code values changed since the row was
|
|
146
|
+
first seeded.
|
|
147
|
+
|
|
148
|
+
**Discovery and the two-call-site rule.** `UseAutoDataSeeding([typeof(InfraSetup).Assembly])` scans
|
|
149
|
+
for every `IDataSeedingConfiguration` implementer by assembly scan (same style as
|
|
150
|
+
`UseAutoConfigModel`, not Scrutor) and must be called in **both**:
|
|
151
|
+
|
|
152
|
+
- `Minimal.Infra/Extensions/InfraSetup.cs` → `AddInfraServices` (the DI-registered `CoreDbContext`
|
|
153
|
+
the running app uses)
|
|
154
|
+
- `Minimal.Infra/Extensions/InfraMigration.cs` → `MigrateDb` (a **separate** `CoreDbContext` built
|
|
155
|
+
for the startup-migration path; seeding runs as part of `db.Database.MigrateAsync()`)
|
|
156
|
+
|
|
157
|
+
This is a real bug the template hit once already: `PurchaseOrderStaticData` was correctly discovered
|
|
158
|
+
by the DI-path context but the migration path built its own context without the same
|
|
159
|
+
`.UseAutoDataSeeding(...)` call, so seed rows never appeared in a real database even though the
|
|
160
|
+
migration itself ran. When adding new seed data, verify both call sites, not just one.
|
|
161
|
+
|
|
162
|
+
**No test fixture wires this.** Neither `Minimal.App.Tests`' `ApiFixture` nor
|
|
163
|
+
`Minimal.App.BDDTests`' `BddApiFactory` calls `.UseAutoDataSeeding(...)` on their in-memory
|
|
164
|
+
`DbContext` — that wiring exists only in the two real composition-root call sites above. Tests that
|
|
165
|
+
actually exercise seeded data:
|
|
166
|
+
|
|
167
|
+
- `Minimal.App.Tests/Unit/ManualSample/PurchaseOrderStaticDataTests.cs` — invokes the `protected
|
|
168
|
+
GetDataAsync` via reflection (nothing public exposes it for a direct call) and asserts the three
|
|
169
|
+
fixed rows, owned by `SharedConsts.SystemAccount`, with distinct `Id`s.
|
|
170
|
+
- `Minimal.App.Tests/Integration/ManualSample/V1/InfraMigrationSeedingTests.cs` — runs
|
|
171
|
+
`InfraMigration.MigrateDb` itself against a real, ephemeral Postgres `Testcontainers` instance,
|
|
172
|
+
then reads `/v1/purchase-orders` over HTTP. This is the one test that fails if
|
|
173
|
+
`.UseAutoDataSeeding(...)` is ever removed from `InfraMigration.MigrateDb`.
|
|
174
|
+
|
|
175
|
+
## `CoreDbContext`
|
|
176
|
+
|
|
177
|
+
`Minimal.Infra/Contexts/CoreDbContext.cs` — `internal class CoreDbContext(DbContextOptions options,
|
|
178
|
+
IEnumerable<IDataOwnerProvider>? dataKeyProviders = null) : DbContext(options), IDataOwnerDbContext`.
|
|
179
|
+
No `DbSet<T>` declarations anywhere — the model is built entirely from the `IEntityTypeConfiguration<T>`
|
|
180
|
+
scan. It exposes `AccessibleKeys` from the first registered `IDataOwnerProvider`
|
|
181
|
+
(`DKNet.EfCore.DataAuthorization` uses this for the global read filter on any `IOwnedBy` entity), and
|
|
182
|
+
overrides every `SaveChanges`/`SaveChangesAsync` entry point to call `EnsureOwnershipResolvable()`
|
|
183
|
+
first:
|
|
184
|
+
|
|
185
|
+
```csharp
|
|
186
|
+
private void EnsureOwnershipResolvable()
|
|
187
|
+
{
|
|
188
|
+
if (_dataKeyProvider is null) return;
|
|
189
|
+
if (!string.IsNullOrEmpty(_dataKeyProvider.GetOwnershipKey())) return;
|
|
190
|
+
|
|
191
|
+
var hasUnattributableInsert = ChangeTracker.Entries()
|
|
192
|
+
.Any(e => e.State == EntityState.Added
|
|
193
|
+
&& e.Entity is IAuditedProperties { CreatedBy: null or "" }
|
|
194
|
+
&& e.Metadata.FindProperty(nameof(IAuditedProperties.CreatedBy)) is { IsNullable: false });
|
|
195
|
+
|
|
196
|
+
if (hasUnattributableInsert) throw new OwnershipRequiredException();
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Fails closed, before EF Core attempts the insert, when the ownership key can't be resolved and a new
|
|
201
|
+
row would be left with no `CreatedBy` — otherwise EF Core's own required-property check throws a raw
|
|
202
|
+
`DbUpdateException` that leaks column/entity names into the response. Mapped to `403 Forbidden` by
|
|
203
|
+
the `StatusCode` branch of `AddErrorResponses(...)` (see `dknet-endpoint`), not `500`.
|
|
204
|
+
|
|
205
|
+
`InfraSetup.AddInfraServices` — the DI wiring, in full:
|
|
206
|
+
|
|
207
|
+
```csharp
|
|
208
|
+
public static IServiceCollection AddInfraServices(this IServiceCollection service)
|
|
209
|
+
{
|
|
210
|
+
service
|
|
211
|
+
.AddScoped<IMembershipService, MembershipService>()
|
|
212
|
+
.AddSpecRepo<CoreDbContext>()
|
|
213
|
+
.AddEventPublisher<CoreDbContext, EventPublisher>()
|
|
214
|
+
.AddDbContextWithHook<CoreDbContext>((sp, builder) =>
|
|
215
|
+
{
|
|
216
|
+
var config = sp.GetRequiredService<IConfiguration>();
|
|
217
|
+
var conn = config.GetConnectionString(SharedConsts.DbConnectionString)!;
|
|
218
|
+
|
|
219
|
+
builder.UseNpgsqlWithMigration(conn)
|
|
220
|
+
.UseAutoConfigModel([typeof(CoreDbContext).Assembly, typeof(Sequences).Assembly])
|
|
221
|
+
.UseAutoDataSeeding([typeof(InfraSetup).Assembly]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return service;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
internal static DbContextOptionsBuilder UseNpgsqlWithMigration(
|
|
228
|
+
this DbContextOptionsBuilder builder, string connectionString) =>
|
|
229
|
+
builder.UseNpgsql(connectionString, o => o
|
|
230
|
+
.MinBatchSize(1)
|
|
231
|
+
.MaxBatchSize(100)
|
|
232
|
+
.MigrationsHistoryTable(nameof(CoreDbContext), DomainSchemas.Migration) // table "CoreDbContext", schema "migrate"
|
|
233
|
+
.MigrationsAssembly(typeof(CoreDbContext).Assembly)
|
|
234
|
+
.EnableRetryOnFailure()
|
|
235
|
+
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`Minimal.Infra/Extensions/InfraMigration.cs` — the startup-migration path, in full:
|
|
239
|
+
|
|
240
|
+
```csharp
|
|
241
|
+
public static async Task MigrateDb(string connectionString)
|
|
242
|
+
{
|
|
243
|
+
await using var db = new CoreDbContext(
|
|
244
|
+
new DbContextOptionsBuilder<CoreDbContext>()
|
|
245
|
+
.UseAutoConfigModel([typeof(CoreDbContext).Assembly, typeof(Sequences).Assembly])
|
|
246
|
+
.UseNpgsqlWithMigration(connectionString)
|
|
247
|
+
.UseAutoDataSeeding([typeof(InfraSetup).Assembly])
|
|
248
|
+
.Options);
|
|
249
|
+
|
|
250
|
+
// Seeding runs as part of MigrateAsync via UseAutoDataSeeding above.
|
|
251
|
+
await db.Database.MigrateAsync();
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`AddSpecRepo<CoreDbContext>()` wires `IRepositorySpec` (see `dknet-queries-specs`).
|
|
256
|
+
`AddEventPublisher<CoreDbContext, EventPublisher>()` wires `Minimal.Infra/Services/EventPublisher.cs`
|
|
257
|
+
— a `DefaultEventPublisher` override that does `bus.Publish(eventObj)` — as the sink both raise
|
|
258
|
+
styles (`AddEvent`/`[RaisesEvent]`) publish through after a successful save.
|
|
259
|
+
|
|
260
|
+
## Migrations
|
|
261
|
+
|
|
262
|
+
Always from the solution's `ApiEndpoints/` directory (there is no wrapper script — inline the real
|
|
263
|
+
`dotnet ef` command):
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
dotnet ef migrations add <Name> -c CoreDbContext -p Minimal.Infra/Minimal.Infra.csproj
|
|
267
|
+
dotnet ef migrations remove -c CoreDbContext -p Minimal.Infra/Minimal.Infra.csproj
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Inspect the generated migration under `Minimal.Infra/Migrations/` before continuing — confirm the
|
|
271
|
+
table, columns, and any index/constraint match what the mapper declares. Never edit an
|
|
272
|
+
already-applied migration; add a new one instead. `Architecture/MigrationSchemaTests.cs` pins
|
|
273
|
+
specific facts about the compiled model: `Product.Name` has a **unique** single-column index,
|
|
274
|
+
the model declares a sequence in schema `seq` and a sequence named `Seq_Membership` (never one named
|
|
275
|
+
after `Sequences.None`), the provider is not SQL Server, and both `Minimal.AppHost` and
|
|
276
|
+
`Minimal.Infra` reference PostgreSQL/Npgsql packages, never SQL Server ones. At application start,
|
|
277
|
+
`FeatureManagement:RunDbMigrationWhenAppStart` (or the `migration` launch argument — `dotnet run
|
|
278
|
+
--project ApiEndpoints/Minimal.Api -- migration`) is what actually calls this path; removing a
|
|
279
|
+
feature's tables is a **drop migration**, covered by `dknet-feature-lifecycle`, not here.
|
|
280
|
+
|
|
281
|
+
## Infra domain services
|
|
282
|
+
|
|
283
|
+
`Minimal.Domains/Services/` holds the interface, `Minimal.Infra/Services/` the implementation,
|
|
284
|
+
`InfraSetup.AddInfraServices` the registration — **by explicit `AddScoped<TInterface,
|
|
285
|
+
TImplementation>()`, one line per service.** There is no Scrutor convention scan anywhere in this
|
|
286
|
+
template's `Minimal.Infra` registration path — the only `Scan(` call in the whole solution is
|
|
287
|
+
Mapster's `TypeAdapterConfig.Scan` in `Minimal.AppServices/Extensions/MapsToExtensions.cs`, unrelated
|
|
288
|
+
to service registration. Do not rely on a naming convention or namespace
|
|
289
|
+
(`.Services`/`.Repos`) to get a service registered — add the `AddScoped` line yourself. `internal
|
|
290
|
+
sealed` is still required by the same architecture rule that covers mappers/handlers/validators
|
|
291
|
+
whenever the class implements a handler-shaped interface (`InfraTests.AllHandlerClassesShouldBeInternalAndSealed`
|
|
292
|
+
covers `IRequestHandler<>`/`IConsumer<>` implementers specifically — a plain domain-service
|
|
293
|
+
implementation like `MembershipService` isn't targeted by that rule either, but follow the same
|
|
294
|
+
`internal sealed` convention regardless).
|
|
295
|
+
|
|
296
|
+
```csharp
|
|
297
|
+
// Minimal.Infra/Services/SequenceService.cs — internal abstract base, one per sequence-backed service
|
|
298
|
+
internal abstract class SequenceService(DbContext dbContext, Sequences sequence) : ISequenceServices
|
|
299
|
+
{
|
|
300
|
+
public virtual async ValueTask<string> NextValueAsync() =>
|
|
301
|
+
dbContext.IsNpgsql()
|
|
302
|
+
? await dbContext.NextSeqValueWithFormat(sequence)
|
|
303
|
+
: Guid.NewGuid().ToString();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Minimal.Infra/Services/MembershipService.cs — the whole file
|
|
307
|
+
internal sealed class MembershipService(CoreDbContext dbContext)
|
|
308
|
+
: SequenceService(dbContext, Sequences.Membership), IMembershipService;
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`NextValueAsync()` formats the next value with the sequence's `FormatString` on PostgreSQL
|
|
312
|
+
(`NextSeqValueWithFormat`), and falls back to a plain `Guid` when the context isn't Npgsql — so a
|
|
313
|
+
unit test against an in-memory/SQLite context never needs a real Postgres sequence. Add your own
|
|
314
|
+
service the same way: an interface in `Minimal.Domains/Services/`, an `internal sealed`
|
|
315
|
+
implementation in `Minimal.Infra/Services/`, one `AddScoped` line in `AddInfraServices`.
|
|
316
|
+
|
|
317
|
+
## Architecture tests constraining `Minimal.Infra`
|
|
318
|
+
|
|
319
|
+
`Architecture/InfraTests.cs`:
|
|
320
|
+
|
|
321
|
+
- `AllEfConfigClassesShouldBeInternalAndSealed` — every `IEntityTypeConfiguration<T>` implementer.
|
|
322
|
+
- `AllSeedingDataClassesShouldBeInternalAndSealed` — every `IDataSeedingConfiguration` implementer.
|
|
323
|
+
- `AllHandlerClassesShouldBeInternalAndSealed` — every `IRequestHandler<>`/`IRequestHandler<,>`/
|
|
324
|
+
`IConsumer<>` implementer (covers `Minimal.Infra/Features/*/ExternalEvents/*` consumers).
|
|
325
|
+
- `AllValidatorClassesShouldBeInternalAndSealed` — every `AbstractValidator<T>` subclass found in
|
|
326
|
+
`Minimal.Infra` (none shipped there today; validators live in `Minimal.AppServices`).
|
|
327
|
+
- `AllEnumProperties_StoringToDb_ShouldHaveStringConversion` — every enum property in the built
|
|
328
|
+
model must have `ProviderClrType == typeof(string)`.
|
|
329
|
+
- `NoEntityString_ShouldBe_ConfiguredAs_Max` — every mapped `string` property must have an explicit
|
|
330
|
+
`MaxLength` and a non-`(max)` column type.
|
|
331
|
+
- `DesignTimeServiceProvider_CanActivateEventPublisher` — `dotnet ef`'s design-time service provider
|
|
332
|
+
must be able to resolve `IEventPublisher`/`IMessageBus`; guards a real regression where
|
|
333
|
+
`dotnet ef database update` died mid-seed because the design-time provider had no bus registered.
|
|
334
|
+
|
|
335
|
+
## Step-by-step
|
|
336
|
+
|
|
337
|
+
1. Create `Minimal.Infra/Features/<Feature>/Mappers/<Entity>Configs.cs`: `internal sealed class
|
|
338
|
+
<Entity>Configs : DefaultEntityTypeConfiguration<Entity>`, call `base.Configure(builder)` first,
|
|
339
|
+
then indexes, `HasMaxLength`/`HasPrecision` on every column, `HasConversion<string>()` on every
|
|
340
|
+
enum, `ToTable("<Plural>", "<schema>")`.
|
|
341
|
+
2. If the feature needs reference data, create `Minimal.Infra/Features/<Feature>/StaticData/<Entity>StaticData.cs`:
|
|
342
|
+
`internal sealed class <Entity>StaticData : DataSeedingConfiguration<Entity>`, override
|
|
343
|
+
`GetDataAsync`, build rows via the entity's `internal` rehydration constructor with fixed `Guid`s
|
|
344
|
+
and `SharedConsts.SystemAccount`.
|
|
345
|
+
3. If the feature needs a new domain service, add the interface under `Minimal.Domains/Services/`,
|
|
346
|
+
an `internal sealed` implementation under `Minimal.Infra/Services/`, and one `AddScoped<...>()`
|
|
347
|
+
line in `InfraSetup.AddInfraServices`.
|
|
348
|
+
4. Generate and inspect the migration from `ApiEndpoints/`:
|
|
349
|
+
`dotnet ef migrations add <Name> -c CoreDbContext -p Minimal.Infra/Minimal.Infra.csproj`.
|
|
350
|
+
5. `dotnet build -c Release` and `dotnet test --settings coverage.runsettings` from the solution
|
|
351
|
+
root.
|
|
352
|
+
|
|
353
|
+
## Validation checklist
|
|
354
|
+
|
|
355
|
+
- [ ] Mapper inherits `DefaultEntityTypeConfiguration<TEntity>` and calls `base.Configure(builder)` first
|
|
356
|
+
- [ ] Mapper class is `internal sealed`, under `Minimal.Infra/Features/<Feature>/Mappers/`
|
|
357
|
+
- [ ] Every mapped `string` has an explicit `HasMaxLength`; every mapped enum has `HasConversion<string>()`
|
|
358
|
+
- [ ] A real business uniqueness needs `.IsUnique()` on the index, not just a validator check
|
|
359
|
+
- [ ] `ToTable("Name", "schema")` set — literal string or a `DomainSchemas` constant
|
|
360
|
+
- [ ] If seeding: class is `internal sealed`, extends `DataSeedingConfiguration<T>` (not an
|
|
361
|
+
interface), uses the entity's rehydration constructor, and `UseAutoDataSeeding(...)` is
|
|
362
|
+
present in **both** `InfraSetup.AddInfraServices` and `InfraMigration.MigrateDb`
|
|
363
|
+
- [ ] If adding a domain service: interface in `Minimal.Domains/Services/`, `internal sealed`
|
|
364
|
+
implementation in `Minimal.Infra/Services/`, explicit `AddScoped<...>()` line added — no
|
|
365
|
+
convention scan will pick it up on its own
|
|
366
|
+
- [ ] Migration generated from `ApiEndpoints/` and inspected before continuing
|
|
367
|
+
- [ ] `dotnet build -c Release` passes
|
|
368
|
+
|
|
369
|
+
## Common mistakes
|
|
370
|
+
|
|
371
|
+
| Mistake | Fix |
|
|
372
|
+
|---|---|
|
|
373
|
+
| Expecting a namespace like `.Services`/`.Repos` to auto-register an infra service | There's no Scrutor scan for this in the template — add the explicit `AddScoped<TInterface, TImplementation>()` line in `InfraSetup.AddInfraServices` yourself. |
|
|
374
|
+
| Wiring `UseAutoDataSeeding`/`UseAutoConfigModel` into only `InfraSetup.AddInfraServices` | Also wire it into `InfraMigration.MigrateDb` — that path builds its own `CoreDbContext`; seed data silently never appears over HTTP otherwise. Real bug this template already hit once. |
|
|
375
|
+
| Expecting re-running seeding to update a changed fixed row | `DataSeedingConfiguration<T>` only inserts rows missing by primary key; it never updates an existing row. Change the fixed data via a migration, not by editing `GetDataAsync` and re-running. |
|
|
376
|
+
| Seeding via the entity's public constructor | Use the `internal` rehydration constructor — the public one re-raises the entity's created event, which a seed insert should never do. |
|
|
377
|
+
| Assuming `[SensitiveData]`/`[Range]`/etc. need mapper configuration | They don't — those are entity/DTO-level concerns (`dknet-entity`, `dknet-crud`). The mapper only configures storage shape. |
|
|
378
|
+
| Forgetting `.Property(p => p.OwnedBy).HasMaxLength(...).IsRequired()` on an `IOwnedBy` entity | Implementing the interface doesn't size or require the column — `ProductConfigs` configures it explicitly. |
|
|
379
|
+
| Editing an already-applied migration file by hand | Add a new migration instead; the applied one is a record of what ran against real databases. |
|