DKNet

DKNet.SlimBus.Extensions

CQRS contracts and EF Core glue on top of SlimMessageBus — fluent command/query/event interfaces, automatic SaveChanges after a successful write, and domain events forwarded onto the bus.

✨ Why use it?

If you are not using SlimMessageBus, or don’t want auto-save, this package buys you little.

🚀 Quick Start

dotnet add package DKNet.SlimBus.Extensions

It brings no transport with it — add whichever SlimMessageBus.Host.* provider your host needs (memory, Azure Service Bus, Kafka, …) and configure it through SlimMessageBus’s own builder:

using Microsoft.Extensions.DependencyInjection;
using SlimMessageBus.Host;
using SlimMessageBus.Host.Memory;
using SlimMessageBus.Host.Serialization.SystemTextJson;

services.AddDbContext<AppDbContext>(o => o.UseSqlServer(connectionString));

// 1) This package: SaveChanges the DbContext after a successful write request.
services.AddSlimBusEfCoreInterceptor<AppDbContext>();

// 2) This package (optional): forward EF Core domain events onto the bus.
services.AddSlimBusEventPublisher<AppDbContext>();

// 3) Plain SlimMessageBus — bus, provider, serializer, handler discovery.
services.AddSlimMessageBus(mbb => mbb
    .AddJsonSerializer()
    .AddServicesFromAssembly(typeof(Program).Assembly) // discovers your Fluents handlers
    .AddChildBus("Memory", bus => bus
        .WithProviderMemory()
        .AutoDeclareFrom(typeof(Program).Assembly)));

Those two AddSlimBus* calls are this package’s entire registration surface — there is no options object. Everything else (AddSlimMessageBus, AddJsonSerializer, AddChildBus, WithProviderMemory, AutoDeclareFrom, AddServicesFromAssembly) is SlimMessageBus’s own API. Call AddSlimBusEfCoreInterceptor<T>() once per DbContext type; repeat calls add the type to the save registry without registering a second interceptor.

Then send a command:

var result = await bus.Send(new CreateProduct("Widget", 9.99m), cancellationToken);
if (result.IsSuccess) return TypedResults.Created($"/products/{result.Value}");

🧩 Features

Commands without a response — Fluents.Requests.INoResponse

For writes that only signal success or failure.

using DKNet.SlimBus.Extensions;
using FluentResults;

public record DeactivateProduct(Guid ProductId) : Fluents.Requests.INoResponse;

internal sealed class DeactivateProductHandler(AppDbContext db)
    : Fluents.Requests.IHandler<DeactivateProduct>
{
    public async Task<IResultBase> OnHandle(DeactivateProduct request, CancellationToken cancellationToken)
    {
        var product = await db.Products.FindAsync([request.ProductId], cancellationToken);
        if (product is null) return Result.Fail("Product not found");

        product.Deactivate();
        // No SaveChangesAsync call here — the auto-save interceptor does it after this returns Ok.
        return Result.Ok();
    }
}

Fluents.Requests.IHandler<TRequest> is IRequestHandler<TRequest, IResultBase> constrained to TRequest : INoResponse, so OnHandle returns Task<IResultBase>.

Commands with a response — Fluents.Requests.IWitResponse<TResponse>

For writes that hand data back, such as a new identifier.

public record CreateProduct(string Name, decimal Price) : Fluents.Requests.IWitResponse<Guid>;

internal sealed class CreateProductHandler(AppDbContext db)
    : Fluents.Requests.IHandler<CreateProduct, Guid>
{
    public async Task<IResult<Guid>> OnHandle(CreateProduct request, CancellationToken cancellationToken)
    {
        var product = new Product(request.Name, request.Price);
        await db.Products.AddAsync(product, cancellationToken);
        return Result.Ok(product.Id);
    }
}

Callers get an IResult<Guid> from IMessageBus.Send(...); auto-save runs only when IsSuccess is true.

Fluents.Requests.IWithKey<TKey> is a small companion interface (TKey Id { get; set; }) for requests addressed by identifier — DKNet.SlimBus.Generators puts it on the update and action requests it emits so shared code can read the key generically.

Single-item queries — Fluents.Queries.IWitResponse<TResponse>

public record GetProduct(Guid Id) : Fluents.Queries.IWitResponse<ProductDto>;

internal sealed class GetProductHandler(AppDbContext db)
    : Fluents.Queries.IHandler<GetProduct, ProductDto>
{
    public async Task<ProductDto?> OnHandle(GetProduct request, CancellationToken cancellationToken)
    {
        var product = await db.Products.AsNoTracking()
            .FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken);
        return product is null ? null : new ProductDto(product.Id, product.Name);
    }
}

The return type is TResponse?, not a FluentResults wrapper — a query answers “found / not found”, it does not carry a business-failure result. Reads never trigger auto-save (see Auto-save behaviour), whatever the handler leaves in the change tracker.

Paged queries — Fluents.Queries.IWitPageResponse<TResponse>

Backed by X.PagedList / X.PagedList.EF, already a dependency.

using X.PagedList;
using X.PagedList.EF;

public record GetProductsPage(int PageIndex, int PageSize) : Fluents.Queries.IWitPageResponse<ProductDto>;

internal sealed class GetProductsPageHandler(AppDbContext db)
    : Fluents.Queries.IPageHandler<GetProductsPage, ProductDto>
{
    public Task<IPagedList<ProductDto>> OnHandle(GetProductsPage request, CancellationToken cancellationToken) =>
        db.Products.AsNoTracking()
            .Select(p => new ProductDto(p.Id, p.Name))
            .ToPagedListAsync(request.PageIndex, request.PageSize, null, cancellationToken);
}

ToPagedListAsync counts and pages server-side; pass null for totalSetCount to let it run the count query, or a precomputed count to skip it.

Event consumers — Fluents.EventsConsumers.IHandler<TEvent>

A thin alias over SlimMessageBus’s IConsumer<TEvent>, so a consumer of a published domain event reads as:

public class ProductCreatedHandler : Fluents.EventsConsumers.IHandler<ProductCreatedEvent>
{
    public Task OnHandle(ProductCreatedEvent message, CancellationToken cancellationToken)
    {
        // react to the event — e.g. send a notification
        return Task.CompletedTask;
    }
}

Domain event publishing

AddSlimBusEventPublisher<TDbContext>() registers SlimBusEventPublisher as an IEventPublisher for TDbContext via DKNet.EfCore.Events’ AddEventPublisher<TDbContext, TImplementation>(), which wires that package’s event hook into the context’s save pipeline. After a successful SaveChangesAsync, the hook collects the events the aggregates raised and hands each to the publisher, which forwards it to IMessageBus.Publish. When an event implements IEventItem, its AdditionalData entries are copied onto the message as headers with case-insensitive keys.

SlimBusEventPublisher is public and both PublishAsync overloads are virtual, so you can subclass it — to stamp extra headers, for example — and register the subclass instead:

public sealed class LoggingEventPublisher(IMessageBus bus, ILogger<LoggingEventPublisher> logger)
    : SlimBusEventPublisher(bus)
{
    public override Task PublishAsync(object eventObj, CancellationToken cancellationToken = default)
    {
        logger.LogInformation("Publishing {EventType}", eventObj.GetType().Name);
        return base.PublishAsync(eventObj, cancellationToken);
    }
}

A collection of events is published one at a time, in order, awaiting each — there is no batching.

Auto-save behaviour

AddSlimBusEfCoreInterceptor<TDbContext>() registers an internal IRequestHandlerInterceptor<,> that runs after every request handler and, on success, saves any registered DbContext with pending changes:

The interceptor and its DbContext type registry are internal; you opt in purely through AddSlimBusEfCoreInterceptor<TDbContext>().

Sequence diagram of one write request: the auto-save interceptor wraps the handler and saves the DbContext when the result succeeded and the ChangeTracker has changes. That save publishes the raised events through SlimBusEventPublisher back onto IMessageBus.

Read the wrapping order from the diagram rather than the bullet list: because auto-save is registered last, the handler’s result passes through it on the way out, which is where the save — and therefore the event publish — actually happens. Event publishing is a second, separate opt-in: without AddSlimBusEventPublisher<TDbContext>() the save still runs and nothing reaches the bus.

Your own interceptors

Because auto-save is just a SlimMessageBus IRequestHandlerInterceptor<TRequest, TResponse>, add validation, logging, or authorization the same way:

using SlimMessageBus;
using SlimMessageBus.Host.Interceptor;

public class LoggingInterceptor<TRequest, TResponse>(ILogger<LoggingInterceptor<TRequest, TResponse>> logger)
    : IRequestHandlerInterceptor<TRequest, TResponse>
{
    public async Task<TResponse> OnHandle(TRequest request, Func<Task<TResponse>> next, IConsumerContext context)
    {
        logger.LogInformation("Handling {RequestType}", typeof(TRequest).Name);
        return await next();
    }
}

services.AddScoped(typeof(IRequestHandlerInterceptor<,>), typeof(LoggingInterceptor<,>));

Lazy mapping — ILazyMap<T> and IMapper.ResultOf<T>

Two Mapster-backed helpers for handlers that return a DTO, so the mapping cost is paid only if something reads the value:

using DKNet.SlimBus.Extensions.LazyMapper;

// IResult<ProductDto> whose Value is mapped on first access
public Task<IResult<ProductDto>> OnHandle(CreateProduct request, CancellationToken cancellationToken)
{
    var product = new Product(request.Name, request.Price);
    db.Products.Add(product);
    return Task.FromResult(mapper.ResultOf<ProductDto>(product));
}

mapper.LazyMap<T>(value) gives the same laziness without the result wrapper: Value throws InvalidOperationException when the source was null, ValueOrDefault returns default instead. When the source is already a T, the same instance is returned rather than mapped. Both require an IMapper (Mapster) in the container.

NotFoundError is a FluentResults.Error subclass for the “the thing you addressed doesn’t exist” case — the shape the generated handlers in DKNet.SlimBus.Generators return, and worth matching in hand-written handlers so an API layer can map one error type to 404:

return Result.Fail<ProductDto>(new NotFoundError($"Product '{request.Id}' was not found."));

Carrying the acting user

This package supplies no base record and no acting-user property. Declare the property on the request itself and mark it with an IContextualSource attribute — e.g. [FromClaim(ClaimTypes.Name)] from DKNet.AspCore.Extensions — then register AddContextualRequestPopulation() so the value is stamped before validation and before the handler runs.

⚙️ Configuration reference

There is no options type, no IConfiguration section, and no builder in this package. What you can vary is which of the two registrations you call, the DbContext you call them with, and which contracts your own types implement — so that is what this section documents.

Registration surface (SlimBusEfCoreSetup)

Method Constraint Registers Lifetime Repeat call
AddSlimBusEfCoreInterceptor<TDbContext>() TDbContext : DbContext IRequestHandlerInterceptor<,> → the internal auto-save interceptor Scoped Adds TDbContext to the save registry; the interceptor itself is registered only once
AddSlimBusEventPublisher<TDbContext>() TDbContext : DbContext SlimBusEventPublisher as IEventPublisher for TDbContext, via DKNet.EfCore.Events Delegated to AddEventPublisher Delegated to AddEventPublisher

Both are declared inside a C# 14 extension(IServiceCollection) block, so they are called as ordinary extension methods on IServiceCollection. Calling them does not require C# 14 in your own project — a consumer at LangVersion 13 compiles against them fine.

Auto-save behaviour — the fixed rules

None of these are switchable; they are the contract the interceptor implements.

Concern Value
Interceptor order int.MaxValue (IInterceptorWithOrder) — runs outermost, after your own interceptors
Saved for Fluents.Requests.INoResponse and Fluents.Requests.IWitResponse<T> only
Skipped when The response is null, or is an IResultBase with IsSuccess == false
Saved contexts Every type registered through AddSlimBusEfCoreInterceptor<T>() whose ChangeTracker.HasChanges()
Save call AddNewEntitiesFromNavigations, then SaveChangesWithConcurrencyHandlingAsync
Exception handler IEfCoreExceptionHandler keyed by the DbContext’s FullName, falling back to the unkeyed registration

Public extension points

Type Accessibility What you do with it
Fluents.Requests.INoResponse / IWitResponse<T> public interface Mark a message as a write — this is what auto-save keys off.
Fluents.Requests.IHandler<TRequest> / IHandler<TRequest, TResponse> public interface Implement the handler for a write.
Fluents.Requests.IWithKey<TKey> public interface Carry a route-bound Id; the generated update and action requests implement it.
Fluents.Queries.IWitResponse<T> / IWitPageResponse<T> and their handlers public interface Mark and handle a read. Never auto-saved.
Fluents.EventsConsumers.IHandler<TEvent> public interface Consume a published event.
SlimBusEventPublisher public class, both PublishAsync overloads virtual Subclass to add headers or logging, then register the subclass.
NotFoundError public sealed class : FluentResults.Error Return it from Result.Fail so the API layer can map one type to 404.
ILazyMap<T>, LazyMapExtensions.LazyMap<T> / ResultOf<T> public interface / public static class Defer a Mapster mapping until the value is read.

The auto-save interceptor (EfAutoSavePostInterceptor<,>), its DbContext registry, and the LazyMap/LazyResult implementations are all internal — you opt in through the two registration methods, not by implementing or replacing those types.

🧱 Where it fits

⚠️ Gotchas & limits