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.
SaveChangesAsync. A write request that returns a successful IResult gets its
DbContext saved by an interceptor, using the same concurrency-aware save path as the rest of DKNet’s EF Core stack.FluentResults types, so “product not
found” is a value the caller inspects rather than a thrown exception to catch.Fluents.Requests.* marks a write, Fluents.Queries.* marks a
read, and the auto-save interceptor keys off exactly that — no attributes, no naming conventions.AddSlimBusEventPublisher<TDbContext>() hooks
DKNet.EfCore.Events up to IMessageBus.Publish, so events raised by aggregates are published after the save that
made them true.If you are not using SlimMessageBus, or don’t want auto-save, this package buys you little.
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}");
Fluents.Requests.INoResponseFor 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>.
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.
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.
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.
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;
}
}
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.
AddSlimBusEfCoreInterceptor<TDbContext>() registers an internal IRequestHandlerInterceptor<,> that runs after every
request handler and, on success, saves any registered DbContext with pending changes:
Fluents.Requests.INoResponse or
Fluents.Requests.IWitResponse<TResponse>. Queries (Fluents.Queries.*) and raw SlimMessageBus
IRequest<T>/IRequestHandler<,> implementations are never auto-saved.null, or is an IResultBase with IsSuccess == false — a failed command never
persists partial state.DbContext type registered through the call, resolves each from the current scope and, for those where
ChangeTracker.HasChanges(), calls AddNewEntitiesFromNavigations then SaveChangesWithConcurrencyHandlingAsync
(both from DKNet.EfCore.Extensions).IEfCoreExceptionHandler keyed by the DbContext’s full type name first, falling back to an unkeyed
registration — per-context or global concurrency-conflict handling.Order = int.MaxValue (IInterceptorWithOrder), so it runs after your own interceptors.The interceptor and its DbContext type registry are internal; you opt in purely through
AddSlimBusEfCoreInterceptor<TDbContext>().
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.
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<,>));
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."));
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.
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.
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.
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 |
| 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.
AddSlimBusEventPublisher<TDbContext>() is the bridge between its hook and the bus.IEventItem / IEventPublisher
contracts SlimBusEventPublisher implements.Fluents interfaces, so generated and hand-written slices sit in one pipeline.DKNet.EfCore.Extensions — provides AddNewEntitiesFromNavigations and
SaveChangesWithConcurrencyHandlingAsync, the save primitives the interceptor calls.DbContext are not saved. Auto-save only fires for INoResponse /
IWitResponse<T> requests — by design, but easy to trip over when a “read” handler writes.IRequestHandler<,> directly, not
through Fluents.Requests, is never recognized as a write.DbContext transaction. Each registered context with pending changes is saved independently in a loop;
if a later save throws, earlier ones have already committed.DbContext type registry is scoped to the IServiceCollection. Each registration is resolved from the
provider built out of that collection, so separately built providers (parallel test fixtures, for instance) never
see each other’s registered types — each one has to call AddSlimBusEfCoreInterceptor<TDbContext>() for itself.null or failed response silently skips the save, including an exception the handler caught and turned into
Result.Fail(...). Unhandled exceptions propagate through SlimMessageBus’s own pipeline; this package adds no
exception handling around the handler call.DbUpdateException surfaces from the
interceptor rather than from the handler, so handler-local try/catch will not see it.Fluents.Requests.IWitResponse<T> and Fluents.Queries.IWitResponse<T> are different interfaces with the same
name in different nested classes — one wraps IResult<T>, the other returns T?. Import them explicitly enough to
keep them apart.