Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Overview

NotifyR is a lightweight mediator library for .NET, inspired by MediatR. It gives you a simple way to move application logic away from controllers, UI handlers, and other entry points and into focused request handlers, notification handlers, and pipeline behaviors. The result is a codebase that is easier to read, easier to test, and easier to grow as the application gets more complicated.

At a high level, NotifyR provides in-process message passing. A request represents a single operation that expects a response. A notification represents a domain event or side effect that may have one or many consumers. Pipeline behaviors sit around request handling and let you add cross-cutting concerns such as logging, validation, caching, or timing without copying that logic into every handler.

The library is intentionally small. It builds on Microsoft.Extensions.DependencyInjection, uses familiar .NET abstractions, and keeps the hot path lean so the mediator layer stays out of the way of the actual business logic.

Core concepts

Requests are the primary entry point. If you have a command such as creating an order or a query such as loading a user profile, that logic belongs in a request handler. When the operation does not need to return data, NotifyR still supports the same pattern through void requests, which keeps your application model consistent even for fire-and-forget workflows.

Notifications are a better fit for situations where one action should fan out to multiple listeners. A user being created might trigger audit logging, cache invalidation, and an email notification, and each of those reactions can live in its own handler. That separation keeps the publisher focused on the event itself rather than the consequences of the event.

Pipeline behaviors are the mechanism that lets NotifyR stay composable. Instead of sprinkling timing, validation, or retry logic into each handler, you can wrap the handler pipeline once and apply the same concern consistently across many request types.

Features

NotifyR is designed around a small set of predictable capabilities:

Request/response handling gives you typed responses without introducing a large abstraction layer.

Void requests make it possible to keep command-style operations consistent even when no data needs to flow back to the caller.

Notifications support one-to-many dispatch, which is useful for events that should trigger multiple side effects.

Pipeline behaviors let you implement cross-cutting logic in one place instead of repeating it in every handler.

Dependency injection is the foundation for registration and resolution, so the library fits naturally into existing ASP.NET Core and generic host applications.

The implementation keeps allocations and reflection to a minimum so that the mediator layer remains a thin adapter instead of a performance bottleneck.

NuGet

dotnet add package NotifyR

Namespace

Most samples use the root namespace directly. That keeps the public surface easy to discover and mirrors the package name.

using NotifyR;

Getting Started

The quickest way to understand NotifyR is to build one request, one handler, and one mediator instance. The pattern is intentionally simple: define a request type, implement a handler for that request, register the assembly with AddNotifyR, and then send the request through IMediator.

Define a request and response

Start with a request type that describes the work you want done. If the operation returns a value, use IRequest<TResponse> so the response type is part of the contract.

public class GetUser : IRequest<GetUserResponse>
{
    public int UserId { get; init; }
}

public class GetUserResponse
{
    public string Name { get; init; } = "";
}

If the operation does not return data, use IRequest. This keeps the API consistent while still making the request explicit.

public class Ping : IRequest;

Create a handler

Handlers hold the actual logic for the request. A handler should be focused on one operation and should return the response directly rather than hiding the work behind framework code.

public class GetUserHandler : IRequestHandler<GetUser, GetUserResponse>
{
    public Task<GetUserResponse> Handle(GetUser request, CancellationToken ct)
    {
        var response = new GetUserResponse { Name = $"User_{request.UserId}" };
        return Task.FromResult(response);
    }
}

For a void request, implement IRequestHandler<TRequest>. The return value is Unit, which gives the handler a consistent signature without forcing an artificial response object.

public class PingHandler : IRequestHandler<Ping>
{
    public Task<Unit> Handle(Ping request, CancellationToken ct)
    {
        Console.WriteLine("Ping received!");
        return Unit.Completed;
    }
}

Wire it up

NotifyR is registered through the Microsoft dependency injection container. Most applications scan an assembly that contains handlers and then resolve IMediator from the service provider.

var services = new ServiceCollection();

services.AddNotifyR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<GetUser>();
});

var provider = services.BuildServiceProvider();
var mediator = provider.GetRequiredService<IMediator>();

Send requests

Once the mediator is available, sending a request is just another asynchronous operation. Typed requests return the declared response, and void requests complete once the handler has finished.

// Typed response
var user = await mediator.Send(new GetUser { UserId = 42 });

// Void request
await mediator.Send(new Ping());

Publish notifications

Notifications are useful when one event should trigger more than one reaction. Each handler is resolved from dependency injection, and every matching handler gets a chance to respond to the same notification.

public class UserCreated : INotification
{
    public int UserId { get; init; }
}

public class LogUserCreated : INotificationHandler<UserCreated>
{
    public Task Handle(UserCreated notification, CancellationToken ct)
    {
        Console.WriteLine($"Log: User {notification.UserId} created");
        return Task.CompletedTask;
    }
}

await mediator.Publish(new UserCreated { UserId = 7 });

This style scales well because the publisher does not need to know which reactions exist. New behavior can be added by registering another handler, which keeps the notification source stable as the application grows.

Request Handling

IRequest<TResponse>

IRequest<TResponse> is the contract that ties a request type to the value it returns. The interface does not define any members because its purpose is compile-time structure rather than runtime behavior. That design keeps request types lightweight while still making the response type explicit.

public interface IRequest<TResponse> { }

public class GetUser : IRequest<GetUserResponse>
{
    public int UserId { get; init; }
}

IRequest (void)

IRequest is a convenience shorthand for requests that do not produce a value. It still participates in the same pipeline and handler resolution process, which means void operations are not treated as a special case by the consumer.

public interface IRequest : IRequest<Unit> { }

public class Ping : IRequest { }

Under the hood, void requests are handled as IRequest<Unit>. The Unit type is a singleton value type that represents the absence of a meaningful return value without introducing null or a throwaway object.

IRequestHandler<TRequest, TResponse>

Implement this interface when a request should return a typed result. The handler receives the request and cancellation token and returns the response directly.

public interface IRequestHandler<in TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}

IRequestHandler<TRequest> (void)

The void handler form is the same idea with a lighter return contract. It is equivalent to IRequestHandler<TRequest, Unit>, which keeps the API readable for operations that only need side effects.

public interface IRequestHandler<in TRequest> : IRequestHandler<TRequest, Unit>
    where TRequest : IRequest<Unit> { }

How Send works

When IMediator.Send(request) is called, NotifyR first validates the argument and then locates the wrapper that knows how to handle the request type. That wrapper is cached so the request type only needs to be resolved once. On each call, the wrapper resolves the concrete handler from dependency injection, resolves any registered pipeline behaviors, and then builds the execution chain around the handler.

The pipeline is executed from the outside in. Each behavior gets a chance to inspect the request before delegating to the next step, and the innermost step is always the handler itself. Once the handler completes, the response unwinds back through the behavior chain to the caller.

The important detail is that this orchestration stays generic. Request types do not need to know how handlers are found, and handlers do not need to know how the mediator resolved them. That separation keeps application code focused on the business operation rather than on the plumbing.

Multiple requests, different handlers

Each request type gets its own handler. Dependency injection resolves the correct IRequestHandler<TRequest, TResponse> for the request being sent, so the mediator can remain completely generic while still dispatching to the right implementation.

Notifications

INotification

INotification marks a message as something that can be published to multiple consumers. Notifications usually represent facts that have already happened, such as a user being created or an order being submitted.

public interface INotification { }

public class UserCreated : INotification
{
    public int UserId { get; init; }
}

INotificationHandler<TNotification>

Implement this interface when a notification should trigger a reaction. Unlike request handlers, notification handlers do not return a value. Their job is to perform the side effect associated with the event.

public interface INotificationHandler<in TNotification>
    where TNotification : INotification
{
    Task Handle(TNotification notification, CancellationToken cancellationToken);
}

Multiple handlers can subscribe to the same notification type. This is where notifications become especially useful: the publisher stays simple while new reactions can be added independently.

public class LogUserCreated : INotificationHandler<UserCreated> { /* ... */ }
public class EmailUserCreated : INotificationHandler<UserCreated> { /* ... */ }

How Publish works

When you call IMediator.Publish(notification), NotifyR resolves the wrapper for the notification type and then asks dependency injection for every matching handler. Those handlers are invoked sequentially so the library can preserve predictable execution order and respect cancellation between each step.

The dispatch model is deliberately simple. There is no hidden event bus or background queue in the middle of the call. The publish operation runs in-process, which means the notification completes only after the subscribed handlers have finished.

Error handling

Error handling is designed to preserve as much information as possible. If only one handler fails, NotifyR rethrows that failure directly. If several handlers fail, it wraps them in an AggregateException so the caller can see the full set of problems instead of only the first one.

Handlers continue executing even when one of them throws. That behavior matters when the handlers represent independent side effects, such as audit logging and email delivery, because one failure should not prevent the others from running unless cancellation has been requested. The exception (or AggregateException) is surfaced to the caller only after all handlers have completed.

Registration

Handlers are usually discovered through assembly scanning. That keeps registration close to the application boundary and avoids manually registering every notification handler one by one.

services.AddNotifyR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<MyHandler>();
});

If you scan the assembly that contains your handlers, NotifyR will discover the matching INotificationHandler<TNotification> implementations and register them using the configured lifetime.

Pipeline Behaviors

Pipeline behaviors are how NotifyR handles cross-cutting concerns without pushing that logic into every individual handler. The pattern is the same idea used by middleware in ASP.NET Core: each behavior wraps the next behavior, and the final inner step is the handler itself. That makes the pipeline a good place for logging, validation, retry policies, timing, caching, or any other logic that should sit around the request rather than inside it.

IPipelineBehavior<TRequest, TResponse>

Behaviors are generic over both the request and response type so they can participate in the same strongly typed flow as the handler they surround.

public interface IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TRequest, TResponse> next,
        CancellationToken cancellationToken);
}

Execution order

If three behaviors are registered as A → B → C, the first registered behavior becomes the outermost wrapper. In practice, that means A sees the request first, then B, then C, and the response flows back outward in the reverse order after the handler completes.

Request
  │
  ▼
BehaviorA.Handle(req, next, ct)
  │
  ├─ BehaviorA does pre-work (logging, validation, etc.)
  │
  ├─ next(req, ct)
  │    │
  │    ▼
  │   BehaviorB.Handle(req, next, ct)
  │    │
  │    ├─ BehaviorB does pre-work
  │    ├─ next(req, ct)
  │    │    │
  │    │    ▼
  │    │   BehaviorC.Handle(req, next, ct)
  │    │    │
  │    │    ├─ BehaviorC does pre-work
  │    │    ├─ next(req, ct) → handler.Handle(req, ct)
  │    │    ├─ BehaviorC does post-work
  │    │    └─ returns response
  │    │
  │    ├─ BehaviorB does post-work
  │    └─ returns response
  │
  ├─ BehaviorA does post-work
  └─ returns response

This ordering matters because it gives the registration list a clear meaning. Behaviors wrap in registration order, execute outer-first, and return inner-first, which is the same mental model most .NET developers already know from middleware.

Short-circuiting

A behavior can skip the next() call entirely to short-circuit the pipeline. That is useful when a cached result is available, when validation fails early, or when a policy decides the request should not continue.

public class CachingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TRequest, TResponse> next,
        CancellationToken ct)
    {
        var cached = TryGetFromCache(request);
        if (cached is not null)
            return cached;            // ← skips next(), returns early

        return await next(request, ct);
    }
}

Internal design

The pipeline is assembled by RequestHandlerWrapperImpl.BuildPipeline. The handler sits at the center of the chain, and each behavior is wrapped around it from the inside out so the resulting delegate can be invoked as one composed operation.

pipeline = handler.Handle;                              // innermost
for (var i = behaviors.Length - 1; i >= 0; i--)
    pipeline = new BehaviorChain(behaviors[i], pipeline).Invoke;  // wrap outward

behaviors is the array returned by provider.GetServices<IPipelineBehavior<TRequest, TResponse>>() in DI registration order (behaviors[0] is the first registered). By iterating from last to first, the pipeline is composed so the first-registered behavior ends up as the outermost wrapper, matching the execution order documented above.

Each BehaviorChain node stores two things: the behavior instance and a delegate to the next step in the chain. That explicit shape keeps the implementation easy to reason about while avoiding the hidden allocations that closures would introduce.

No closures are allocated. Each node is an explicit class with stored fields, which is a deliberate tradeoff in favor of predictable allocation behavior on the hot path.

No-behavior cache (per-root-container)

When no behaviors are registered for a request type, NotifyR caches that fact using a ConditionalWeakTable. The cache is keyed by IServiceScopeFactory, which is effectively a singleton for the root container, so the optimization is shared across all scopes in the same application. If the provider does not expose IServiceScopeFactory, the provider itself becomes the key.

On later calls, the cache hit avoids the GetServices and ToArray work entirely. That matters because the no-behavior case is common in smaller applications and in request types that do not need cross-cutting logic.

When one or more behaviors are registered, they are still resolved from dependency injection on every Send. NotifyR does not cache the resolved behavior instances, because doing so would interfere with the configured lifetime semantics. Transient behaviors should be created per call, scoped behaviors should live for the current scope, and singleton behaviors should be created once.

Registration

Behaviors can be registered as open generics or as closed types, depending on whether they should apply broadly or only to a specific request pair.

// Open generic — applies to all requests
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));

// Closed generic — applies to specific request type only
services.AddTransient<IPipelineBehavior<Ping, Pong>, MyPingBehavior>();

Configuration

AddNotifyR

AddNotifyR is the entry point for configuring NotifyR in the service collection. It is where you tell the library which assemblies should be scanned and which lifetime should be used for the mediator and handlers.

services.AddNotifyR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<MyHandler>();
    cfg.UseLifetime(ServiceLifetime.Scoped);
});

At least one assembly containing handlers must be registered. If no assemblies are provided, NotifyR throws an InvalidOperationException because it has nothing to discover or wire up.

RegisterServicesFromAssembly

This method scans a specific assembly for handlers. It is useful when your handlers live in a dedicated assembly or when you want to keep configuration explicit.

cfg.RegisterServicesFromAssembly(typeof(MyHandler).Assembly);

NotifyR scans concrete, non-generic types that implement IRequestHandler<,>, IRequestHandler<>, INotificationHandler<>, or IPipelineBehavior<,>. That keeps registration focused on application code instead of abstract base types or helper classes that should not be activated directly.

RegisterServicesFromAssemblyContaining<T>

This is a convenience overload for the common case where you already have a type in the target assembly. It keeps startup code short without hiding what is being scanned.

cfg.RegisterServicesFromAssemblyContaining<MyHandler>();

UseLifetime

UseLifetime controls how IMediator and the discovered handlers are registered. The default is transient, which gives each resolution a new mediator instance and new handlers unless the underlying services use a broader lifetime.

LifetimeBehavior
Transient (default)New mediator + handlers per resolution
ScopedSame mediator + handlers within a scope
SingletonNot allowed — throws ArgumentException

Singleton is rejected because the mediator captures IServiceProvider. If the mediator itself were singleton, handler resolution would happen through the root container and scoped dependencies would lose their expected lifetime boundaries.

Assembly scanning details

Assembly scanning uses Assembly.GetTypes(), which means both public and non-public types can be discovered. NotifyR then filters to concrete, non-abstract, non-generic types and matches handler interfaces by open generic definition before registering the results with the configured lifetime.

Duplicate assemblies are deduplicated with .Distinct(), so it is safe to call the registration helpers more than once with the same assembly. Type load exceptions are handled gracefully as well, which means a partial scan can still succeed if one type fails to load.

Performance

Every allocation on the hot path of mediator.Send(request):

Warm path (cache populated, 3 behaviors)

Send<TResponse>(request, ct)                          ALLOCS
├─ ArgumentNullException.ThrowIfNull                  0
├─ WrapperCache.GetOrCreate → cache hit               0
└─ wrapper.Handle(request, provider, ct)
   ├─ GetRequiredService<IRequestHandler<..>>()       0-N (DI)
   ├─ GetBehaviors(provider)
   │   └─ GetServices<...>().ToArray()                1
   │      (no-behavior flag cached per-provider via CWT)
   ├─ BuildPipeline(handler, behaviors)
   │   ├─ handler.Handle delegate                     1
   │   └─ × N behaviors:
   │       new BehaviorChain                          1 each
   │       chain.Invoke delegate                      1 each
   └─ pipeline(request, ct)                           0
ScenarioAllocations per Send (steady-state)
No behaviors1 (handler delegate only; no-behavior flag cached per-provider, GetServices+ToArray skipped)
1 behavior4 (handler delegate + ToArray() + BehaviorChain + invoke delegate)
3 behaviors8 (handler delegate + ToArray() + 3×(BehaviorChain + invoke delegate))

All are small, short-lived gen-0 objects.

Cold path (first call per type)

On the first Send for a given request type, the wrapper factory runs once:

Before (Activator.CreateInstance)After (compiled expression)
Reflection-based constructionCached delegate invocation

The factory result is cached in ConcurrentDictionary — subsequent calls pay zero overhead.

Publishes with zero handlers

BeforeAfter
async Task — class state machine on heapasync ValueTask — struct state machine on stack

When no handlers are registered, the method completes synchronously. ValueTask keeps the state machine on the stack.

What was removed from the hot path

RemovedSaving
is not TRequest type check + throw path1 branch + 1 dead code path per Send
Lambda closures per behavior1 heap closure per behavior layer
Activator.CreateInstance reflectionReflection → delegate call
async Task class state machineHeap → stack allocation
Enum.IsDefined reflectionRange check (JIT-inlinable)

Architecture

File structure

The library is organized around a small set of public contracts and a larger internal execution layer. Public interfaces live in Contracts, DI configuration lives in Extensions, and the actual dispatch mechanics are kept in Internal so the surface area stays focused and easy to consume.

src/NotifyR/
├── Contracts/
├── Extensions/
├── Internal/
│   ├── BehaviorChain.cs
│   ├── NotificationHandlerWrapperBase.cs
│   ├── NotificationHandlerWrapperFactory.cs
│   ├── NotificationHandlerWrapperImpl.cs
│   ├── RequestHandlerWrapperBase.cs
│   ├── RequestHandlerWrapperFactory.cs
│   ├── RequestHandlerWrapperImpl.cs
│   └── WrapperCache.cs
├── Models/
│   └── Unit.cs
├── Mediator.cs
└── NotifyR.csproj

All public types are exposed through the NotifyR namespace. That keeps the consumer experience simple and avoids forcing users to learn a deeper namespace hierarchy just to get started.

NotifyR replaces closure-based composition with an explicit BehaviorChain class. Instead of creating a lambda that captures the behavior and the next delegate, each chain node stores those values as fields and exposes a concrete Invoke method. The main benefit is predictability: the number of allocations no longer grows with the number of request types multiplied by the number of behaviors on the pipeline.

Namespace

The library declares all its public types in the NotifyR namespace (file-scoped):

namespace NotifyR;

Internal implementation types remain in the same namespace but are internal.

Design decisions

The request path is intentionally direct. A request enters the mediator, the mediator resolves or creates the wrapper for that request type, and the wrapper resolves the handler, resolves any pipeline behaviors, and executes the composed pipeline.

Why no closure allocations in the pipeline?

Closure allocations for (req, ct) => behavior.Handle(req, next, ct) are replaced with an explicit BehaviorChain class. Each chain node stores behavior + next delegate as fields rather than capturing them in a closure. This eliminates heap allocations proportional to the number of request types × behaviors per type.

Why the static “no behaviors” cache?

The ConditionalWeakTable<object, object> in RequestHandlerWrapperImpl avoids a GetServices DI call + .ToArray() allocation on every Send when no behaviors are registered. The cache is keyed by IServiceScopeFactory (or the provider itself when no scope factory is available), so the optimization is shared across all scopes in the same application. In production, behavior registrations are fixed at startup, so caching the “no behaviors” state is safe.

Why ValueTask for notification dispatch?

The INotificationHandlerWrapperBase interface uses ValueTask instead of Task. When no handlers are registered or all handlers complete synchronously, the struct state machine stays on the stack, avoiding a heap allocation.

Why compiled expression factories?

Activator.CreateInstance uses runtime reflection to locate and invoke the constructor. By compiling an Expression.New once per closed generic type and caching the delegate, construction becomes a simple delegate call.

Request flow

IMediator.Send(request)
  → Mediator.Send<TResponse>(request)
    → WrapperCache.GetOrCreate(request type)
      → RequestHandlerWrapperFactory.Create (if cache miss)
    → RequestHandlerWrapperImpl.Handle
      → GetRequiredService<IRequestHandler<TRequest, TResponse>>
      → GetBehaviors (no-behavior flag cached per-provider via ConditionalWeakTable)
      → BuildPipeline (BehaviorChain)
      → pipeline(request, ct)

Notification flow

Notifications follow the same broad shape, but instead of building a behavior pipeline they fan out to all registered handlers for the notification type.

IMediator.Publish(notification)
  → Mediator.Publish<TNotification>(notification)
    → WrapperCache.GetOrCreate(notification type)
      → NotificationHandlerWrapperFactory.Create (if cache miss)
    → NotificationHandlerWrapperImpl.Handle
      → GetServices<INotificationHandler<TNotification>>
      → foreach handler:
          → handler.Handle(notification, ct)
      → aggregate exceptions if any

That split between request flow and notification flow is what keeps the public API simple while still allowing each path to optimize for its own use case. Requests are about returning one result. Notifications are about distributing one fact to many consumers.