@drunkcoding/dknet-implementation-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +38 -0
  3. package/LICENSE +21 -0
  4. package/README.md +261 -0
  5. package/agents/dknet-architect.md +49 -0
  6. package/agents/dknet-bdd-engineer.md +62 -0
  7. package/agents/dknet-implementer.md +75 -0
  8. package/package.json +52 -0
  9. package/plugin.json +26 -0
  10. package/skills/README.md +47 -0
  11. package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
  12. package/skills/dknet-bdd-tests/SKILL.md +355 -0
  13. package/skills/dknet-bdd-tests/checklist.md +39 -0
  14. package/skills/dknet-crud/SKILL.md +483 -0
  15. package/skills/dknet-ddd-principles/SKILL.md +87 -0
  16. package/skills/dknet-docs/SKILL.md +296 -0
  17. package/skills/dknet-docs/checklist.md +58 -0
  18. package/skills/dknet-docs/templates/README-template.md +68 -0
  19. package/skills/dknet-docs/templates/api-reference-template.md +275 -0
  20. package/skills/dknet-docs/templates/architecture-template.md +166 -0
  21. package/skills/dknet-docs/templates/data-model-template.md +99 -0
  22. package/skills/dknet-docs/templates/events-template.md +155 -0
  23. package/skills/dknet-dto-mapping/SKILL.md +278 -0
  24. package/skills/dknet-efcore-config/SKILL.md +379 -0
  25. package/skills/dknet-endpoint/SKILL.md +458 -0
  26. package/skills/dknet-entity/SKILL.md +483 -0
  27. package/skills/dknet-feature/SKILL.md +139 -0
  28. package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
  29. package/skills/dknet-feature-remove/SKILL.md +131 -0
  30. package/skills/dknet-messaging-events/SKILL.md +395 -0
  31. package/skills/dknet-package-adoption/SKILL.md +252 -0
  32. package/skills/dknet-platform-config/SKILL.md +342 -0
  33. package/skills/dknet-project-structure/SKILL.md +148 -0
  34. package/skills/dknet-queries-specs/SKILL.md +330 -0
  35. package/skills/dknet-scaffold/SKILL.md +209 -0
  36. package/skills/dknet-unit-tests/SKILL.md +382 -0
@@ -0,0 +1,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. |