custom dispatcher

custom dispatcher

CustomDispatcher

A small CQRS-first command/query dispatcher for .NET with dispatch middleware support.

What is CustomDispatcher?

CustomDispatcher is a lightweight, explicit CQRS-style dispatcher library for .NET applications. It separates commands and queries, supports dispatch middleware for cross-cutting concerns, and integrates seamlessly with `Microsoft.Extensions.DependencyInjection`.

Installation

dotnet add package CustomDispatcher

Quick Start

1. Register CustomDispatcher

builder.Services.AddCustomDispatcher(options =>
{
    options.RegisterServicesFromAssembly(typeof(Program).Assembly);
});

2. Define Commands and Queries

// Command with result
public record CreateUserCommand(string Name, string Email) : ICommand<UserId>;
public record UserId(Guid Value);

// Command without result
public record DeleteUserCommand(Guid Id) : ICommand;

// Query
public record GetUserByIdQuery(Guid Id) : IQuery<UserDto>;
public record UserDto(Guid Id, string Name, string Email);

3. Implement Handlers

public class CreateUserCommandHandler : ICommandProcessor<CreateUserCommand, UserId>
{
    public Task<UserId> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken = default)
    {
        // Create user and return ID
        return Task.FromResult(new UserId(Guid.NewGuid()));
    }
}

public class DeleteUserCommandHandler : ICommandProcessor<DeleteUserCommand>
{
    public Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken = default)
    {
        // Delete user
        return Task.CompletedTask;
    }
}

public class GetUserByIdQueryHandler : IQueryProcessor<GetUserByIdQuery, UserDto>
{
    public Task<UserDto> HandleAsync(GetUserByIdQuery query, CancellationToken cancellationToken = default)
    {
        // Fetch and return user
        return Task.FromResult(new UserDto(query.Id, "John", "john@example.com"));
    }
}

4. Dispatch from Your Endpoints

app.MapPost("/users", async (CreateUserCommand command, ICommandDispatcher dispatcher) =>
{
    var userId = await dispatcher.DispatchAsync<CreateUserCommand, UserId>(command);
    return Results.Created($"/users/{userId.Value}", userId);
});

app.MapDelete("/users/{id}", async (Guid id, ICommandDispatcher dispatcher) =>
{
    await dispatcher.DispatchAsync(new DeleteUserCommand(id));
    return Results.NoContent();
});

app.MapGet("/users/{id}", async (Guid id, IQueryDispatcher dispatcher) =>
{
    var user = await dispatcher.DispatchAsync<GetUserByIdQuery, UserDto>(new GetUserByIdQuery(id));
    return Results.Ok(user);
});

Dispatch Middleware

Pipeline middleware allow cross-cutting concerns around handlers.

1. Create Middleware

public class ValidationMiddleware<TRequest, TResult> : IDispatchMiddleware<TRequest, TResult>
{
    public async Task<TResult> HandleAsync(
        TRequest request,
        DispatchContinuation<TResult> next,
        CancellationToken cancellationToken = default)
    {
        // Pre-processing (e.g., validation)
        Console.WriteLine($"Before handling: {request}");

        var result = await next();

        // Post-processing
        Console.WriteLine($"After handling: {result}");

        return result;
    }
}

2. Register Middleware

builder.Services.AddCustomDispatcher(options =>
{
    options.RegisterServicesFromAssembly(typeof(Program).Assembly);
    options.AddDispatchMiddleware(typeof(ValidationMiddleware<,>));
    options.AddDispatchMiddleware(typeof(LoggingMiddleware<,>));
});

Middleware execute in registration order:

ValidationMiddleware -> LoggingMiddleware -> Handler

First registered is outermost, last registered is closest to the handler.

FluentValidation Integration

An optional package provides seamless FluentValidation integration.

1. Install the Package

dotnet add package CustomDispatcher.FluentValidation

2. Create Validators

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required.");
        RuleFor(x => x.Email).NotEmpty().WithMessage("Email is required.");
        RuleFor(x => x.Email).EmailAddress().WithMessage("Email is not valid.");
    }
}

3. Register Validation

builder.Services.AddCustomDispatcher(options =>
{
    options.RegisterServicesFromAssembly(typeof(Program).Assembly);
    options.AddDispatchMiddleware(typeof(ValidationMiddleware<,>));
});

builder.Services.AddCustomDispatcherValidation(options =>
{
    options.RegisterValidatorsFromAssembly(typeof(Program).Assembly);
});

Validation runs automatically before the handler. If validation fails, a `ValidationException` is thrown with all failures.

Dependency Injection Registration

Basic Registration

services.AddCustomDispatcher();

With Assembly Scanning

services.AddCustomDispatcher(options =>
{
    options.RegisterServicesFromAssembly(typeof(Program).Assembly);
});

Multiple Assemblies

services.AddCustomDispatcher(options =>
{
    options.RegisterServicesFromAssembly(
        typeof(ApplicationAssembly).Assembly,
        typeof(DomainAssembly).Assembly);
});

With Dispatch Middleware

services.AddCustomDispatcher(options =>
{
    options.RegisterServicesFromAssembly(typeof(Program).Assembly);
    options.AddDispatchMiddleware(typeof(ValidationMiddleware<,>));
    options.AddDispatchMiddleware(typeof(LoggingMiddleware<,>));
    options.AddDispatchMiddleware(typeof(TransactionMiddleware<,>));
});

Error Handling

The library throws clear, actionable exceptions for infrastructure and configuration errors:

| Exception | When |

|-----------|------|

| `DispatchTargetNotFoundException` | No handler registered for a command or query |

| `MultipleDispatchTargetsFoundException` | Multiple handlers registered for the same command or query |

| `InvalidMiddlewareRegistrationException` | Invalid dispatch middleware registration |

| `CustomDispatcherException` | Base exception type for all CustomDispatcher errors |

All exceptions bubble up from handlers unless a user-defined middleware handles them.

Public API

Commands

public interface ICommand { }
public interface ICommand<TResult> { }

Queries

public interface IQuery<TResult> { }

Handlers

public interface ICommandProcessor<in TCommand>
    where TCommand : ICommand
{
    Task HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}

public interface ICommandProcessor<in TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}

public interface IQueryProcessor<in TQuery, TResult>
    where TQuery : IQuery<TResult>
{
    Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}

Dispatchers

public interface ICommandDispatcher
{
    Task DispatchAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
        where TCommand : ICommand;

    Task<TResult> DispatchAsync<TCommand, TResult>(TCommand command, CancellationToken cancellationToken = default)
        where TCommand : ICommand<TResult>;
}

public interface IQueryDispatcher
{
    Task<TResult> DispatchAsync<TQuery, TResult>(TQuery query, CancellationToken cancellationToken = default)
        where TQuery : IQuery<TResult>;
}

Pipeline

public delegate Task<TResult> DispatchContinuation<TResult>();

public interface IDispatchMiddleware<in TRequest, TResult>
{
    Task<TResult> HandleAsync(
        TRequest request,
        DispatchContinuation<TResult> next,
        CancellationToken cancellationToken = default);
}

Non-Goals (V1)

These are intentionally not included in V1:

  • Notifications / domain events
  • Event sourcing
  • Outbox pattern
  • Built-in Result/Error types
  • Built-in validation
  • Built-in logging
  • Built-in transaction management
  • Source generators
  • AOT-specific support
  • OpenTelemetry integration
  • Retry policies
  • Background job dispatching
  • Message bus integration

Versioning Policy

CustomDispatcher follows semantic versioning. Before 1.0, the API may change but will be documented. After 1.0, breaking changes require a major version bump.

License

MIT License. See LICENSE for details.