DSoftStudio Mediator

← Back to Documentation

Migrating from MediatR

This guide targets MediatR 12–14.x (the current MediatR unified package). Earlier versions that used separate MediatR.Extensions.Microsoft.DependencyInjection follow the same steps.

1. Replace the NuGet package

dotnet remove package MediatR
dotnet add package DSoftStudio.Mediator

2. Update namespaces

- using MediatR;
+ using DSoftStudio.Mediator.Abstractions;
+ using DSoftStudio.Mediator;

3. Update service registration

MediatR’s AddMediatR(cfg => ...) maps directly to DSoftStudio.Mediator’s AddMediator(builder => ...):

- services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
+ services.AddMediator(builder =>
+ {
+     builder.AddOpenBehavior(typeof(LoggingBehavior<,>));
+     // ... other pipeline configuration
+ });

AddOpenBehavior changes lifetime when you port the call. MediatR’s cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)) registers the behavior Transient; the DSoftStudio.Mediator builder registers it Scoped, so one instance is shared by every dispatch in the scope, concurrent ones included. A behavior that keeps per-dispatch state in a FIELD — a Stopwatch started before next and read after it — must be ported as AddOpenBehavior(typeof(LoggingBehavior<,>), ServiceLifetime.Transient). One that keeps its state in locals needs no change.

Or, if you prefer the step-by-step approach:

- services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
+ services
+     .AddMediator()
+     .RegisterMediatorHandlers()
+     .PrecompilePipelines()
+     .PrecompileNotifications()
+     .PrecompileStreams();

Do not mix both approaches. Using AddMediator(configure) with RegisterMediatorHandlers() or PrecompilePipelines() causes redundant registrations. See Registration Order.

4. Update handler return types

  public class PingHandler : IRequestHandler<Ping, int>
  {
-     public Task<int> Handle(Ping request, CancellationToken ct)
-         => Task.FromResult(42);
+     public ValueTask<int> Handle(Ping request, CancellationToken ct)
+         => new ValueTask<int>(42);
  }

Notification handlers stay the same — both use Task Handle(...).

5. Update pipeline behavior signatures

  public class LoggingBehavior<TRequest, TResponse>
      : IPipelineBehavior<TRequest, TResponse>
      where TRequest : IRequest<TResponse>
  {
-     public async Task<TResponse> Handle(
+     public async ValueTask<TResponse> Handle(
          TRequest request,
-         RequestHandlerDelegate<TResponse> next,
+         IRequestHandler<TRequest, TResponse> next,
          CancellationToken ct)
      {
          Console.WriteLine($"Handling {typeof(TRequest).Name}");
-         return await next();
+         return await next.Handle(request, ct);
      }
  }

6. Update Send calls (usually no change needed)

The source generator creates typed extension methods, so mediator.Send(new Ping()) works out of the box with full type inference — zero reflection, best performance:

// Works automatically via generated extension method (recommended)
var result = await mediator.Send(new Ping());

// Explicit generics also work
var result = await mediator.Send<Ping, int>(new Ping());

7. Update Pre/Post processor return types

Pre/post processors use ValueTask instead of MediatR’s Task:

  public class ValidationPreProcessor<TRequest> : IRequestPreProcessor<TRequest>
  {
-     public Task Process(TRequest request, CancellationToken ct)
+     public ValueTask Process(TRequest request, CancellationToken ct)
      {
          // ...
-         return Task.CompletedTask;
+         return ValueTask.CompletedTask;
      }
  }

8. Handler lifetimes (behavioral difference)

MediatR registers all handlers as Transient by default. DSoftStudio.Mediator uses automatic lifetime detection:

You can override any handler’s lifetime after RegisterMediatorHandlers() — the last registration wins. Place overrides before PrecompilePipelines() so the pipeline chain picks up the correct lifetime:

services
    .AddMediator()
    .RegisterMediatorHandlers();

// Override before PrecompilePipelines so the chain lifetime is correct
services.AddScoped<IRequestHandler<MyRequest, MyResponse>, MyHandler>();

services
    .PrecompilePipelines()
    .PrecompileNotifications()
    .PrecompileStreams();

What stays the same

Concept MediatR DSoftStudio.Mediator
Request marker IRequest<TResponse> IRequest<TResponse>
Notification marker INotification INotification
Notification handler Task Handle(T, CancellationToken) Task Handle(T, CancellationToken)
Stream request IStreamRequest<TResponse> IStreamRequest<TResponse>
Send syntax mediator.Send(new Ping()) mediator.Send(new Ping()) (via generated extension)
Open generic registration services.AddTransient(typeof(IPipelineBehavior<,>), ...) Same — the raw registration is unchanged; see below for the builder default

What changes

Concept MediatR DSoftStudio.Mediator
Handler return type Task<TResponse> ValueTask<TResponse>
Behavior next param RequestHandlerDelegate<TResponse> IRequestHandler<TRequest, TResponse>
Calling next await next() await next.Handle(request, ct)
Pre/Post processor return Task ValueTask
Handler lifetime (default) All Transient Derived from the constructor: stateless or all-Singleton deps → Singleton, any Scoped → Scoped, any Transient or unregistered → Transient
Component lifetime (default) AddOpenBehavior(...) → Transient AddOpenBehavior(...) → Scoped. One Transient component makes the request’s whole chain Transient, so it is rebuilt on every dispatch. Pass ServiceLifetime.Transient explicitly when the component must be constructed per dispatch
Namespace using MediatR; using DSoftStudio.Mediator.Abstractions;
Stream dispatch timing Handler resolved on the first MoveNextAsync Handler and behavior chain resolved when CreateStream is called

Stream dispatch is not deferred

CreateStream resolves the handler and builds the behavior chain when you call it, not when you start enumerating. The handler’s own body is still deferred, because an async IAsyncEnumerable method is an iterator — that part is the C# language, not the mediator.

The practical difference from MediatR: a stream created inside a DI scope has already captured its handler and behaviors, so enumerating it after the scope is disposed keeps using what it captured instead of failing on the provider. Create and enumerate the stream inside the same scope.

await using var scope = provider.CreateAsyncScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();

await foreach (var item in mediator.CreateStream<GetNumbers, int>(new GetNumbers(), ct))
{
    // enumerated inside the scope that created it
}

9. Optional: Consolidate with self-handling requests

For simple commands/queries where the handler is trivial, you can eliminate the handler class entirely by placing a static Execute method inside the request. This is particularly useful in large MediatR migrations with hundreds of small command/query pairs:

- public record Ping(int Value) : IRequest<int>;
-
- public class PingHandler : IRequestHandler<Ping, int>
- {
-     public ValueTask<int> Handle(Ping request, CancellationToken ct)
-         => new ValueTask<int>(request.Value * 2);
- }
+ public record Ping(int Value) : ICommand<int>
+ {
+     internal static int Execute(Ping cmd) => cmd.Value * 2;
+ }

See Self-Handling Requests for full documentation.

See Also