DKNet.Templates

DDD Implementation Guide

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:

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 prefixed Minimal.* in this repo; a generated solution renames them to <YourApp>.*).

At a glance: the eleven stages

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.

1. Layer map

Architecture diagram of a scaffolded solution: Minimal.AppHost orchestrates and Minimal.Api dispatches through IMessageBus into Minimal.AppServices, which calls Minimal.Domains aggregates; Minimal.Infra supplies IRepositorySpec and the event publisher to AppServices and the EF Core mapping and seeding to Domains; Minimal.Share is read by every layer, and the Minimal.App.Tests/Architecture project holds NetArchTest shape rules over the Api, AppServices, Infra and Domains projects.

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.

2. Domain entity — 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:

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.

3. EF Core mapping — 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");
    }
}

This layer is hand-written for both samples — none of the three generators in this template ([RaisesEvent], [CrudCreate]/[CrudUpdate], [GenerateDto]) touches IEntityTypeConfiguration<T>.

4. Application action — Minimal.AppServices/<Feature>/V1/Actions/

Reference: Minimal.AppServices/ManualSample/V1/Actions/Create.cs

Each action file holds three things, in this order:

  1. Request — implements 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.
  2. Validatorinternal 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.
  3. Handlerinternal sealed class XCommandHandler(...) : Fluents.Requests.IHandler<XRequest, TDto>, constructor-injecting whatever it needs (IRepositorySpec, IMapper, feature-specific services). OnHandle does the real work:
    • Query via a Specification<TEntity> for lookup/uniqueness checks (see §5).
    • Construct or mutate the entity through its own constructor/methods only — never set a property directly from a handler.
    • repository.AddAsync(entity, ct) / repository.Delete(entity). No SaveChanges inside the handler; the DKNet SlimBus pipeline calls it after the handler returns.
    • Return 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.

5. Query specs — 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.

6. DTO and Mapster wiring — Minimal.AppServices/<Feature>/V1/<Feature>Dto.cs

Reference (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.

7. Lazy mapping — DKNet.SlimBus.Extensions.LazyMapper

mapper.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>(...).

8. Domain event and handler — Minimal.AppServices/<Feature>/V1/Events/

Reference (hand-raised): Minimal.Domains/Features/ManualSample/Entities/PurchaseOrderCreatedEvent.cs

public 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) { ... }
}

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.

9. Endpoint — Minimal.Api/ApiEndpoints/<Feature>V1Endpoint.cs

Reference (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}"
    }
}

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.ValidationsGenerator source generator, and that generator only recognizes literal Map*(string, Delegate) calls in the compiling project’s own source. That is exactly what §9’s manual example does, and exactly what the generic MapPost<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/products with a negative price returns 201, not 400. Don’t assume a DataAnnotations attribute on a [CrudCreate]/[CrudUpdate] parameter is enforced without checking which mapping style its endpoint uses — full detail in docs/samples/manual-vs-automated.md.

10. Unit / integration tests — Minimal.App.Tests/Integration/<Feature>/V1/

11. BDD tests — Minimal.App.BDDTests/Features/<Domain>/

Running the loop while you build

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.