A lightweight, modular request handling package for .NET.
A lightweight, modular .NET Standard library that provides request/response handlers, a pipeline, an event bus, built-in validation, and a fluent registration API — all with zero forced patterns.
| Feature | Description |
|---|---|
| Dispatcher | Unified IDispatcher with Send (1:1) and Publish (1:N) |
| Handlers | Strongly-typed IRequestHandler<TRequest, TResponse> |
| Validation | Automatic, opt-in per-handler via IValidationHandler<TRequest> |
| Pipeline | Ordered middleware via IPipelineBehavior<TRequest, TResponse> |
| Events | Fire-and-forget pub/sub via IEvent / IEventListener<TEvent> |
| Cache Behavior | Opt-in response caching via ICacheableRequest<TResponse> |
| Permission Behavior | Opt-in authorization via IAuthorizedRequest + IPermissionContext |
| Idempotency Behavior | Duplicate-request prevention via IIdempotentRequest |
| Fluent API | Explicit registration with AddDotnetHandler(...) |
| Source Generator | Zero-reflection registration via UseGeneratedHandlers() |
| Assembly scanning | Reflection-based auto-discovery via FromAssembly(...) (legacy) |
| Modular | Use only the modules you need |
public record CreateUserCommand(string Name, string Email) : IRequest<User>;public class CreateUserHandler
: IRequestHandler<CreateUserCommand, User>,
IValidationHandler<CreateUserCommand> // optional
{
public Task<ValidationResult> ValidateAsync(CreateUserCommand request)
{
if (string.IsNullOrWhiteSpace(request.Name))
return Task.FromResult(ValidationResult.Failure("Name is required."));
return Task.FromResult(ValidationResult.Success());
}
public Task<User> HandleAsync(CreateUserCommand request) =>
Task.FromResult(new User(Guid.NewGuid(), request.Name, request.Email));
}Recommended — source generator (zero reflection):
// All IRequestHandler<,> and IEventListener<> in this assembly are registered automatically.
builder.Services.AddDotnetHandler(app =>
{
app.UseGeneratedHandlers();
// Pipeline behaviors are always explicit (open-generic supported)
app.Pipeline(p => p.Use(typeof(LoggingBehavior<,>)));
});Alternative — fluent (explicit, no generator needed):
builder.Services.AddDotnetHandler(app =>
{
app.Handlers(h =>
h.Register<CreateUserCommand, User>().HandledBy<CreateUserHandler>());
app.Events(e =>
e.Register<UserCreatedEvent>().Subscribe<SendWelcomeEmailListener>());
app.Pipeline(p => p.Use(typeof(LoggingBehavior<,>)));
});app.MapPost("/users", async (CreateUserCommand cmd, IDispatcher dispatcher) =>
{
try
{
var user = await dispatcher.Send(cmd);
return Results.Ok(user);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { errors = ex.Errors });
}
});public interface IRequest<TResponse> { }
public interface IRequestHandler<TRequest, TResponse>
{
Task<TResponse> HandleAsync(TRequest request);
}public interface IEvent { }
public interface IEventListener<TEvent>
{
Task HandleAsync(TEvent @event);
}public interface IPipelineBehavior<TRequest, TResponse>
{
Task<TResponse> HandleAsync(TRequest request, Func<Task<TResponse>> next);
}public interface IValidationHandler<TRequest>
{
Task<ValidationResult> ValidateAsync(TRequest request);
}Validation runs automatically before the handler and pipeline when the handler also implements IValidationHandler<TRequest>. A ValidationException is thrown on failure — no manual wiring needed.
// Opt-in response caching
public interface ICacheableRequest<TResponse>
{
string CacheKey { get; }
TimeSpan? CacheDuration { get; }
}
// Opt-in authorization
public interface IAuthorizedRequest
{
IEnumerable<string> RequiredPermissions { get; }
}
// Current-user permission check (implement in your application)
public interface IPermissionContext
{
bool HasPermission(string permission);
}
// Opt-in idempotency
public interface IIdempotentRequest
{
string IdempotencyKey { get; }
}| Operation | Validation | Pipeline | Listeners | Throws if none |
|---|---|---|---|---|
Send |
✅ (if handler implements IValidationHandler) |
✅ | — | ✅ |
Publish |
❌ | ❌ | All matching | ❌ |
The source generator ships with the package. It runs at compile time and emits a UseGeneratedHandlers() extension method for your assembly — no reflection at runtime.
What it discovers automatically:
| Type | Registered via |
|---|---|
IRequestHandler<TRequest, TResponse> |
app.Handlers(...) |
IEventListener<TEvent> |
app.Events(...) |
What must remain explicit:
| Type | Why |
|---|---|
IPipelineBehavior<,> |
Order matters — always declare in app.Pipeline(...) |
| FluentValidation validators | External dependency, not a DotnetHandler abstraction |
Pipeline behaviors and external validators are not auto-registered by design.
builder.Services.AddDotnetHandler(app =>
{
app.UseGeneratedHandlers();
app.Pipeline(p => p.Use(typeof(LoggingBehavior<,>)));
});The generated code is emitted to obj/{config}/{tfm}/generated/DotnetHandler.SourceGenerators/.../*.g.cs and is visible in your IDE.
Runtime reflection fallback — useful for plugin scenarios or when source generators are unavailable:
builder.Services.AddDotnetHandler(app =>
app.FromAssembly(typeof(Program).Assembly));Auto-registers: IRequestHandler<,>, IEventListener<>
Does NOT auto-register: pipeline behaviors (always explicit)
app.Pipeline(p => p.Use<MyBehavior>());app.Pipeline(p => p.Use(typeof(LoggingBehavior<,>)));Behaviors execute in registration order (first registered = outermost wrapper).
Mark a query as cacheable by implementing ICacheableRequest<TResponse>:
public record GetUsersQuery : IRequest<List<User>>, ICacheableRequest<List<User>>
{
public string CacheKey => "users:all";
public TimeSpan? CacheDuration => TimeSpan.FromMinutes(1); // null = 5 min default
}Implement the behavior using IMemoryCache (or any cache abstraction):
public class CacheBehavior<TRequest, TResponse>(IMemoryCache cache)
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> HandleAsync(TRequest request, Func<Task<TResponse>> next)
{
if (request is not ICacheableRequest<TResponse> cacheable)
return await next();
if (cache.TryGetValue(cacheable.CacheKey, out TResponse? cached))
return cached!;
var result = await next();
cache.Set(cacheable.CacheKey, result, cacheable.CacheDuration ?? TimeSpan.FromMinutes(5));
return result;
}
}Register:
builder.Services.AddMemoryCache();
app.Pipeline(p => p.Use(typeof(CacheBehavior<,>)));Non-cacheable requests pass through transparently.
Implement IPermissionContext to provide the current user's permissions:
public interface IPermissionContext
{
bool HasPermission(string permission);
}Mark a command as requiring authorization by implementing IAuthorizedRequest:
public record DeleteUserCommand(Guid Id) : IRequest<bool>, IAuthorizedRequest
{
public IEnumerable<string> RequiredPermissions => ["users:delete"];
}Implement the behavior:
public class PermissionBehavior<TRequest, TResponse>(IPermissionContext context)
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> HandleAsync(TRequest request, Func<Task<TResponse>> next)
{
if (request is not IAuthorizedRequest authorized)
return await next();
foreach (var permission in authorized.RequiredPermissions)
{
if (!context.HasPermission(permission))
throw new UnauthorizedException(permission);
}
return await next();
}
}UnauthorizedException exposes the Permission property. Catch it at the endpoint to return 403:
app.MapDelete("/users/{id:guid}", async (Guid id, IDispatcher dispatcher) =>
{
try { ... }
catch (UnauthorizedException ex)
{
return Results.Problem(ex.Message, statusCode: 403);
}
});Mark a command as idempotent by implementing IIdempotentRequest:
public record CreateUserCommand(string Name, string Email, string IdempotencyKey = "")
: IRequest<UserResponse>, IIdempotentRequest;The client sends the key in the Idempotency-Key header; the endpoint injects it into the command before dispatching. Repeated requests with the same key return the stored result without re-executing the handler.
Implement the behavior:
public class IdempotencyBehavior<TRequest, TResponse>(IMemoryCache cache)
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> HandleAsync(TRequest request, Func<Task<TResponse>> next)
{
if (request is not IIdempotentRequest idempotent || string.IsNullOrWhiteSpace(idempotent.IdempotencyKey))
return await next();
var key = $"idempotency:{idempotent.IdempotencyKey}";
if (cache.TryGetValue(key, out TResponse? stored))
return stored!;
var result = await next();
cache.Set(key, result, TimeSpan.FromHours(24));
return result;
}
}Requests with a blank IdempotencyKey bypass the behavior and always execute.
ValidationResult is a simple value object:
ValidationResult.Success();
ValidationResult.Failure("Error 1", "Error 2");ValidationException exposes IReadOnlyCollection<string> Errors.
DotnetHandler.sln
├── src/
│ ├── DotnetHandler/ # Core library (netstandard2.1)
│ │ ├── Abstractions/ # Interfaces
│ │ ├── Core/ # Dispatcher implementation
│ │ ├── Validation/ # ValidationResult, ValidationException
│ │ ├── Registration/ # Fluent API, ApplicationBuilder
│ │ └── Internal/ # Assembly scanner (legacy)
│ └── DotnetHandler.SourceGenerators/ # Roslyn source generator (netstandard2.0)
├── tests/
│ ├── DotnetHandler.Tests/ # Core unit tests (net10.0)
│ └── DotnetHandler.Sample.Tests/ # Integration tests for sample (net10.0)
└── samples/
└── DotnetHandler.Sample/ # Minimal API sample (net10.0)
- As simple as Coravel
- As structured as MediatR
- Built-in validation without a framework dependency
- No enforced response pattern (return whatever
TResponseyou want) - Explicit and predictable — no magic beyond optional assembly scanning
