Where your own code attaches to a solution scaffolded by dotnet new dknet-minimal. Each seam
below is discovered by convention or by assembly scan — none of them has a registration list you
must remember to update, apart from launch-time jobs, where the list is the
seam.
Paths use the template’s own project names (Minimal.Api, Minimal.AppServices, …); a generated
solution renames them to <YourApp>.*. For the configuration keys these seams read, see
configuration.md.
| Seam | Contract | Where you put it | How it is discovered |
|---|---|---|---|
| HTTP route group | IEndpointConfig |
Minimal.Api/ApiEndpoints/<Feature>/ |
Assembly scan by UseEndpointConfigs |
| Command / query | Fluents.Requests.* / Fluents.Queries.* |
Minimal.AppServices/<Feature>/V1/ |
AutoDeclareFrom on the in-memory bus |
| Request validation | AbstractValidator<TRequest> |
next to the request | AddValidatorsFromAssembly |
| Domain event consumer | Fluents.EventsConsumers.IHandler<TEvent> |
AppServices or Infra |
AddServicesFromAssembly |
| Query filter | Specification<TEntity> |
Minimal.AppServices/<Feature>/V1/Specs/ |
Constructed by the handler |
| EF Core mapping | IEntityTypeConfiguration<T> |
Minimal.Infra/Features/<Feature>/Mappers/ |
UseAutoConfigModel |
| Seed data | IDataSeedingConfiguration<T> |
Minimal.Infra/Features/<Feature>/StaticData/ |
UseAutoDataSeeding |
| Claims enrichment | IClaimsTransformation |
Minimal.Api/Configs/Auth/ |
Explicit DI registration |
| Authorization rule | IAuthorizationRequirement + IAuthorizationHandler |
Minimal.Api/Configs/Auth/ |
Explicit DI registration |
| Rate-limit partitioning | IRateLimitKeyProvider |
Minimal.Api/Configs/RateLimits/ |
Explicit DI registration |
| Rate-limit values | IRateLimitOptionsProvider |
Minimal.Api/Configs/RateLimits/ |
Explicit DI registration |
| Domain service | IDomainService and friends |
Minimal.Domains/Services/ + Minimal.Infra/Services/ |
Explicit DI registration |
| Launch-time job | Func<WebApplicationBuilder, Task<int>> |
Minimal.Api/Configs/Jobs/ |
One entry in JobRegistry.Jobs |
| Test host | TestApiFactoryBase |
Minimal.App.TestSupport/ |
Subclass it |
IEndpointConfig (DKNet.AspCore.Extensions) is the only route-registration seam. Implement it
and the route group exists — Minimal.Api/Program.cs calls UseEndpointConfigs(...) once, which
scans the API assembly, builds one versioned RouteGroupBuilder per implementation, and calls
Map. There is no registration list anywhere in the template.
internal sealed class PurchaseOrderV1Endpoint : IEndpointConfig
{
public int Version => 1; // → /v1/...
public string GroupEndpoint => "/purchase-orders";
public void Map(RouteGroupBuilder group) { /* literal MapPost/MapGet/... */ }
}
Two further members carry defaults the template never overrides, and both are yours to set:
| Member | Default | What it controls |
|---|---|---|
string? AuthPolicy |
null |
The authorization policy applied to the whole group. null means RequireAuthorization() with no named policy. Ignored entirely when FeatureManagement:RequireAuthorization is false. This is where you would attach HasScopeRequirement.PolicyName. |
string Tag |
GroupEndpoint with / replaced by - — "/products" becomes "products" |
The OpenAPI tag the group’s routes are grouped under. |
Two rules the template’s own endpoints follow, both enforced by
Minimal.App.Tests/Architecture/ApiTests.cs:
internal sealed (abstract bases may stay non-sealed).Map* call literal. .NET 10’s validation source generator only sees literal
Map*(string, Delegate) calls in the compiling project’s own source; a route registered through
a generic library wrapper silently loses DataAnnotations validation. See
api-pipeline.md and
samples/manual-vs-automated.md.The Program.cs call also sets ConfigureGroup, which runs for every discovered group after the
contextual-population filter and before authorization. The template uses it for exactly one thing
— group.AddFluentValidationAutoValidation() — and it is where any other cross-cutting endpoint
filter belongs:
o.ConfigureGroup = (group, _) => group.AddFluentValidationAutoValidation();
A request is a record implementing one of the DKNet.SlimBus.Extensions fluent contracts; the
handler is an internal sealed class implementing the matching handler interface. Both are
discovered by AutoDeclareFrom/AddServicesFromAssembly on the in-memory child bus
(Minimal.Infra/Extensions/ServiceBusSetup.cs), scanning the Minimal.AppServices assembly. No
per-message registration.
| Shape | Request contract | Handler contract |
|---|---|---|
| Command returning a DTO | Fluents.Requests.IWitResponse<TDto> |
Fluents.Requests.IHandler<TRequest, TDto> |
| Command with no response | Fluents.Requests.INoResponse |
Fluents.Requests.IHandler<TRequest> |
| Query returning one item | Fluents.Queries.IWitResponse<TDto> |
Fluents.Queries.IHandler<TRequest, TDto> |
| Query returning a page | Fluents.Queries.IWitPageResponse<TDto> |
Fluents.Queries.IPageHandler<TRequest, TDto> |
| Domain event consumer | a plain sealed record |
Fluents.EventsConsumers.IHandler<TEvent> |
The handler method is OnHandle(TRequest, CancellationToken), not Handle. Handlers never call
SaveChanges — the SlimBus EF Core interceptor does, after the handler returns.
The validator convention. Declare an AbstractValidator<TRequest> and it runs; there is no
opt-in call on the route. Minimal.Api/Configs/FluentValidationConfig.cs registers every validator
in the Minimal.AppServices assembly, internal types included:
builder.Services.AddValidatorsFromAssembly(typeof(AppSetup).Assembly, includeInternalTypes: true);
Two constraints come with the convention:
Minimal.AppServices assembly. One placed in Minimal.Api is
never registered.internal sealed, enforced by Minimal.App.Tests/Architecture/AppServiceTests.cs.Co-locate it with the request it validates, as ListPurchaseOrders.cs does.
Both seams are registered by Minimal.Api/Configs/Auth/AuthConfig.cs, and both ship as samples
marked TODO in source — replace them rather than adding a parallel set.
Claims transformation. SampleClaimsTransformation implements IClaimsTransformation and is
registered scoped. ASP.NET Core invokes it on every authentication event, so it is the seam for
enriching the principal — roles from a database, normalised claim types, a tenant identifier. It
may be called more than once per request, so guard against adding a claim twice; the sample shows
the guard.
Authorization policy. HasScopeRequirement (an IAuthorizationRequirement) plus
HasScopeHandler (an AuthorizationHandler<HasScopeRequirement>) implement the sample
SampleScopePolicy, which checks the scp/scope claim for a required scope. The policy is
registered but applied to no shipped route — attach it with
.RequireAuthorization(HasScopeRequirement.PolicyName) on a route, or by returning that constant
from an endpoint’s AuthPolicy.
The acting user. IPrincipalProvider (Minimal.AppServices/Share/IPrincipalProvider.cs)
extends DKNet’s IDataOwnerProvider and ICurrentUserProvider with ProfileId, Email and
UserName. The implementation, Minimal.Api/Configs/Handlers/PrincipalProvider.cs, resolves both
the ownership key (GetOwnershipKey(), the source of OwnedBy) and the acting user
(GetCurrentUser(), the source of CreatedBy/UpdatedBy) from the first non-empty of
http://schemas.microsoft.com/identity/claims/objectidentifier, oid, ClaimTypes.NameIdentifier,
sub. Replace the implementation to change how identity is read;
.AddDataOwnerProvider<CoreDbContext, PrincipalProvider>() and
.AddCurrentUserProvider<CoreDbContext, PrincipalProvider>() in ServiceConfigs.cs are the two
registration points. Full behaviour:
auditing-and-data-ownership.md.
Reading a claim into a request. Mark a request property [FromClaim(...)] and the endpoint
pipeline overwrites it from the caller’s claims before validation and before the handler — a
security seam, not a binding convenience. It never reaches a generated CRUD request, because the
generator forwards only System.ComponentModel.DataAnnotations attributes.
Two public interfaces sit behind the limiter, both registered in
Minimal.Api/Configs/RateLimits/RateLimitConfig.cs and both meant to be replaced:
| Interface | Default implementation | Replace it to |
|---|---|---|
IRateLimitKeyProvider |
RateLimitKeyProvider — User.Identity.Name, then remote IP, then request host |
Partition by tenant, subscription tier, API key, or route instead of caller identity. Registered as a singleton. |
IRateLimitOptionsProvider |
RateLimitOptionsProvider — returns the same RateLimit section values for everyone |
Return per-caller limits: look the caller’s plan up and vary PermitLimit/Window. Registered as scoped, so it can inject request-scoped services. |
Both are resolved per request from HttpContext.RequestServices, so a replacement takes effect
without touching the limiter wiring itself.
An entity carrying [CrudCreate]/[CrudUpdate]/[CrudAction] plus a [GenerateDto] DTO gets its
requests, handlers and routes emitted by DKNet.SlimBus.Generators. Full mechanics:
crud-attributes.md. Two seams are worth knowing here.
Dropping and configuring routes. The generated Map<Entity>Crud() takes an optional
Action<CrudMapOptions>, the one knob on the generated method. It drops routes and applies settings
to the routes that stay:
public void Map(RouteGroupBuilder group)
{
group.MapProductCrud(o => o
.Exclude("Discontinue") // drop that one action
.Configure(CrudOp.Update, b => b.WithTags("products-write"))); // every generated PUT
group.MapPut("{id:guid}/discontinue", /* writes two aggregates in one transaction */);
}
Exclude has two overloads — params CrudOp[] (GetById, GetList, Create, Update, Delete,
Action) and params string[] route names — so an Update or an Action can be dropped one
member at a time, not only as a whole kind. Configure mirrors that pair, attaching any
RouteHandlerBuilder setting to an operation kind or to one named route. A route’s name is
GetById, GetList, Create or Delete, or the verbatim C# member name of a
[CrudUpdate]/[CrudAction] member; an unknown name throws ArgumentException at start-up. Full
rules: crud-attributes.md.
Nothing is excluded by default. The template’s own ProductV1Endpoint is the composite shape this
seam exists for — MapProductCrud(o => …) first, carrying the per-route scopes and one
Exclude("Discontinue"), then two hand-written business routes below it.
Minimal.App.Tests/Architecture/SampleInvariantTests.cs pins that sample’s shape — change it and
that test tells you.
Narrowing the response. Filter, search and order on the generated list route resolve against
the DTO, never the entity, so [GenerateDto(..., Exclude = [...])] is the query-surface control as
well as the response-shape control. See
generic-list-endpoint.md.
Adding a response value the convention cannot produce. [GenerateDto] emits a partial record,
so a value it cannot derive — anything computed from more than one column — can be hand-written onto
the DTO and mapped by a Mapster IRegister. ForType merges onto the convention’s config instead of
replacing it, and config.Scan in Minimal.AppServices/Extensions/MapsToExtensions.cs:34-37
discovers the register with no per-DTO wiring. The customisation stays a response-mapping concern:
no generated route, request or handler is written or dropped for it, and the added property is
response-only, so the list route refuses to filter or order on it with a 400. Worked example —
ProductDto.GrossMargin:
automated sample.
| Seam | Contract | Discovery |
|---|---|---|
| Table mapping | IEntityTypeConfiguration<T>, usually via DefaultEntityTypeConfiguration<T> |
UseAutoConfigModel([...]) scans Minimal.Infra and Minimal.Domains |
| Seed data | IDataSeedingConfiguration<T>, usually via DataSeedingConfiguration<T> |
UseAutoDataSeeding([...]) scans Minimal.Infra |
| Named sequences | a [SqlSequence] enum plus a SequenceService subclass |
UseAutoConfigModel registers the enum’s sequences on PostgreSQL |
Both scans are wired in two places that build a CoreDbContext, and both must carry the call:
Minimal.Infra/Extensions/InfraSetup.cs (AddInfraServices, the DI host path) and
Minimal.Infra/Extensions/InfraMigration.cs (MigrateDb, the startup-migration path). Wiring only
one is a bug this template already hit.
Mapping and seeding classes must be internal sealed
(Minimal.App.Tests/Architecture/InfraTests.cs), every mapped string needs an explicit
HasMaxLength, and every mapped enum needs HasConversion<string>().
CoreDbContext itself carries one guard you can rely on but should not bypass:
EnsureOwnershipResolvable throws OwnershipRequiredException before EF Core attempts an insert
that would leave a required CreatedBy unset, and the StatusCode branch of the
AddErrorResponses(...) registration in Minimal.Api/Configs/FluentValidationConfig.cs turns that
into a 403, not a 500.
Minimal.Domains/Services/ holds the contracts, Minimal.Infra/Services/ the implementations,
and InfraSetup.AddInfraServices the registration. The shipped chain is deliberately thin:
public interface IDomainService; // marker
public interface ISequenceServices : IDomainService { ValueTask<string> NextValueAsync(); }
public interface IMembershipService : ISequenceServices; // one sequence, one interface
Add your own the same way: an interface in Minimal.Domains/Services/ so the domain can depend on
it, an implementation in Minimal.Infra/Services/, and one AddScoped line in AddInfraServices.
Keeping the contract in Domains is what lets a domain method call the service without the domain
layer knowing about infrastructure.
Minimal.Infra/Extensions/ServiceBusSetup.cs is the single place topology is declared. To forward
an event to Azure Service Bus, add the produce/consume pair next to the existing
ProductCreatedEvent lines and write the consumer under
Minimal.Infra/Features/<Feature>/ExternalEvents/:
azb.Produce<TEvent>(o => o.DefaultTopic("<topic>"));
azb.Consume<TEvent>(o => o.Path("<topic>")
.SubscriptionName("<subscription>")
.WithConsumer<THandler>());
No DI registration is needed — azb.AddServicesFromAssembly(typeof(InfraSetup).Assembly) picks the
consumer up. TopologyProvisioning.Enabled is false, so provision the topic and subscription
yourself. Detail: slimbus-messaging.md.
Minimal.Api/Configs/Healthz/HealthzConfig.cs registers an EF Core connectivity check plus
HealthCheckHandler, and maps them at /healthz and /. HealthCheckHandler is a template stub
that always reports healthy — implement IHealthCheck and add it in AddHealthzConfig for a real
readiness probe. The whole block is gated on FeatureManagement:EnableHealthCheck.
A scaffolded service can be launched to run a job instead of serving — dotnet <Name>.Api.dll
migration, or the same container image with args: ["migration"]. Which names it accepts is
Minimal.Api/Configs/Jobs/JobRegistry.cs, and adding a second job is one entry in that dictionary:
public static IReadOnlyDictionary<string, Func<WebApplicationBuilder, Task<int>>> Jobs { get; } =
new Dictionary<string, Func<WebApplicationBuilder, Task<int>>>(StringComparer.OrdinalIgnoreCase)
{
["migration"] = MigrationJob.RunAsync,
["reindex"] = ReindexJob.RunAsync
};
A job is a Func<WebApplicationBuilder, Task<int>>: it is handed the builder as it stood right after
AddLogConfig ran (Program.cs), so it reads both the configuration the process was started with
(builder.Configuration) and the logging pipeline already configured on it, does its work, and
returns the process exit code — 0 for success, anything else for failure, which is what a
Kubernetes Job acts on. It runs before any host is built, so there is no DI container to resolve
from: build what you need out of the builder you are given, the way MigrationJob does. Keep it as
small as MigrationJob is — a job that needs the full application is a request the service should
be serving, not a job.
Nothing else changes when you add one. The start-up path gains no branch, the solution gains no
project, and the deployment gains no second image — the new name is simply recognised. How a name is
selected from the arguments, and what happens when it is not recognised:
template-usage.md.
Minimal.App.TestSupport/TestApiFactoryBase.cs is the shared WebApplicationFactory<Program> both
suites use. It boots the Testing environment, swaps CoreDbContext onto EF Core InMemory (via
AddDbContextWithHook, so domain events still publish) and substitutes IMembershipService. Two
protected virtual members are the seams:
| Member | Use it to |
|---|---|
AddFeatureOverrides(IDictionary<string, string?>) |
Add configuration entries for one suite. Extends the base set rather than replacing it. |
ConfigureTestServices(IServiceCollection) |
Swap further services. Call base.ConfigureTestServices(services) first. |
Feature flags are the exception: Program.cs binds FeatureOptions before these overrides merge,
so a flag must come from appsettings.Testing.json or a FeatureManagement__<Flag> environment
variable. The shipped fixtures — AuthOnApiFixture, AuthOnMultiSubjectApiFixture,
AuthOnNoNameClaimApiFixture, VersioningOffApiFixture — show the pattern; add a variant only when
a business-domain test genuinely needs a different host.
These are checked by Minimal.App.Tests/Architecture/ (layer and sample rules) and by
tests/DKNet.Templates.ScaffoldTests/ (packaging, package pinning and appsettings-contract guards,
which live outside src/ so they never ship to a consumer), so a violation fails the test run
rather than review.
| Rule | Test |
|---|---|
Endpoint, handler, validator, EF-config and seeding classes are internal and sealed |
ApiTests, AppServiceTests, InfraTests |
Configs classes are static and [ExcludeFromCodeCoverage] |
ApiTests |
Every mapped string property has an explicit max length |
InfraTests.NoEntityString_ShouldBe_ConfiguredAs_Max |
Every mapped enum is stored with HasConversion<string>() |
InfraTests.AllEnumProperties_StoringToDb_ShouldHaveStringConversion |
No public property on a record has a private setter |
RecordArchitectureTests |
| Npgsql only — no SQL Server EF Core, Testcontainers or Aspire hosting package | PackageArchitectureTests, MigrationSchemaTests |
Every PackageReference is version-less; every DKNet version pin is referenced by a project; all DKNet packages resolve to one release |
PackageArchitectureTests |
| JWT signature validation is never disabled in the API source | JwtSignatureValidationTests |
The base appsettings.json never turns a security flag off, and always carries an explicit RateLimit section |
SecureDefaultAppSettingsTests |
Every FeatureManagement key in every appsettings file binds to a FeatureOptions property, and every property is read by production code |
FeatureOptionsBindingTests, FeatureFlagContractTests |
| The two samples never reference each other | SampleInvariantTests.SampleAreas_ShouldNotCrossReferenceEachOther |
The layer boundary itself is not one of these. Minimal.Domains references only
Minimal.Share; Minimal.AppServices references only Domains; Infra and Api reference
inward. Because every reference already points inward, adding an outward one — Domains to
AppServices, say — is a circular project reference that MSBuild refuses, so the compiler is what
holds the boundary, not a test.