@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,342 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dknet-platform-config
|
|
3
|
+
description: Reference for everything the template wires outside a feature's own vertical slice — Program.cs start-up order, the FeatureManagement flag table, every configuration section and its defaults, launch-time jobs, the Aspire host, and the test-host overrides. Use when adding or changing a platform-level behavior (auth, rate limiting, health checks, telemetry, caching, CORS, a new flag, a new launch job) rather than a business feature.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# DKNet platform configuration
|
|
7
|
+
|
|
8
|
+
Everything a generated solution wires before a business feature exists. For a feature's own
|
|
9
|
+
vertical slice (entity → endpoint), use `dknet-project-structure` and `dknet-feature-lifecycle`
|
|
10
|
+
instead — this skill only covers cross-cutting platform wiring.
|
|
11
|
+
|
|
12
|
+
## Start-up order
|
|
13
|
+
|
|
14
|
+
`Minimal.Api/Program.cs`, in the order it actually runs:
|
|
15
|
+
|
|
16
|
+
```csharp
|
|
17
|
+
var builder = WebApplication.CreateBuilder(args);
|
|
18
|
+
var feature = builder.Configuration.GetSection(FeatureOptions.Name).Get<FeatureOptions>() ?? new FeatureOptions();
|
|
19
|
+
|
|
20
|
+
builder.AddLogConfig(feature).AddAzureAppConfig(feature);
|
|
21
|
+
|
|
22
|
+
var jobSelection = JobSelector.Select(args, JobRegistry.Jobs.Keys.ToArray());
|
|
23
|
+
if (jobSelection.HasJobName)
|
|
24
|
+
{
|
|
25
|
+
if (!jobSelection.IsRecognized) { /* print known jobs, exit 1 */ }
|
|
26
|
+
return await JobRegistry.Jobs[jobSelection.RequestedJobName!](builder);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
builder.AddFluentValidationConfig();
|
|
30
|
+
await builder.RunMigrationAsync(feature); // only if RunDbMigrationWhenAppStart
|
|
31
|
+
|
|
32
|
+
builder.Services
|
|
33
|
+
.AddOptions(builder.Configuration)
|
|
34
|
+
.AddAppConfig(feature, builder.Configuration)
|
|
35
|
+
.AddContextualRequestPopulation(); // populates [FromClaim] members
|
|
36
|
+
|
|
37
|
+
await builder.Build().UseAppConfig(a => a.UseEndpointConfigs(o =>
|
|
38
|
+
{
|
|
39
|
+
o.RequireAuthorization = feature.RequireAuthorization;
|
|
40
|
+
o.EnableVersioning = feature.EnableVersioning;
|
|
41
|
+
o.ConfigureGroup = (group, _) => group.AddFluentValidationAutoValidation();
|
|
42
|
+
}, typeof(Program).Assembly));
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Why this order matters:
|
|
46
|
+
|
|
47
|
+
- `AddLogConfig` and `AddAzureAppConfig` run **before** the job-name check, so a job (the
|
|
48
|
+
`migration` job included) resolves configuration from exactly the same sources — Azure App
|
|
49
|
+
Configuration included — as the serving path.
|
|
50
|
+
- Job dispatch happens **before any service is registered** — a job binds no HTTP listener and
|
|
51
|
+
opens no message-bus connection. See [Launch-time jobs](#launch-time-jobs).
|
|
52
|
+
- `FeatureOptions` is bound once, at the very top, from `builder.Configuration` as it stood at that
|
|
53
|
+
instant. A `WebApplicationFactory`'s `ConfigureAppConfiguration` override merges its settings
|
|
54
|
+
later — too late to change a flag. Only an `appsettings.{Environment}.json` file or a
|
|
55
|
+
`FeatureManagement__<Flag>` environment variable lands early enough. This is why
|
|
56
|
+
`TestApiFactoryBase` (below) pushes overrides through `ConfigureAppConfiguration` with an
|
|
57
|
+
in-memory collection, not through `ConfigureServices`.
|
|
58
|
+
- `RunMigrationAsync` runs after validation config but before any request-serving service is
|
|
59
|
+
registered, so a failed migration (`RunDbMigrationWhenAppStart`) throws and the process never
|
|
60
|
+
starts serving with an un-migrated schema.
|
|
61
|
+
|
|
62
|
+
`AppConfig.AddAppConfig` (service registration) and `AppConfig.UseAppConfig` (middleware) are the
|
|
63
|
+
two composition points everything else in this skill hangs off:
|
|
64
|
+
|
|
65
|
+
```csharp
|
|
66
|
+
// AddAppConfig — service registration order
|
|
67
|
+
if (EnableAntiforgery) AddAntiforgeryConfig();
|
|
68
|
+
if (RequireAuthorization && EnableDemoAuthentication) throw new InvalidOperationException(...); // mutually exclusive
|
|
69
|
+
if (RequireAuthorization) AddAuthConfig(); else if (EnableDemoAuthentication) AddDemoAuthConfig();
|
|
70
|
+
if (EnableSwagger) AddOpenApiDoc();
|
|
71
|
+
if (EnableHttps) AddHttpsConfig(configuration);
|
|
72
|
+
if (EnableRateLimit) AddRateLimitConfig(configuration);
|
|
73
|
+
if (EnableVersioning) AddAppVersioning();
|
|
74
|
+
AddForwardedHeadersConfig(features, configuration).AddSecurityHeadersConfig(features).AddRequestBoundsConfig(features, configuration);
|
|
75
|
+
AddHttpContextAccessor().AddFeatureManagement();
|
|
76
|
+
CacheConfig(configuration);
|
|
77
|
+
// Redis connection string set -> AddIdempotencyWithRedisStore, else AddIdempotentKey (in-memory)
|
|
78
|
+
AddCrosConfig(configuration).AddAllAppServices(configuration, features).AddHealthzConfig(features);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```csharp
|
|
82
|
+
// UseAppConfig — middleware order, with the reason for each position
|
|
83
|
+
UseAzureAppConfig() // logs that it's enabled
|
|
84
|
+
.UseForwardedHeadersConfig() // must rewrite RemoteIpAddress before CORS/rate-limit read it
|
|
85
|
+
.UseSecurityHeadersConfig() // wraps everything downstream (200/404/500 alike)
|
|
86
|
+
.UseAntiforgeryConfig()
|
|
87
|
+
.UseCrosConfig()
|
|
88
|
+
.UseHttpsConfig()
|
|
89
|
+
.UseHealthzConfig();
|
|
90
|
+
UseRouting();
|
|
91
|
+
UseRequestBoundsConfig();
|
|
92
|
+
UseRateLimitConfig();
|
|
93
|
+
UseAuthConfig(); // must be after UseRouting
|
|
94
|
+
// -- UseEndpointConfigs runs here (extra?.Invoke(app)) --
|
|
95
|
+
UseOpenApiDoc(); // must be after UseEndpoints
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Every `Add*Config`/`Use*Config` pair follows the same shape: `Add*` calls
|
|
99
|
+
`services.MarkConfigAdded(nameof(XConfig))` (a keyed singleton scoped to that host's own
|
|
100
|
+
`IServiceProvider` — see `HostConfigMarker`), and `Use*` checks
|
|
101
|
+
`app.Services.IsConfigAdded(nameof(XConfig))` before doing anything. This is why two
|
|
102
|
+
`WebApplicationFactory` instances built in the same test process never leak each other's feature
|
|
103
|
+
state, and why a flag that's off means the corresponding middleware is never added, not merely
|
|
104
|
+
skipped at request time.
|
|
105
|
+
|
|
106
|
+
`ServiceConfigs.AddAllAppServices` (called from inside `AddAppConfig`) wires the acting-user
|
|
107
|
+
providers (`IPrincipalProvider` as both `IDataOwnerProvider` and `ICurrentUserProvider`, see
|
|
108
|
+
`dknet-auth-and-ownership`), then `AddAppServices().AddInfraServices().AddServiceBus(...)`.
|
|
109
|
+
`ServiceConfigs.AddOptions` (called from `Program.cs`, before `AddAppConfig`) binds
|
|
110
|
+
`FeatureOptions` for `IOptions<FeatureOptions>` injection, configures JSON serializer options
|
|
111
|
+
(naming policy, ignore conditions, converters — see `SharedConsts.JsonSerializerOptions`), and
|
|
112
|
+
wires role-aware sensitive-data filtering onto the same `JsonOptions` instance via a factory
|
|
113
|
+
registration (needed because `ConfigureHttpJsonOptions` has no service-provider access).
|
|
114
|
+
|
|
115
|
+
## `FeatureManagement` flags
|
|
116
|
+
|
|
117
|
+
Section name is `FeatureManagement` (`FeatureOptions.Name`), bound in `Minimal.Api/Program.cs` via
|
|
118
|
+
`GetSection(FeatureOptions.Name).Get<FeatureOptions>()`. **Every JSON key must spell a
|
|
119
|
+
`FeatureOptions` property name exactly** — `Get<FeatureOptions>()` silently ignores an unknown key
|
|
120
|
+
instead of failing, so a typo no-ops rather than erroring.
|
|
121
|
+
|
|
122
|
+
| Flag | Class default | base `appsettings.json` | `Development` overlay | `Testing` overlay | Wires |
|
|
123
|
+
|---|---|---|---|---|---|
|
|
124
|
+
| `EnableAntiforgery` | `false` | `false` | `false` | — | `Configs/Antiforgery/AntiforgeryConfig.cs` |
|
|
125
|
+
| `EnableAzureAppConfig` | `false` | `false` | `false` | — | `Configs/AzureAppConfig/AzureAppConfigSetup.cs` |
|
|
126
|
+
| `EnableDemoAuthentication` | `false` | — (false) | **`true`** | **`true`** | `Configs/Auth/DemoAuthConfig.cs` |
|
|
127
|
+
| `EnableForwardedHeaders` | `true` | `true` | **`false`** | — | `Configs/ForwardedHeadersConfig.cs` |
|
|
128
|
+
| `EnableHealthCheck` | `true` | — | — | — | `Configs/Healthz/HealthzConfig.cs` |
|
|
129
|
+
| `EnableHttps` | `false` | **`true`** | `false` | `false` | `Configs/HttpsConfig.cs` |
|
|
130
|
+
| `EnableOpenTelemetry` | `false` | `false` | — | — | `Configs/LogConfigs.cs` |
|
|
131
|
+
| `EnableRateLimit` | `true` | `true` | `false` | `false` | `Configs/RateLimits/RateLimitConfig.cs` |
|
|
132
|
+
| `EnableRequestBounds` | `true` | `true` | **`false`** | — | `Configs/RequestBoundsConfig.cs` |
|
|
133
|
+
| `EnableSecurityHeaders` | `true` | `true` | **`false`** | — | `Configs/SecurityHeadersConfig.cs` |
|
|
134
|
+
| `EnableServiceBus` | `false` | `true` | `false` | — | `Minimal.Infra/Extensions/ServiceBusSetup.cs` (Azure child bus only) |
|
|
135
|
+
| `EnableSwagger` | `false` | `false` | `true` | — | `Configs/Swagger/SwaggerConfig.cs` |
|
|
136
|
+
| `EnableVersioning` | `true` | `true` | — | — | `Configs/VersioningConfig.cs` |
|
|
137
|
+
| `RequireAuthorization` | `false` | **`true`** | `false` | `false` | `Configs/Auth/AuthConfig.cs` |
|
|
138
|
+
| `RunDbMigrationWhenAppStart` | `false` | `false` | `true` | — | `Configs/DbMigration.cs` |
|
|
139
|
+
|
|
140
|
+
`—` means the file doesn't name the key; the value falls through to the column on its left. The
|
|
141
|
+
base file is what a deployed service runs with (no `appsettings.Production.json` ships), so it
|
|
142
|
+
always carries the secure value; relaxation lives in the `Development`/`Testing` overlays. Never
|
|
143
|
+
turn a security flag off in the base file — `SecureDefaultAppSettingsTests` (in the shipped test
|
|
144
|
+
suite) fails the build if you do.
|
|
145
|
+
|
|
146
|
+
Two mutual-exclusion rules, both enforced at start-up (`InvalidOperationException`, not a silent
|
|
147
|
+
pick): `RequireAuthorization` and `EnableDemoAuthentication` cannot both be `true`. `EnableServiceBus`
|
|
148
|
+
alone does nothing — the Azure child bus is added only when the flag is `true` **and**
|
|
149
|
+
`ConnectionStrings:AzureBus` is non-empty; the in-memory child bus that carries internal
|
|
150
|
+
command/event dispatch is unconditional.
|
|
151
|
+
|
|
152
|
+
**Adding a flag**: add the `bool` property to `Minimal.Share/Options/FeatureOptions.cs`, add the
|
|
153
|
+
same-spelled key to every `appsettings*.json` that needs a non-default value, and consume it either
|
|
154
|
+
as `features.YourFlag` inside `AppConfig.cs`/`ServiceConfigs.cs` (both already receive a
|
|
155
|
+
`FeatureOptions features` parameter) or via `IOptions<FeatureOptions>` injected anywhere else in DI.
|
|
156
|
+
|
|
157
|
+
## Configuration sections
|
|
158
|
+
|
|
159
|
+
| Section | Keys | Shipped default | Reads |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `ConnectionStrings` | `AppDb`, `Redis`, `AzureBus`, `AzureAppConfig` | all `""` except overlays | `AppDb` → `Minimal.Infra/Extensions/InfraSetup.cs`, `DbMigration.cs`; `Redis` → `CacheConfig.cs`, `AppConfig.cs` (idempotency store); `AzureBus` → `ServiceBusSetup.cs`; `AzureAppConfig` → `AzureAppConfigSetup.cs` |
|
|
162
|
+
| `Authentication:Schemes:Bearer` | `MetadataAddress`, `ValidAudiences`, `ValidIssuer` | placeholder tenant/audience | bound by ASP.NET Core's own `AddJwtBearer()`; registered only when `RequireAuthorization` is on |
|
|
163
|
+
| `Cors` | `AllowedOrigins` (`[]`), `AllowedMethods` (`GET,POST,PUT,PATCH`), `AllowedHeaders` (`Authorization,Content-Type,Accept,X-Idempotency-Key`) | empty origins ⇒ CORS not wired at all | `Configs/CrosConfig.cs` |
|
|
164
|
+
| `Security` | `TrustedProxies` (IP list), `TrustedNetworks` (CIDR list) | both `[]` | `Configs/ForwardedHeadersConfig.cs` — both empty ⇒ `ForwardedHeaders.None` |
|
|
165
|
+
| `Https` | `HstsMaxAgeDays` | `365` | `Configs/HttpsConfig.cs` — preload only when ≥ 365 |
|
|
166
|
+
| `RequestBounds` | `RequestTimeoutSeconds` (30), `MaxRequestBodySizeBytes` (1048576), `RequestHeadersTimeoutSeconds` (10) | as class defaults | `Configs/RequestBoundsConfig.cs` |
|
|
167
|
+
| `RateLimit` | `DefaultRequestLimit`, `DefaultConcurrentLimit`, `TimeWindowInSeconds` | class default `2/2/1s`; base file `100/20/1s`; Development `1/1/10s` | `Configs/RateLimits/RateLimitConfig.cs` |
|
|
168
|
+
| `AzureAppConfiguration` | `KeyPrefix`, `Label`, `CacheExpirationInSeconds`, `LoadFeatureFlags`, `FeatureFlagPrefix` | ships in base file | **dead** — see below |
|
|
169
|
+
| `AzureAppConfig` | `ConnectionStringName` (`AzureAppConfig`), `Label` (`null`→`SharedConsts.ApiName`), `LoadFeatureFlags`, `FeatureFlagPrefix`, `RefreshIntervalInMinutes` | not shipped in base file | `Configs/AzureAppConfig/AzureAppConfigSetup.cs` — the last three properties are declared but never read; refresh is hard-coded to 30 minutes |
|
|
170
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME` | flat keys, not a section | `http://localhost:4317`; `Minimal.Api` | `Configs/LogConfigs.cs` reads only the endpoint key as a presence check; `OTEL_SERVICE_NAME` is never read by template code |
|
|
171
|
+
| `AzureMonitor:ConnectionString` | — | `""` | `Configs/LogConfigs.cs` — non-blank adds `UseAzureMonitor()` |
|
|
172
|
+
| `DKNet:ListQuery` | `DefaultPageSize`, `MaxPageSize`, `DefaultActivityWindowMonths` | package defaults (1000/1000/3) | consumed by the generic `MapGetList<TEntity,TKey,TDto>` route |
|
|
173
|
+
| `SampleData:RecordsPerEntity` | — | `10000` | `Minimal.AppHost/appsettings.json`, read by `AppHost.cs` only — never present in the API |
|
|
174
|
+
|
|
175
|
+
`AzureAppConfiguration` (note the longer name) is a real trap: it ships in the base
|
|
176
|
+
`appsettings.json` but binds to nothing — `AzureAppConfigOptions.Name` is `"AzureAppConfig"`, a
|
|
177
|
+
different section name, and `KeyPrefix`/`CacheExpirationInSeconds` aren't even properties on that
|
|
178
|
+
options class. Setting any key under `AzureAppConfiguration` changes no behavior.
|
|
179
|
+
|
|
180
|
+
Override any key as an environment variable by replacing `:` with `__`, e.g.
|
|
181
|
+
`FeatureManagement__RequireAuthorization=false`. Environment variables outrank every JSON file but
|
|
182
|
+
lose to Azure App Configuration (added last, after `WebApplication.CreateBuilder(args)` has already
|
|
183
|
+
merged everything else).
|
|
184
|
+
|
|
185
|
+
## Platform features, one at a time
|
|
186
|
+
|
|
187
|
+
- **Forwarded headers** (`Configs/ForwardedHeadersConfig.cs`) — honors `X-Forwarded-For`/`-Proto`
|
|
188
|
+
only from `Security:TrustedProxies` (exact `IPAddress.Parse`) or `Security:TrustedNetworks`
|
|
189
|
+
(`IPNetwork.Parse` CIDR ranges); both empty clears the seeded loopback trust and sets
|
|
190
|
+
`ForwardedHeaders.None`, not merely an unmodified default. Must run before rate limiting, which
|
|
191
|
+
partitions on the rewritten `RemoteIpAddress`.
|
|
192
|
+
- **Security headers** (`Configs/SecurityHeadersConfig.cs`, package `OwaspHeaders.Core`) — writes
|
|
193
|
+
the OWASP set (`X-Frame-Options`, CSP, `X-Content-Type-Options`, `Referrer-Policy`,
|
|
194
|
+
`Cache-Control`, `X-Permitted-Cross-Domain-Policies`, `X-XSS-Protection`,
|
|
195
|
+
`Cross-Origin-Resource-Policy`) via `Response.OnStarting`, so it survives `Response.Clear()` in
|
|
196
|
+
the exception handler — 200, 404 and unhandled 500 all carry it. `/docs` gets a relaxed CSP
|
|
197
|
+
(`script-src 'self' 'unsafe-inline'`) for Scalar's inline bootstrap; every other path keeps the
|
|
198
|
+
strict policy. `Strict-Transport-Security` is owned by `HttpsConfig`, never duplicated here.
|
|
199
|
+
- **Request bounds** (`Configs/RequestBoundsConfig.cs`) — sets `KestrelServerOptions.Limits.MaxRequestBodySize`,
|
|
200
|
+
`RequestHeadersTimeout`, and a default `RequestTimeoutPolicy` (→ `504` on expiry). Also sets
|
|
201
|
+
`AddServerHeader = false`, unconditionally, not configurable.
|
|
202
|
+
- **CORS** (`Configs/CrosConfig.cs`) — plain config array, not a `FeatureManagement` flag; empty
|
|
203
|
+
`AllowedOrigins` means neither `AddCors` nor `UseCors` is registered. A key present but empty
|
|
204
|
+
(`[]`) for methods/headers is honored as "nothing allowed," distinct from the key being absent.
|
|
205
|
+
- **HTTPS/HSTS** (`Configs/HttpsConfig.cs`) — always adds both `UseHsts()` and
|
|
206
|
+
`UseHttpsRedirection()`; the redirect only fires when ASP.NET Core can determine an HTTPS port,
|
|
207
|
+
which the published container image does not expose by default — set `ASPNETCORE_HTTPS_PORT` if
|
|
208
|
+
you need the redirect behind a TLS-terminating ingress.
|
|
209
|
+
- **Antiforgery** (`Configs/Antiforgery/AntiforgeryConfig.cs`) — off by default in every shipped
|
|
210
|
+
file; cookie-based double-submit with `SameSite=Strict`, `Secure=Always`.
|
|
211
|
+
- **Rate limiting** (`Configs/RateLimits/*`) — chained `PartitionedRateLimiter` (fixed-window +
|
|
212
|
+
concurrency), both `QueueLimit = 0` so an over-limit request is `429` immediately, never queued.
|
|
213
|
+
Partition key: `User.Identity.Name`, else `Connection.RemoteIpAddress` (post-forwarded-headers),
|
|
214
|
+
else request host (`RateLimitKeyProvider`). Both `IRateLimitKeyProvider` and
|
|
215
|
+
`IRateLimitOptionsProvider` are replaceable DI seams. 429 is decided before authentication runs
|
|
216
|
+
(`UseRateLimitConfig` sits before `UseAuthConfig` in the pipeline).
|
|
217
|
+
- **API versioning** (`Configs/VersioningConfig.cs`) — URL-segment versioning, default `1.0`,
|
|
218
|
+
`AssumeDefaultVersionWhenUnspecified = true`.
|
|
219
|
+
- **Health checks** (`Configs/Healthz/HealthzConfig.cs`) — `/healthz` and `/` are anonymous,
|
|
220
|
+
status-only (`{"status":"..."}`, no check name/duration/exception text). `/healthz/detail` is the
|
|
221
|
+
full per-check report and requires authorization, but only when `AuthConfig` actually wired
|
|
222
|
+
`UseAuthorization()` — with `RequireAuthorization` off, `/healthz/detail` is anonymous too.
|
|
223
|
+
`HealthCheckHandler` is a template stub that always reports healthy; replace it for a real
|
|
224
|
+
readiness check.
|
|
225
|
+
- **OpenAPI/Scalar** (`Configs/Swagger/SwaggerConfig.cs`) — document + UI at `/docs`
|
|
226
|
+
(`SwaggerConfig.DocsPath`), gated on `EnableSwagger`. Outside `Development`, both require
|
|
227
|
+
authorization independent of `EnableSwagger` — but again only enforceable while `AuthConfig` is
|
|
228
|
+
wired. Health routes are hand-described into the OpenAPI document (`MapHealthChecks` leaves no
|
|
229
|
+
`MethodInfo` for ApiExplorer to see).
|
|
230
|
+
- **Logging + OpenTelemetry** (`Configs/LogConfigs.cs`) — with `EnableOpenTelemetry` off (shipped
|
|
231
|
+
default), only `#if DEBUG` console logging is added and standard `Logging:LogLevel` filtering
|
|
232
|
+
applies. On, it clears providers, adds ASP.NET Core + HttpClient tracing/metrics, and the console
|
|
233
|
+
exporter follows the **runtime environment** (`IsDevelopment()`), not the build configuration —
|
|
234
|
+
a `Release` build run as `Development` still gets console traces. OTLP and Azure Monitor exporters
|
|
235
|
+
are additive, each gated on its own connection value being non-blank.
|
|
236
|
+
- **Azure App Configuration** (`Configs/AzureAppConfig/AzureAppConfigSetup.cs`) — appended to
|
|
237
|
+
`builder.Configuration` after every other source, so it outranks environment variables and
|
|
238
|
+
command-line args too. Uses `DefaultAzureCredential`; a missing/blank connection string is a
|
|
239
|
+
silent no-op even with the flag on.
|
|
240
|
+
- **Cache** (`Configs/CacheConfig.cs`) — `AddDistributedMemoryCache()` when `ConnectionStrings:Redis`
|
|
241
|
+
is unset, else `AddStackExchangeRedisCache`; `AddHybridCache()` is always registered on top but no
|
|
242
|
+
shipped feature consumes it yet.
|
|
243
|
+
- **Idempotency store** (in `AppConfig.AddAppConfig`) — Redis-backed
|
|
244
|
+
(`AddIdempotencyWithRedisStore`) when `ConnectionStrings:Redis` is set, else in-memory
|
|
245
|
+
(`AddIdempotentKey`), both with `ConflictHandling = IdempotentConflictHandling.ConflictResponse`
|
|
246
|
+
(not `CachedResult` — verify against source if a doc says otherwise). Per-route opt-in only; see
|
|
247
|
+
`dknet-crud` and `dknet-endpoint` for `.RequiredIdempotentKey()`.
|
|
248
|
+
- **JSON options** (`ServiceConfigs.AddOptions`) — naming policy, ignore conditions and converters
|
|
249
|
+
come from `SharedConsts.JsonSerializerOptions`; role-aware sensitive-data filtering
|
|
250
|
+
(`[SensitiveData]`) is wired onto the same `JsonOptions` instance via a factory registration. Full
|
|
251
|
+
detail in `dknet-auth-and-ownership`.
|
|
252
|
+
- **Error responses** (`Configs/FluentValidationConfig.cs`, `AddErrorResponses`) — one registration
|
|
253
|
+
answers every refusal: an error code prefixed `precondition.` (`PreconditionCodes.Prefix`) → `409`;
|
|
254
|
+
an unhandled `OwnershipRequiredException` → `403`; everything else keeps its existing status. Full
|
|
255
|
+
detail in `dknet-crud`.
|
|
256
|
+
- **Auth and ownership** — see `dknet-auth-and-ownership` for `AuthConfig`/`DemoAuthConfig`, scope
|
|
257
|
+
policies, `[FromClaim]`, and `IDataOwnerProvider`/`ICurrentUserProvider`.
|
|
258
|
+
|
|
259
|
+
## Launch-time jobs
|
|
260
|
+
|
|
261
|
+
One process, one entry point. `JobSelector.Select` reads the first CLI argument that is neither an
|
|
262
|
+
option nor an option's value; `JobRegistry.Jobs` maps recognized names (case-insensitive) to
|
|
263
|
+
`Func<WebApplicationBuilder, Task<int>>`. Only `"migration"` ships, running
|
|
264
|
+
`InfraMigration.MigrateDb` and returning `0`/`1` — no host is built, no listener bound, no
|
|
265
|
+
message-bus connection opened.
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
dotnet run --project ApiEndpoints/Minimal.Api -- migration
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`RunDbMigrationWhenAppStart` is the in-process alternative: same `MigrationJob.RunAsync` call, made
|
|
272
|
+
from `Program.cs` before serving, in every environment and build configuration. Use it for a single
|
|
273
|
+
replica; use the job for anything with more than one replica racing the same migration.
|
|
274
|
+
|
|
275
|
+
Adding a job is one entry in `JobRegistry.Jobs` — no new branch in `Program.cs`, no new project.
|
|
276
|
+
Keep a job as small as `MigrationJob`: it receives the builder as it stood right after
|
|
277
|
+
`AddLogConfig`, with no DI container built yet, so it constructs what it needs directly from
|
|
278
|
+
`builder.Configuration`/`builder.Services.BuildServiceProvider()` rather than resolving from a host.
|
|
279
|
+
|
|
280
|
+
## Aspire (`Minimal.AppHost`)
|
|
281
|
+
|
|
282
|
+
`AppHost.cs` provisions `Redis` and `Postgres` (with an `AppDb` database), starts the `Api` project
|
|
283
|
+
by path (`../Minimal.Api/Minimal.Api.csproj` — a literal path string, not `AddProject<T>`, so it
|
|
284
|
+
survives `sourceName` rewriting even for a dotted name), and calls `.WaitFor(cache).WaitFor(apDb)`.
|
|
285
|
+
Azure Service Bus is **not** wired — `.WaitFor(bus)` is commented out in source; only Redis and
|
|
286
|
+
PostgreSQL resources exist. `Minimal.AppHost/Configs/busConfig.json` (an Azure Service Bus emulator
|
|
287
|
+
topology file for the `product-tp`/`product-sub` topic) sits in the project but nothing in
|
|
288
|
+
`AppHost.cs` references it — it is not currently wired to any resource.
|
|
289
|
+
|
|
290
|
+
After resources are up, `SampleDataGenerator.RunAsync` (subscribed on
|
|
291
|
+
`AfterResourcesCreatedEvent`) populates `Product` and `PurchaseOrder` with
|
|
292
|
+
`SampleData:RecordsPerEntity` records each (default `10000`; `0` or negative skips generation
|
|
293
|
+
entirely). It polls up to 60s for the migration-created schema, never tops up a database that
|
|
294
|
+
already holds rows, and generates one fixed demonstration product
|
|
295
|
+
(`Demo-Product-With-Supplier-Data`) carrying both `[SensitiveData]` supplier properties so
|
|
296
|
+
role-gated filtering is visible on a freshly started host.
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
# Full stack — Redis + PostgreSQL via Docker, sample data generated
|
|
300
|
+
dotnet run --project ApiEndpoints/Minimal.AppHost
|
|
301
|
+
|
|
302
|
+
# API only — no containers, needs ConnectionStrings:AppDb supplied yourself
|
|
303
|
+
dotnet run --project ApiEndpoints/Minimal.Api
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Test hosts
|
|
307
|
+
|
|
308
|
+
`Minimal.App.TestSupport/TestApiFactoryBase.cs` is the shared `WebApplicationFactory<Program>` both
|
|
309
|
+
xUnit and BDD suites subclass. It always: `UseEnvironment("Testing")`; pushes
|
|
310
|
+
`FeatureManagement:RunDbMigrationWhenAppStart=false`, `EnableSwagger=false`,
|
|
311
|
+
`EnableAzureAppConfig=false`, `ConnectionStrings:AppDb=UseInMemory` through
|
|
312
|
+
`ConfigureAppConfiguration` (early enough to beat the `Program.cs` bind — see
|
|
313
|
+
[Start-up order](#start-up-order)); swaps `CoreDbContext` onto `AddDbContextWithHook` +
|
|
314
|
+
`UseInMemoryDatabase` (not plain `AddDbContext`, which would silently drop the DKNet events hook);
|
|
315
|
+
and substitutes `IMembershipService` with `TestMembershipService`.
|
|
316
|
+
|
|
317
|
+
Two `protected virtual` seams: `AddFeatureOverrides(IDictionary<string,string?>)` to extend the
|
|
318
|
+
config-override set for one suite, and `ConfigureTestServices(IServiceCollection)` (call
|
|
319
|
+
`base.ConfigureTestServices` first) to swap further services. Combined with the `Testing` overlay
|
|
320
|
+
(`RequireAuthorization=false`, `EnableDemoAuthentication=true`, `EnableHttps=false`,
|
|
321
|
+
`EnableRateLimit=false`), the effective test host runs with authorization off, the demo identity
|
|
322
|
+
authenticated on every call, no HTTPS redirect, and no rate limiting — a business test that needs a
|
|
323
|
+
different combination adds its own fixture subclass rather than changing these defaults.
|
|
324
|
+
|
|
325
|
+
## Common mistakes
|
|
326
|
+
|
|
327
|
+
- **What you might expect**: setting a `FeatureManagement` flag through a `WebApplicationFactory`'s
|
|
328
|
+
`ConfigureAppConfiguration` in-memory collection changes behavior in a test.
|
|
329
|
+
**What actually happens**: it's ignored. **Why**: `Program.cs` binds `FeatureOptions` before a
|
|
330
|
+
factory's overrides are merged; only an `appsettings.{Environment}.json` file or a
|
|
331
|
+
`FeatureManagement__<Flag>` environment variable lands early enough.
|
|
332
|
+
- **What you might expect**: `AzureAppConfiguration:*` keys in `appsettings.json` configure the
|
|
333
|
+
Azure App Configuration integration. **What actually happens**: nothing — that section name binds
|
|
334
|
+
to nothing; the real section is `AzureAppConfig` (no trailing "-uration").
|
|
335
|
+
- **What you might expect**: turning `EnableServiceBus` off disables the message bus.
|
|
336
|
+
**What actually happens**: only the Azure child bus goes away; the in-memory child bus that
|
|
337
|
+
carries every internal command/event/query still runs.
|
|
338
|
+
- **What you might expect**: a validator or `[Range]` attribute on a request is what determines
|
|
339
|
+
`400` vs `201`. **What actually happens for platform wiring specifically**: FluentValidation runs
|
|
340
|
+
on every endpoint group via `AddFluentValidationAutoValidation()` in `ConfigureGroup` — that part
|
|
341
|
+
always works. What silently doesn't work is a DataAnnotations attribute on a **generated** CRUD
|
|
342
|
+
request; see `dknet-crud` and `dknet-endpoint` for the enforcement gap.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dknet-project-structure
|
|
3
|
+
description: Orientation to the DKNet.Minimal.Template layer boundaries, the six projects, the vertical-slice folder layout for a feature, and auto-discovery wiring. Use first, before any other dknet-* skill, when working in a solution generated from this template.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# DKNet project structure
|
|
7
|
+
|
|
8
|
+
Read this before touching any layer. `AGENTS.md` at the solution root carries a condensed version
|
|
9
|
+
of this same map — read it too if you're unsure which project a file belongs in.
|
|
10
|
+
|
|
11
|
+
## Layer boundaries and dependency direction
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Minimal.Api → entry point, endpoints, auth, OpenAPI
|
|
15
|
+
↓
|
|
16
|
+
Minimal.AppServices → CQRS handlers, validators, DTOs, domain event handlers
|
|
17
|
+
↓
|
|
18
|
+
Minimal.Domains → entities, aggregate roots, domain service contracts
|
|
19
|
+
↑
|
|
20
|
+
Minimal.Infra → EF Core (CoreDbContext), repos, event publisher, service bus
|
|
21
|
+
(wires into Api via InfraSetup.AddInfraServices)
|
|
22
|
+
|
|
23
|
+
Minimal.Share → shared constants/options/base types (read by all layers)
|
|
24
|
+
Minimal.AppHost → Aspire orchestration only (Redis + PostgreSQL + Minimal.Api), no business logic
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Every project reference points inward: `Minimal.Domains` references only `Minimal.Share`;
|
|
28
|
+
`Minimal.AppServices` depends only on `Domains` (+ `Share`); `Minimal.Infra` and `Minimal.Api`
|
|
29
|
+
depend on both, never the reverse. An outward reference (`Domains` → `AppServices`, say) is a
|
|
30
|
+
circular project reference MSBuild refuses outright — the compiler holds this boundary, not a test.
|
|
31
|
+
|
|
32
|
+
## The six feature-carrying projects
|
|
33
|
+
|
|
34
|
+
| Project | Owns |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `Minimal.Domains` | Entities, aggregate roots, owned types, domain service **contracts** (`IDomainService`) |
|
|
37
|
+
| `Minimal.Infra` | EF Core mappers, seed data, repositories, `EventPublisher`, service-bus topology, domain service **implementations** |
|
|
38
|
+
| `Minimal.AppServices` | Command/query requests, handlers, FluentValidation validators, specs, DTOs, domain-event consumers |
|
|
39
|
+
| `Minimal.Api` | `IEndpointConfig` route groups, auth policies, platform `Configs/` |
|
|
40
|
+
| `Minimal.Share` | Cross-cutting constants/options (`FeatureOptions`, `SharedConsts`) read by every layer |
|
|
41
|
+
| `Minimal.AppHost` | Aspire orchestration (Redis, PostgreSQL, sample-data generation) — no business logic |
|
|
42
|
+
|
|
43
|
+
`Minimal.App.Tests` (xUnit + Shouldly), `Minimal.App.BDDTests` (Reqnroll + NUnit) and
|
|
44
|
+
`Minimal.App.TestSupport` (shared `WebApplicationFactory` base) round out the solution but carry no
|
|
45
|
+
feature code of their own.
|
|
46
|
+
|
|
47
|
+
## Vertical-slice folder footprint for one feature
|
|
48
|
+
|
|
49
|
+
Every business feature is a folder name (`<Feature>`) repeated across projects. Verified against
|
|
50
|
+
the two shipped samples, `ManualSample` and `AutomatedSample`, under `ApiEndpoints/`:
|
|
51
|
+
|
|
52
|
+
| Layer | `mode=manual` | `mode=auto` |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Domains | `Minimal.Domains/Features/<Feature>/Entities/` — hand-written mutation + `AddEvent(...)` | same path — class-level `[RaisesEvent]`, `[CrudCreate]` ctor, `[CrudUpdate]`/`[CrudAction]` methods |
|
|
55
|
+
| Infra | `Minimal.Infra/Features/<Feature>/Mappers/` (`IEntityTypeConfiguration<T>`), optional `StaticData/` (seed data) | same `Mappers/` (still hand-written — no generator produces it), optional `ExternalEvents/` (broker consumers) |
|
|
56
|
+
| AppServices | `Minimal.AppServices/<Feature>/V1/Actions/`, `Queries/`, `Specs/`, `Events/`, `<Feature>Dto.cs` | `Minimal.AppServices/<Feature>/V1/<Feature>Dto.cs` (one `[GenerateDto]` line), `Events/` (consumers only — generator raises, doesn't consume), plus optional `Validators/` (precondition rules against a generated request), `Actions/` (any operation dropped out of the generated map), `Queries/`/`Specs/` (custom read shapes), and a Mapster `IRegister` for any hand-added DTO property |
|
|
57
|
+
| Api | `Minimal.Api/ApiEndpoints/<Feature>/<Entity>V1Endpoint.cs` — every route a literal `Map*` call | same file — one `group.Map<Entity>Crud(o => …)` call, plus any routes excluded from it mapped literally below |
|
|
58
|
+
| Tests | `Minimal.App.Tests/Unit/<Feature>/`, `Minimal.App.Tests/Integration/<Feature>/V1/` | same |
|
|
59
|
+
| BDD | `Minimal.App.BDDTests/Features/<Plural>/*.feature` + `Steps/*.cs` | same |
|
|
60
|
+
|
|
61
|
+
The domain/AppServices feature folder name doesn't have to match the BDD folder's plural — the two
|
|
62
|
+
samples happen to (`ManualSample`↔`PurchaseOrders`, `AutomatedSample`↔`Products`).
|
|
63
|
+
|
|
64
|
+
## Auto-discovery — what gets found without registering it
|
|
65
|
+
|
|
66
|
+
| What | Found by | Scans |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| HTTP route group | `IEndpointConfig` | `UseEndpointConfigs`, assembly scan of `Minimal.Api` |
|
|
69
|
+
| Command/query request+handler | `Fluents.Requests.*`/`Fluents.Queries.*` | `AutoDeclareFrom`/`AddServicesFromAssembly` on the in-memory bus, scanning `Minimal.AppServices` |
|
|
70
|
+
| Request validator | `AbstractValidator<TRequest>` | `AddValidatorsFromAssembly(typeof(AppSetup).Assembly, includeInternalTypes: true)` |
|
|
71
|
+
| EF Core table mapping | `IEntityTypeConfiguration<T>` | `UseAutoConfigModel([...])` — must be wired in **both** `InfraSetup.AddInfraServices` and `InfraMigration.MigrateDb` |
|
|
72
|
+
| Seed data | `DataSeedingConfiguration<T>` (base class, not an interface) | `UseAutoDataSeeding([...])` — same both-places rule; wiring only one is a real bug this template hit once |
|
|
73
|
+
| Repos/domain services | — (not auto-discovered) | Explicit `AddScoped<IService, Service>()` line in `InfraSetup.AddInfraServices`; keep implementations `internal sealed` under `Minimal.Infra/Services/` |
|
|
74
|
+
| DTO mapping | `[MapsFrom(typeof(Entity))]` or `[GenerateDto(typeof(Entity))]` | Mapster `ScanMaps()` in `Minimal.AppServices/AppSetup.cs` |
|
|
75
|
+
| Custom Mapster config | `IRegister` | `config.Scan(assembly)`, same `AppSetup.cs` |
|
|
76
|
+
| Internal event consumer | `Fluents.EventsConsumers.IHandler<TEvent>` in `AppServices` | `AddServicesFromAssembly` on the in-memory child bus |
|
|
77
|
+
| External (broker) event consumer | same interface in `Minimal.Infra/Features/<Feature>/ExternalEvents/` | `AddServicesFromAssembly` on the Azure child bus — reached only via an explicit `azb.Produce`/`azb.Consume` pair in `ServiceBusSetup.cs` |
|
|
78
|
+
|
|
79
|
+
## The two shipped samples
|
|
80
|
+
|
|
81
|
+
| Feature folder | Entity | Flow | Read for |
|
|
82
|
+
|---|---|---|---|
|
|
83
|
+
| `ManualSample` | `PurchaseOrder` | Hand-written every layer | Enforced DataAnnotations, idempotent create, `[FromClaim]` acting user, custom list filter |
|
|
84
|
+
| `AutomatedSample` | `Product` | `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]`/`[RaisesEvent]`/`[GenerateDto]` | Generated CRUD + events, composite endpoint (generated routes + hand-written ones below them) |
|
|
85
|
+
|
|
86
|
+
Full trade-off table: `dknet-feature-lifecycle` §1.
|
|
87
|
+
|
|
88
|
+
## Commands
|
|
89
|
+
|
|
90
|
+
Run from the solution root — `dotnet build`/`dotnet test` need no explicit `.sln` path:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
dotnet build -c Release
|
|
94
|
+
dotnet test --settings coverage.runsettings --collect:"XPlat Code Coverage"
|
|
95
|
+
dotnet run --project ApiEndpoints/Minimal.Api # API only
|
|
96
|
+
dotnet run --project ApiEndpoints/Minimal.AppHost # Redis + PostgreSQL via Aspire
|
|
97
|
+
|
|
98
|
+
cd ApiEndpoints
|
|
99
|
+
dotnet ef migrations add <Name> -c CoreDbContext -p Minimal.Infra/Minimal.Infra.csproj
|
|
100
|
+
dotnet ef migrations remove -c CoreDbContext -p Minimal.Infra/Minimal.Infra.csproj
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
See `dknet-scaffold` for the install/generate steps and how these project names map onto your own
|
|
104
|
+
solution's names.
|
|
105
|
+
|
|
106
|
+
## Which skill for what
|
|
107
|
+
|
|
108
|
+
Reference (read, don't invoke with args):
|
|
109
|
+
|
|
110
|
+
| Skill | For |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `dknet-project-structure` | This page — orientation, always read first |
|
|
113
|
+
| `dknet-ddd-principles` | Aggregate boundaries, entity vs. value object, when to raise a domain event |
|
|
114
|
+
| `dknet-feature-lifecycle` | Choosing manual vs. auto, a feature's full file footprint, add/remove order |
|
|
115
|
+
| `dknet-scaffold` | Installing the template, generating a solution, first run, deleting the samples |
|
|
116
|
+
| `dknet-entity` | Entity class mechanics (`AggregateRoot`, ctor rules, mutation methods) |
|
|
117
|
+
| `dknet-efcore-config` | `IEntityTypeConfiguration<T>` mappers, seed data, domain-service wiring |
|
|
118
|
+
| `dknet-crud` | Commands: requests, validators, handlers, both flows |
|
|
119
|
+
| `dknet-queries-specs` | Queries: specs, hand-written read handlers, the generic list route |
|
|
120
|
+
| `dknet-dto-mapping` | DTO shape, Mapster config, `[GenerateDto]` vs. hand-written record |
|
|
121
|
+
| `dknet-endpoint` | `IEndpointConfig`, literal routes vs. `Map<Entity>Crud()`, idempotency |
|
|
122
|
+
| `dknet-messaging-events` | Domain events, `[RaisesEvent]`, in-memory vs. Azure Service Bus |
|
|
123
|
+
| `dknet-auth-and-ownership` | Auth policies, `[FromClaim]`, `IDataOwnerProvider`/`ICurrentUserProvider` |
|
|
124
|
+
| `dknet-platform-config` | Everything else the template wires: start-up order, flags, config sections, jobs, Aspire, test hosts |
|
|
125
|
+
| `dknet-unit-tests` | xUnit + `ApiFixture` integration tests |
|
|
126
|
+
| `dknet-bdd-tests` | Reqnroll + NUnit scenarios |
|
|
127
|
+
| `dknet-docs` | Feature README + architecture diagrams |
|
|
128
|
+
| `dknet-package-adoption` | Adopting DKNet packages into a non-template project |
|
|
129
|
+
|
|
130
|
+
Workflow (invoke with `/`, takes arguments):
|
|
131
|
+
|
|
132
|
+
| Command | Does |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `/dknet-feature <Feature> <Entity> [mode=manual\|auto] [props…]` | End-to-end slice: plan → domain → CRUD → endpoint → tests → BDD → docs |
|
|
135
|
+
| `/dknet-feature-remove <Feature>` | Retire a slice end-to-end, including touchpoints and a drop migration |
|
|
136
|
+
| `/dknet-entity`, `/dknet-crud`, `/dknet-endpoint`, `/dknet-unit-tests`, `/dknet-bdd-tests`, `/dknet-docs` | Individual phases of the same lifecycle |
|
|
137
|
+
|
|
138
|
+
Subagents (Claude Code only, used by the workflow commands): `dknet-architect` (plans),
|
|
139
|
+
`dknet-implementer` (writes code across layers), `dknet-bdd-engineer` (BDD scenarios).
|
|
140
|
+
|
|
141
|
+
## Read-first ordering
|
|
142
|
+
|
|
143
|
+
1. This skill.
|
|
144
|
+
2. `dknet-ddd-principles`, if the aggregate shape or event placement isn't obvious.
|
|
145
|
+
3. `dknet-feature-lifecycle` §1, to pick manual vs. auto.
|
|
146
|
+
4. The layer skill for whatever you're about to write (`dknet-entity` → `dknet-efcore-config`
|
|
147
|
+
→ `dknet-crud`/`dknet-queries-specs`/`dknet-dto-mapping` → `dknet-endpoint`).
|
|
148
|
+
5. `dknet-platform-config` only when the change is cross-cutting, not feature-scoped.
|