How to add one vertical-slice feature to a solution generated by dotnet new dknet-minimal,
end to end — domain entity, EF Core mapping, domain event, application action, endpoint,
and its tests.
Two shipped samples ground every step below, built two different ways:
ManualSample/PurchaseOrder — every layer hand-written. This guide’s primary
walkthrough; copy this when a feature needs request validation that’s actually enforced,
idempotency, a conditional business rule, a filtered query, or a response shape you control.AutomatedSample/Product — entity/events/CRUD declared via attributes, everything the
generators can produce is produced. Called out inline wherever it diverges from the manual
walkthrough.Read docs/samples/manual-vs-automated.md first — it states,
layer by layer, what the automated shape costs you before you pick it.
Path of truth:
src/ApiEndpoints/(inner projects are prefixedMinimal.*in this repo; a generated solution renames them to<YourApp>.*).
Each stage below adds one layer of the feature, in the order you’ll actually write it.
| # | Stage | Where it lives |
|---|---|---|
| 1 | Layer map | — |
| 2 | Domain entity | Minimal.Domains/Features/<Feature>/Entities/ |
| 3 | EF Core mapping | Minimal.Infra/Features/<Feature>/Mappers/ |
| 4 | Application action | Minimal.AppServices/<Feature>/V1/Actions/ |
| 5 | Query specs | Minimal.AppServices/<Feature>/V1/Specs/ |
| 6 | DTO and Mapster wiring | Minimal.AppServices/<Feature>/V1/<Feature>Dto.cs |
| 7 | Lazy mapping | DKNet.SlimBus.Extensions.LazyMapper |
| 8 | Domain event and handler | Minimal.AppServices/<Feature>/V1/Events/ |
| 9 | Endpoint | Minimal.Api/ApiEndpoints/<Feature>V1Endpoint.cs |
| 10 | Unit / integration tests | Minimal.App.Tests/Integration/<Feature>/V1/ |
| 11 | BDD tests | Minimal.App.BDDTests/Features/<Domain>/ |
Each section below also calls out an “automated alternative”: what AutomatedSample/Product
does instead of that stage, and what it gives up by skipping it.
Minimal.Api entry point, endpoints, auth, OpenAPI
↓
Minimal.AppServices CQRS actions: request + validator + handler, DTOs, event handlers
↓
Minimal.Domains entities, aggregate roots
↑
Minimal.Infra EF Core mapping, seed data, repositories, event publisher, message bus
Minimal.Share constants/options read by every layer
Minimal.AppHost Aspire orchestration only (Redis + PostgreSQL + Api project)
No layer skips another. Api never references Domains/Infra directly — it dispatches
through IMessageBus (SlimMessageBus) to an AppServices handler.
Minimal.Domains/Features/<Feature>/Entities/Reference: Minimal.Domains/Features/ManualSample/Entities/PurchaseOrder.cs
public sealed class PurchaseOrder : AggregateRoot
{
public PurchaseOrder(string customerName, decimal amount, string byUser) : base(byUser)
{
CustomerName = customerName;
Amount = amount;
Status = PurchaseOrderStatus.Placed;
AddEvent(new PurchaseOrderCreatedEvent(Id, CustomerName, Amount));
}
public string CustomerName { get; private set; } = null!;
public decimal Amount { get; private set; }
public PurchaseOrderStatus Status { get; private set; }
public void ChangeAmount(decimal amount, string userId) { Amount = amount; SetUpdatedBy(userId); }
public void Cancel(string userId) { Status = PurchaseOrderStatus.Cancelled; SetUpdatedBy(userId); }
}
Rules that matter:
AggregateRoot → DomainEntity → AuditedEntity<Guid> (from DKNet.EfCore.Abstractions)
supplies Id, CreatedBy, CreatedOn, UpdatedBy, UpdatedOn — don’t redeclare them.
base(byUser) stamps CreatedBy immediately at construction.private. All mutation goes through entity methods (ChangeAmount,
Cancel) — never expose a public setter and never mutate from a handler.
Minimal.App.Tests/Architecture/* (NetArchTest) enforces shape rules like this. A public
setter or a non-sealed/non-internal handler fails the architecture test suite, not just
review.AppServices slice (ManualSample, AutomatedSample) — pick
whichever reads better for your feature.Automated alternative (Minimal.Domains/Features/AutomatedSample/Entities/Product.cs): the
entity carries three class-level [RaisesEvent] declarations — Created with an Include list,
and Updated narrowed to Price and to IsDiscontinued — instead of an AddEvent call, a
[CrudCreate] constructor instead of a plain one, a [CrudUpdate] method instead of a loose
ChangeAmount-style method, and [CrudAction] methods for its two named domain operations. It
also implements IOwnedBy, so DataOwnerHook stamps OwnedBy on insert and DKNet’s global read
filter isolates rows per owner. No hand-written event record, no AddEvent call anywhere in that
sample — see docs/samples/manual-vs-automated.md for exactly what those attributes generate
and what they cost.
Minimal.Infra/Features/<Feature>/Mappers/Reference: Minimal.Infra/Features/ManualSample/Mappers/PurchaseOrderConfigs.cs
internal sealed class PurchaseOrderConfigs : DefaultEntityTypeConfiguration<PurchaseOrder>
{
public override void Configure(EntityTypeBuilder<PurchaseOrder> builder)
{
base.Configure(builder);
builder.HasIndex(p => p.CustomerName);
builder.Property(p => p.CustomerName).HasMaxLength(200).IsRequired();
builder.Property(p => p.Amount).HasPrecision(18, 2);
builder.Property(p => p.Status).HasConversion<string>();
builder.ToTable("PurchaseOrders", "manual_sample");
}
}
DbSet and no manual modelBuilder.ApplyConfiguration(...) call anywhere. Every
IEntityTypeConfiguration<T> under the assembly is picked up automatically by
UseAutoConfigModel, wired in both Minimal.Infra/Extensions/InfraSetup.cs
(AddInfraServices, the DI host path) and InfraMigration.cs (MigrateDb, the
startup-migration path). Wiring only one of the two is a real bug this template hit once
already — seed data silently didn’t appear over HTTP. The class must stay internal sealed
(architecture tests check this) so it’s only ever reached through the scan, never referenced
directly.string property an explicit HasMaxLength. An unconstrained string fails
InfraTests and, on PostgreSQL, would otherwise map to unbounded text.HasConversion<string>()). An architecture test
(AllEnumProperties_StoringToDb_ShouldHaveStringConversion) enforces this on every enum property.IDataSeedingConfiguration<T> next to the mapper, under
StaticData/ (see Minimal.Infra/Features/ManualSample/StaticData/PurchaseOrderStaticData.cs).
It’s discovered the same way, via UseAutoDataSeeding.src/ApiEndpoints/:
./add-migration.sh <Name> (wraps dotnet ef migrations add -c CoreDbContext -p Minimal.Infra/Minimal.Infra.csproj).This layer is hand-written for both samples — none of the three generators in this template
([RaisesEvent], [CrudCreate]/[CrudUpdate], [GenerateDto]) touches IEntityTypeConfiguration<T>.
Minimal.AppServices/<Feature>/V1/Actions/Reference: Minimal.AppServices/ManualSample/V1/Actions/Create.cs
Each action file holds three things, in this order:
Fluents.Requests.IWitResponse<TDto> (or INoResponse for
delete-style commands with no return value). Package: DKNet.SlimBus.Extensions.
[FromClaim(ClaimTypes.Name)] on ByUser auto-populates the acting user from the bearer
token via AddContextualRequestPopulation (wired in Program.cs). This is the single
mechanism, and it sets ByUser unconditionally, so a caller-supplied value in the body or
query string is always discarded, never trusted.internal sealed class XCommandValidator : AbstractValidator<XRequest>
(FluentValidation). It runs automatically because every endpoint group calls
AddFluentValidationAutoValidation() — you never call Validate() yourself. This only fires
for literal Map* route registrations (see §9). A route mapped through a generic library
wrapper never reaches it.internal sealed class XCommandHandler(...) : Fluents.Requests.IHandler<XRequest, TDto>,
constructor-injecting whatever it needs (IRepositorySpec, IMapper, feature-specific
services). OnHandle does the real work:
Specification<TEntity> for lookup/uniqueness checks (see §5).repository.AddAsync(entity, ct) / repository.Delete(entity). No SaveChanges inside the
handler; the DKNet SlimBus pipeline calls it after the handler returns.mapper.ResultOf<TDto>(entity) (from DKNet.SlimBus.Extensions.LazyMapper, see §7)
rather than Result.Ok(mapper.Map<TDto>(entity)) when the DTO needs a value only available
after SaveChanges — for example, a DB-generated Id. Use the latter (see Update.cs) when
nothing about the DTO depends on that.Handler and validator classes must be internal sealed — same architecture-test rule as the
EF config.
Automated alternative: none of this section exists for AutomatedSample/Product.
[CrudCreate] on the entity’s constructor and [CrudUpdate] on a method generate the request,
validator-equivalent (DataAnnotations forwarded from the constructor/method parameters), and
handler for you — see Minimal.AppServices/obj/Generated/DKNet.SlimBus.Generators/.../ProductCrudRequests.g.cs
and ...Handlers.g.cs after a build. The forwarded DataAnnotations attributes are not enforced
under this template’s endpoint-registration convention — see §9 and the comparison doc’s “Request
validation” row before relying on one.
Minimal.AppServices/<Feature>/V1/Specs/Reference: Minimal.AppServices/ManualSample/V1/Specs/SpecGetPurchaseOrder.cs
internal sealed class SpecGetPurchaseOrder : Specification<PurchaseOrder>
{
public SpecGetPurchaseOrder(Guid? byId = null, string? byCustomerName = null)
{
var predicate = CreatePredicate();
if (byId is not null) predicate = predicate.And(a => a.Id == byId);
if (!string.IsNullOrEmpty(byCustomerName)) predicate = predicate.And(a => a.CustomerName == byCustomerName);
// An unstarted predicate builder compiles to WHERE FALSE — force a true predicate when
// no filter is supplied, or "list everything" silently returns nothing instead.
if (byId is null && string.IsNullOrEmpty(byCustomerName)) predicate = predicate.And(_ => true);
WithFilter(predicate);
}
}
One spec class per query shape, built from DKNet.EfCore.Specifications. Handlers ask
IRepositorySpec for AnyAsync(spec, ...) / FirstOrDefaultAsync(spec, ...) /
ToPagedListAsync(spec, ...) — never write a raw LINQ query against the DbContext from a
handler. The WHERE FALSE gotcha above is a real bug this template hit once already, when a
spec’s predicate builder was never given a starting clause. Check for it in any spec that
supports “no filter at all” as a valid call shape.
Automated alternative: no hand-written spec exists for Product. GetById/GetList/Delete
map to DKNet.AspCore.Extensions’s generic MapGetById<TEntity,TKey,TDto>/MapGetList/
MapDeleteById, which query directly against IEntity<TKey> with no per-entity spec at all. The
generated list route is not unfiltered, though: it exposes a uniform
filter/search/orderBy/desc/pageNumber/pageSize query surface resolved against the DTO —
the full contract is generic-list-endpoint.md. What it cannot do is
express a bespoke predicate the way a hand-written spec can.
Minimal.AppServices/<Feature>/V1/<Feature>Dto.csReference (hand-written): Minimal.AppServices/ManualSample/V1/PurchaseOrderDto.cs
public sealed record PurchaseOrderDto
{
public Guid Id { get; init; }
public string CustomerName { get; init; } = null!;
public decimal Amount { get; init; }
public PurchaseOrderStatus Status { get; init; }
public string CreatedBy { get; init; } = null!;
}
Hand-writing the DTO means the response exposes exactly the fields you list — nothing more.
Mapster’s global config in AppServices/AppSetup.cs (TypeAdapterConfig.GlobalSettings) maps it
to/from the entity by convention (matching property names); no per-feature mapping file to write.
Automated alternative (Minimal.AppServices/AutomatedSample/V1/ProductDto.cs):
[GenerateDto(typeof(Product),
Exclude = [nameof(Product.OwnedBy), nameof(AuditedEntity<Guid>.LastModifiedBy), nameof(AuditedEntity<Guid>.LastModifiedOn)])]
public sealed partial record ProductDto;
One declaration. [GenerateDto] (source generator, DKNet.EfCore.DtoGenerator) emits every
audited property from the entity at compile time, so the default is “everything audited”, not “only
what I chose to expose”. The sample narrows it with Exclude, leaving Name, Price,
IsDiscontinued, CreatedBy, CreatedOn, UpdatedBy, UpdatedOn and Id — verified against
obj/Generated/.../ProductDto.g.cs. Decide explicitly what your entity should expose before
reaching for this shape; on a generated CRUD slice the DTO is also the filter/search/order surface,
so it is a query boundary as well as a response shape.
DKNet.SlimBus.Extensions.LazyMappermapper.ResultOf<TDto>(entity) wraps the entity in an IResult<TDto> that only calls
mapper.Map<TDto>(entity) when the caller reads .Value — that is, after the pipeline’s
SaveChangesAsync() has run and any DB-generated values (sequence-assigned IDs, defaults) are
populated on the entity. Use it for create actions. A plain Result.Ok(mapper.Map<TDto>(entity))
(see Update.cs) is fine when nothing about the DTO depends on SaveChanges having run yet. This
template previously carried a private copy of this helper under AppServices/Extensions/LazyMapper;
it now uses the package’s own version — both samples’ generated and hand-written handlers alike
call DKNet.SlimBus.Extensions.LazyMapper.LazyMapExtensions.ResultOf<T>(...).
Minimal.AppServices/<Feature>/V1/Events/Reference (hand-raised): Minimal.Domains/Features/ManualSample/Entities/PurchaseOrderCreatedEvent.cs
Minimal.AppServices/ManualSample/V1/Events/PurchaseOrderCreatedEventHandler.cspublic sealed record PurchaseOrderCreatedEvent(Guid Id, string CustomerName, decimal Amount);
internal sealed class PurchaseOrderCreatedEventHandler(ILogger<PurchaseOrderCreatedEventHandler> logger)
: Fluents.EventsConsumers.IHandler<PurchaseOrderCreatedEvent>
{
public Task OnHandle(PurchaseOrderCreatedEvent notification, CancellationToken cancellationToken) { ... }
}
sealed record. entity.AddEvent(...) in the constructor/method queues it;
Minimal.Infra/Services/EventPublisher.cs forwards every queued event onto
IMessageBus.Publish(...) after SaveChanges.Fluents.EventsConsumers.IHandler<TEvent>. Put it in
AppServices for in-process concerns such as logging, or in Infra
(Minimal.Infra/Features/<Feature>/ExternalEvents/, e.g. ProductCreatedNotificationHandler.cs)
for anything backed by an external system. No manual DI registration is needed — both projects’
assemblies are scanned by AddServiceBus in Minimal.Infra/Extensions/ServiceBusSetup.cs.FeatureManagement:EnableServiceBus is true and
ConnectionStrings:AzureBus is non-empty. Wire a subscriber’s
Produce<TEvent>/Consume<TEvent> topic/subscription in ServiceBusSetup.cs the same way
ProductCreatedEvent → product-tp/product-sub is wired for ProductCreatedNotificationHandler.Automated alternative: no hand-written event record exists for Product. Its three
class-level [RaisesEvent] declarations — Created with an Include list, Updated narrowed to
Price, and Updated narrowed to IsDiscontinued — compose ProductCreatedEvent,
ProductPriceUpdatedEvent and ProductIsDiscontinuedUpdatedEvent at compile time. Note that the second name folds
the narrowing property in (Product+Price+Updated+Event) — it is not
ProductUpdatedEvent. Neither type has source you can read; confirm a composed name against the
compiled assembly (strings bin/**/Minimal.Domains.dll | grep <Entity>) before wiring a consumer.
The generator raises the event automatically via DKNet’s EF Core save hook — nothing in the sample
calls AddEvent. A hand-written consumer is still required either way; the generator only
declares and raises, it never generates one.
Minimal.Api/ApiEndpoints/<Feature>V1Endpoint.csReference (hand-mapped): Minimal.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs
internal sealed class PurchaseOrderV1Endpoint : IEndpointConfig
{
public int Version => 1;
public string GroupEndpoint => "/purchase-orders";
public void Map(RouteGroupBuilder group)
{
group.MapPost("/", async (CreatePurchaseOrderRequest req, IMessageBus bus, CancellationToken ct) =>
{
var result = await bus.Send(req, cancellationToken: ct);
return result.Response(isCreated: true);
})
.RequiredIdempotentKey();
// ...GET "/", GET "{id:guid}", PUT "{id:guid}", POST "{id:guid}/cancel", DELETE "{id:guid}"
}
}
IEndpointConfig comes from the DKNet.AspCore.Extensions NuGet package, not this repo. Every
route here is a literal call against the raw minimal-API surface (group.MapPost(...), etc.).
That literalness matters: it’s what lets .NET 10’s automatic validation source generator see the
route at all (see the callout below).IEndpointConfig in the API assembly is discovered and mapped by UseEndpointConfigs (called
once from Program.cs) — adding the class is the whole registration step./v{Version}{GroupEndpoint} when API versioning is enabled, which is the
default — e.g. /v1/purchase-orders, not /api/v1/purchase-orders..RequiredIdempotentKey() explicitly on any
POST that must be idempotent; callers then must send an X-Idempotency-Key: {Guid} header.[FromClaim] population on request properties (see §4) is wired per endpoint group
automatically via AddContextualRequestPopulation — no extra call needed on the Map method
itself.Automated alternative (Minimal.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs):
internal sealed class ProductV1Endpoint : IEndpointConfig
{
public int Version => 1;
public string GroupEndpoint => "/products";
public void Map(RouteGroupBuilder group) => group.MapProductCrud();
}
Nine lines. The generated Map<Entity>Crud() extension (ProductCrudEndpointExtensions here) maps
GetById/GetList/Delete through DKNet.AspCore.Extensions’s generic
MapGetById<TEntity,TKey,TDto>/MapGetList/MapDeleteById, and Create/Update through the same
package’s generic MapPost<TRequest,TDto>/MapPutById<TRequest,TKey,TDto>.
Validation gap to know before you pick this shape. .NET 10’s automatic minimal-API validation for complex-type parameters only activates through the
Microsoft.Extensions.Validation.ValidationsGeneratorsource generator, and that generator only recognizes literalMap*(string, Delegate)calls in the compiling project’s own source. That is exactly what §9’s manual example does, and exactly what the genericMapPost<TRequest,TDto>wrapper above does not let it see through. A[Range]/[Required]forwarded onto a generated request property is therefore never evaluated for a generator-mapped route. Confirmed live:POST /v1/productswith a negative price returns201, not400. Don’t assume a DataAnnotations attribute on a[CrudCreate]/[CrudUpdate]parameter is enforced without checking which mapping style its endpoint uses — full detail indocs/samples/manual-vs-automated.md.
Minimal.App.Tests/Integration/<Feature>/V1/ApiFixture (Minimal.App.Tests/Integration/Support/ApiFixture.cs) boots the real host once per
test class (IClassFixture<ApiFixture>) against an EF Core in-memory database, with auth,
versioning, and Azure App Config disabled via feature overrides. Variants exist for
auth-on (AuthOnApiFixture), Swagger-on, and versioning-off scenarios — add a new variant only
if your test genuinely needs a different host configuration.IMessageBus/IRepositorySpec from fixture.CreateScope(),
await bus.Send(request), assert result.IsSuccess/IsFailed, then confirm persisted state
through a Specification. Reach for fixture.CreateClient() and a real HTTP call only when
claim-population or endpoint-registration behavior specifically is what you’re testing.Eventually.IsTrueAsync(...)
(Minimal.App.TestSupport/Eventually.cs) instead of asserting immediately after Send.SqlServer package reference, Npgsql only, enum stored as string — are enforced
separately by Minimal.App.Tests/Architecture/* (NetArchTest). A new feature that violates one of
these fails the build, not a code review.src/coverage.runsettings scopes
collection to [DKNet*]/[Minimal*] and excludes *Tests/bin/obj/GlobalUsings.cs — don’t
put real logic in an excluded path.Unit/ManualSample/, Unit/AutomatedSample/, Integration/ManualSample/V1/, and
Integration/AutomatedSample/V1/ already hold tests for both samples. Use them as your
reference shape alongside the production code; dev-qc extends this coverage at Verify.Minimal.App.BDDTests/Features/<Domain>/.feature file under Features/<Domain>/ and a matching [Binding] step class under
Features/<Domain>/Steps/. Reqnroll’s BoDi container injects HttpClient, ScenarioState, and
the BddApiFactory into the step class constructor — no manual wiring.Support/ApiHooks.cs boots one shared WebApplicationFactory<Program> for the whole run
([BeforeTestRun]) and resets the database before every scenario ([BeforeScenario(Order = 0)]),
so scenarios don’t leak state into each other.X-Idempotency-Key: {Guid.NewGuid()} header per request, generated in the [When] step.
Reusing a key across scenarios returns the first call’s cached result. The automated sample’s
generated create route has no such requirement.RequireAuthorization = false, so [FromClaim] properties fall back to
the system-account default (SharedConsts.SystemAccount) rather than a real claim. That false
comes from Minimal.Api/appsettings.Testing.json — the base appsettings.json ships
RequireAuthorization: true, and TestApiFactoryBase boots the host with
UseEnvironment("Testing"). BddApiFactory.AddFeatureOverrides also sets the key, but that
in-memory entry is merged after Program.cs has already bound FeatureOptions, so the overlay
file is what actually takes effect.@redis / Redis-backed variant only when a scenario specifically needs a Redis-backed
idempotency store.Features/PurchaseOrders/PurchaseOrder.feature and Features/Products/Product.feature exist
and are covered by the green BDD suite; a new action gets scenarios added there.dotnet build src/DKNet.Templates.sln -c Release
dotnet test src/ApiEndpoints/Minimal.App.Tests/Minimal.App.Tests.csproj --filter "FullyQualifiedName~<Feature>"
dotnet test src/ApiEndpoints/Minimal.App.BDDTests/Minimal.App.BDDTests.csproj --filter "TestCategory=<Feature>"
Full run/test/migrate/pack commands: template-usage.md.
Everything the scaffolded solution wires up before you write a line of feature code:
template-features.md.