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.
dotnet remove package MediatR
dotnet add package DSoftStudio.Mediator
- using MediatR;
+ using DSoftStudio.Mediator.Abstractions;
+ using DSoftStudio.Mediator;
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
+ });
AddOpenBehaviorchanges lifetime when you port the call. MediatR’scfg.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 — aStopwatchstarted beforenextand read after it — must be ported asAddOpenBehavior(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)withRegisterMediatorHandlers()orPrecompilePipelines()causes redundant registrations. See Registration Order.
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(...).
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);
}
}
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());
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;
}
}
MediatR registers all handlers as Transient by default. DSoftStudio.Mediator uses automatic lifetime detection:
ILogger<T> and IOptions<T> count), Scoped when any is scoped, Transient only when one is transient or unregisteredYou 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();
| 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 |
| 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 |
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
}
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.