Immediate.Handlers

repository·main·Indexed 41 days ago

https://github.com/immediateplatform/immediate.handlers

A high-performance implementation of the mediator pattern for .NET that uses source-generation to build request-handling call-trees at compile-time. It provides type safety and minimal runtime overhead for Query and Command handlers, supports cross-cutting concerns via Behaviors, and includes specialized support for streaming handlers using IAsyncEnumerable.

Tokens
2.9K
Snippets
7
Records
9
Agent score
3%

What's inside Immediate.Handlers

  1. How Immediate.Handlers works

    main

    Immediate.Handlers is a mediator pattern implementation for .NET that uses source-generation.

    Key characteristics:

    • Compile-time Call-tree: The entire pipeline of behaviors is determined at compile-time.
    • Compile-time Safety: All dependencies are enforced via compile-time checks.
    • Runtime DI: While the call-tree is static, dependencies and behaviors are resolved via Dependency Injection at runtime.
    • Automatic Handler Generation: When you mark a class with [Handler], a partial class (e.g., YourClass.Handler) is generated to encapsulate behaviors and DI services.
  2. Create and apply Behaviors

    main

    Behaviors allow you to wrap request handling with cross-cutting concerns like logging or validation.

    1. Implement a Behavior

    Implement the Behavior<TRequest, TResponse> class and override HandleAsync. Use the Next(request, cancellationToken) method to continue the pipeline.

    public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    	: Behavior<TRequest, TResponse>
    {
    	public override async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken)
    	{
    		logger.LogInformation("LoggingBehavior.Enter");
    		var response = await Next(request, cancellationToken);
    		logger.LogInformation("LoggingBehavior.Exit");
    		return response;
    	}
    }

    2. Register Behaviors

    You can apply behaviors in three ways:

    • Assembly-wide: Use the [assembly: Behaviors] attribute.
      [assembly: Behaviors(typeof(LoggingBehavior<,>))]
    • Per-Handler: Use the [Behavior] attribute on a specific handler. Note: This overrides and disregards all assembly-wide behaviors for that handler.
      [Handler]
      [Behavior(typeof(LoggingBehavior<,>))]
      public static class GetUsersQuery { ... }
    • Via Custom Attribute: Define a custom attribute decorated with [Behaviors] to group common pipelines.
      [Behaviors(typeof(ValidationBehavior<,>), typeof(TransactionBehavior<,>))]
      public sealed class DefaultBehaviorsAttribute : Attribute;
      
      // Usage
      [Handler]
      [DefaultBehaviors]
      public sealed class GetUsersQuery { ... }

    3. Behavior Constraints

    You can restrict a behavior to specific request/response types using C# generic constraints:

    public sealed class LoggingBehavior<TRequest, TResponse>
    	: Behavior<TRequest, TResponse>
    	where TRequest : IRequestConstraint
    	where TResponse : IResponseConstraint
  3. Implement and use Streaming Handlers

    main

    Streaming handlers are used for scenarios where responses are produced incrementally using IAsyncEnumerable<TResponse>.

    1. Create a Streaming Handler

    Return IAsyncEnumerable<TResponse> from the HandleAsync method and use the [EnumeratorCancellation] attribute on the token.

    [Handler]
    public static partial class StreamItems
    {
        public record Query(int Count);
    
        private static async IAsyncEnumerable<int> HandleAsync(
            Query query,
            [EnumeratorCancellation] CancellationToken token)
        {
            for (var i = 0; i < query.Count; i++)
            {
                await Task.Yield();
                yield return i;
            }
        }
    }

    2. Create a Streaming Behavior

    Extend StreamingBehavior<TRequest, TResponse> instead of the standard Behavior class.

    public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
        : StreamingBehavior<TRequest, TResponse>
    {
        public override async IAsyncEnumerable<TResponse> HandleAsync(
            TRequest request,
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            logger.LogInformation("LoggingBehavior.Enter");
            await foreach (var item in Next(request, cancellationToken))
                yield return item;
            logger.LogInformation("LoggingBehavior.Exit");
        }
    }

    3. Consuming Streaming Handlers

    The generated handler implements IStreamingHandler<TRequest, TResponse>.

    public class Consumer(IStreamingHandler<StreamItems.Query, int> handler)
    {
        public async Task ConsumeAsync(CancellationToken token)
        {
            await foreach (var item in handler.HandleAsync(new(5), token))
                Console.WriteLine(item);
        }
    }

    Note: Streaming behaviors are only applied to streaming handlers, and non-streaming behaviors are only applied to non-streaming handlers. They can coexist in the same configuration without interference.

  4. Register Handlers with IServiceCollection

    main

    To register all handlers in an assembly, use the AddXxxHandlers() extension method on IServiceCollection.

    By default, Xxx is the short form of your assembly name. For example:

    • Assembly Web $\rightarrow$ services.AddWebHandlers()
    • Assembly Application.Web $\rightarrow$ services.AddApplicationWebHandlers()

    To use a custom name, decorate your assembly with [assembly: ImmediateAssemblyIdentifierAttribute("SomeIdentifier")].

    Using Tags for Filtering

    You can assign string tags to handlers. When calling AddXxxHandlers with specific tags, only registrations that share at least one tag (or have no tags) will be included.

    [Handler(Tags = ["worker", "background"])]
    public sealed class BackgroundWorker { }
  5. Fix Swashbuckle schemaId conflicts with Immediate.Handlers

    main

    When using Immediate.Handlers inside controller actions, Swashbuckle may fail to generate schemas because multiple handlers might use nested classes with the same name (e.g., Command or Query) in different namespaces. This results in a SwaggerGeneratorException regarding duplicate schemaIds.

    To resolve this, configure SwaggerGen to use the full type name and replace the + character (used for nested classes) with a dot:

    builder.Services.AddSwaggerGen( options =>
    {
        options.CustomSchemaIds(x => x.FullName?.Replace("+", ".", StringComparison.Ordinal));
    });
  6. Create a Query Handler

    main

    To create a Query handler, define a partial class and decorate it with the [Handler] attribute. The handler should contain a private ValueTask<TResponse> HandleAsync method. The source generator will create a Handler nested class that can be injected into consumers.

    [Handler]
    public sealed partial class GetUsersQuery(
    	UsersService usersService
    )
    {
        public record Query;
    
        private ValueTask<IEnumerable<User>> HandleAsync(
            Query _,
            CancellationToken token
        )
        {
            return usersService.GetUsers();
        }
    }
  7. Create a Command Handler

    main

    Command handlers are similar to Query handlers but use a ValueTask return type without a specific response type. Immediate.Handlers automatically inserts a ValueTuple return type for these handlers.

    [Handler]
    public sealed partial class CreateUserCommand(
        UsersService usersService
    )
    {
        public record Command(string Email);
    
        private async ValueTask HandleAsync( 
            Command command,
            CancellationToken token
        )
        {
            await usersService.CreateUser(command.Email);
        }
    }
  8. Reference the Immediate.Handlers Analyzer Rule IDs

    main

    The Immediate.Handlers.Analyzers package provides a set of diagnostic rules to ensure correct implementation of Handlers and Behaviors. Use the following Rule IDs to identify issues reported by your IDE or build process. Rules are categorized by their analyzer type (e.g., HandlerClassAnalyzer, BehaviorsAnalyzer) and assigned a severity level (Error, Warning, or Info).

    ### Rule ID Reference
    
    | Rule ID | Category | Severity | Notes |
    |---------|----------|----------|-------|
    | IHR0001 | ImmediateHandler | Error | HandlerClassAnalyzer |
    | IHR0002 | ImmediateHandler | Error | HandlerClassAnalyzer |
    | IHR0005 | ImmediateHandler | Error | HandlerClassAnalyzer |
    | IHR0006 | ImmediateHandler | Error | BehaviorsAnalyzer |
    | IHR0007 | ImmediateHandler | Error | BehaviorsAnalyzer |
    | IHR0008 | ImmediateHandler | Error | BehaviorsAnalyzer |
    | IHR0009 | ImmediateHandler | Error | HandlerClassAnalyzer |
    | IHR0010 | ImmediateHandler | Error | HandlerClassAnalyzer |
    | IHR0011 | ImmediateHandler | Error | HandlerClassAnalyzer |
    | IHR0012 | ImmediateHandler | Warning | HandlerClassAnalyzer |
    | IHR0013 | ImmediateHandler | Warning | InvalidIHandlerAnalyzer |
    | IHR0014 | ImmediateHandler | Error | HandlerClassAnalyzer |
    | IHR0015 | ImmediateHandler | Error | HandlerClassAnalyzer |
    | IHR0016 | ImmediateHandler | Warning | HandlerClassAnalyzer |
    | IHR0017 | ImmediateHandler | Warning | HandlerClassAnalyzer |
    | IHR0018 | ImmediateHandler | Warning | HandlerClassAnalyzer |
    | IHR0019 | ImmediateHandler | Info | HandlerClassAnalyzer |
    | IHR0020 | ImmediateHandler | Warning | HandlerClassAnalyzer |
    | IHR0021 | ImmediateHandler | Error | BehaviorsClassAnalyzer |
    | IHR0022 | ImmediateHandler | Warning | InvalidHandlerUsageAnalyzer |
    | IHR0023 | ImmediateHandler | Error | InvalidImmediateAssemblyIdentifierAnalyzer |
    | IHR0024 | ImmediateHandler | Error | BehaviorsAnalyzer |