Table of Contents

CQRS & Pipeline Behaviors

Vulthil.SharedKernel.Application implements the CQRS (Command Query Responsibility Segregation) pattern with a lightweight in-process sender and configurable pipeline behaviors.

Concepts

Abstraction Description
ICommand / ICommand<TResponse> Write operation that changes state
ITransactionalCommand / ITransactionalCommand<TResponse> Command executed inside a database transaction
IQuery<TResponse> Read operation that returns data
ICommandHandler<TCommand, TResponse> Handles a command
IQueryHandler<TQuery, TResponse> Handles a query
ISender Dispatches requests to their handlers
IPipelineHandler<TRequest, TResponse> Cross-cutting middleware for the handler pipeline

Registration

builder.Services.AddApplication(options =>
{
    // Scan assemblies for handlers
    options.RegisterHandlerAssemblies(typeof(Program).Assembly);

    // Scan assemblies for FluentValidation validators
    options.RegisterFluentValidationAssemblies(typeof(Program).Assembly);

    // Add pipeline behaviors (order matters)
    options.AddValidationPipelineBehavior();
    options.AddRequestLoggingBehavior();
    options.AddTransactionalPipelineBehavior();
});

Defining Commands and Handlers

public sealed record CreateUserCommand(string Email) : ICommand<Result<Guid>>;

public sealed class CreateUserCommandHandler(IUserRepository users, IUnitOfWork uow)
    : ICommandHandler<CreateUserCommand, Result<Guid>>
{
    public async Task<Result<Guid>> HandleAsync(
        CreateUserCommand request, CancellationToken cancellationToken)
    {
        var user = User.Create(request.Email);
        users.Add(user);
        await uow.SaveChangesAsync(cancellationToken);
        return user.Id.Value;
    }
}

Defining Queries and Handlers

public sealed record GetUserQuery(Guid UserId) : IQuery<Result<UserDto>>;

public sealed class GetUserQueryHandler(AppDbContext db)
    : IQueryHandler<GetUserQuery, Result<UserDto>>
{
    public async Task<Result<UserDto>> HandleAsync(
        GetUserQuery request, CancellationToken cancellationToken)
    {
        var user = await db.Users
            .Where(u => u.Id == new UserId(request.UserId))
            .Select(u => new UserDto(u.Id.Value, u.Email))
            .FirstOrDefaultAsync(cancellationToken);

        return user is not null
            ? Result.Success(user)
            : Result.Failure<UserDto>(UserErrors.NotFound);
    }
}

Pipeline Behaviors

Pipeline behaviors wrap every handler invocation, allowing you to add cross-cutting logic without modifying individual handlers.

Validation

ValidationPipelineBehavior runs all registered IValidator<TCommand> instances before the handler executes. When validation fails it short-circuits: a command returning Result or Result<T> receives a failed result containing a ValidationError, while a command with any other response type throws a ValidationException (there is no in-band way to represent failure for a non-result response):

public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress();
    }
}

No additional wiring is needed – the validator is picked up automatically from the registered assemblies.

Request Logging

RequestLoggingPipelineBehavior logs the start and completion of every request, attaching the serialised error to the logging scope when the result is a failure.

Transactional

TransactionalPipelineBehavior wraps ITransactionalCommand handlers inside a database transaction. If the handler returns a successful Result, the transaction commits; if it returns a failed Result or throws, the transaction rolls back. (The non-generic ITransactionalCommand marker returns Result and is wrapped the same way as ITransactionalCommand<Result>.)

// Mark a command as transactional
public sealed record TransferFundsCommand(Guid FromAccount, Guid ToAccount, decimal Amount)
    : ITransactionalCommand<Result>;

Dispatching Requests

Inject ISender into your API endpoints or controllers:

app.MapPost("/users", async (CreateUserCommand command, ISender sender) =>
{
    var result = await sender.SendAsync(command);
    return result.ToIResult();
});

Direct handler injection

You can also inject the handler interface directly. The resolved instance is the same pipeline-wrapped handler that ISender would dispatch to, so behaviors (validation, logging, transactions, custom ones) apply uniformly either way.

app.MapPost("/users", async (
    CreateUserCommand command,
    ICommandHandler<CreateUserCommand, Result<Guid>> handler) =>
{
    var result = await handler.HandleAsync(command);
    return result.ToIResult();
});

The interfaces you can inject are IHandler<TRequest, TResponse>, ICommandHandler<TCommand, TResponse>, ICommandHandler<TCommand> (for Result-returning commands) and IQueryHandler<TQuery, TResponse>. The concrete handler implementation is not registered as a DI service — there is no way to bypass the pipeline by injecting the concrete type.

Behaviors across assemblies

Pipeline behaviors are composed at handler-resolution time, not at registration time, so the order of registration is irrelevant. Different assemblies may register handlers and behaviors independently — all behaviors registered before BuildServiceProvider apply to all handlers resolved afterwards.

// In an Infrastructure assembly:
services.AddOpenPipelineHandler(typeof(MyCustomBehavior<,>));