DotnetHandler 0.0.3

dotnet add package DotnetHandler --version 0.0.3
                    
NuGet\Install-Package DotnetHandler -Version 0.0.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DotnetHandler" Version="0.0.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DotnetHandler" Version="0.0.3" />
                    
Directory.Packages.props
<PackageReference Include="DotnetHandler" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DotnetHandler --version 0.0.3
                    
#r "nuget: DotnetHandler, 0.0.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DotnetHandler@0.0.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DotnetHandler&version=0.0.3
                    
Install as a Cake Addin
#tool nuget:?package=DotnetHandler&version=0.0.3
                    
Install as a Cake Tool

DotnetHandler

<p align="center"> <img src="./dot-net-handler-pack.png" alt="DotnetHandler logo" width="720" /> </p>

<p align="center"> <strong>A lightweight, modular request handling package for .NET.</strong> </p>

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.


Features

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

Installation

Nuget


Quick Start

1. Define a request

public record CreateUserCommand(string Name, string Email) : IRequest<User>;

2. Implement a handler (with optional built-in validation)

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));
}

3. Register

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<,>)));
});

4. Dispatch

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 });
    }
});

Core Abstractions

Requests & Handlers

public interface IRequest<TResponse> { }

public interface IRequestHandler<TRequest, TResponse>
{
    Task<TResponse> HandleAsync(TRequest request);
}

Events

public interface IEvent { }

public interface IEventListener<TEvent>
{
    Task HandleAsync(TEvent @event);
}

Pipeline

public interface IPipelineBehavior<TRequest, TResponse>
{
    Task<TResponse> HandleAsync(TRequest request, Func<Task<TResponse>> next);
}

Validation

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.

Behavior contracts

// 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; }
}

Dispatcher Behaviour

Operation Validation Pipeline Listeners Throws if none
Send ✅ (if handler implements IValidationHandler)
Publish All matching

Source Generator

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.

Usage

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.


Assembly Scanning (legacy)

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)

Pipeline Behaviors

Closed (single request type)

app.Pipeline(p => p.Use<MyBehavior>());

Open generic (all requests)

app.Pipeline(p => p.Use(typeof(LoggingBehavior<,>)));

Behaviors execute in registration order (first registered = outermost wrapper).


Cache Behavior

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.


Permission Behavior

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);
    }
});

Idempotency Behavior

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.


Validation Details

ValidationResult is a simple value object:

ValidationResult.Success();
ValidationResult.Failure("Error 1", "Error 2");

ValidationException exposes IReadOnlyCollection<string> Errors.


Project Structure

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)

Design Goals

  • As simple as Coravel
  • As structured as MediatR
  • Built-in validation without a framework dependency
  • No enforced response pattern (return whatever TResponse you want)
  • Explicit and predictable — no magic beyond optional assembly scanning
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.0.3 53 6/5/2026
0.0.2 65 4/29/2026
0.0.1 58 4/28/2026