Updated on

MediatR handles exceptions through IRequestExceptionHandler<TRequest, TResponse, TException>. We implement it once with open generics, register it in the container, and any exception thrown inside a handler is caught, logged, and turned into a response instead of a 500.

That is one of two approaches. The other is to return failure as a value (ErrorOr<Success>, Result<T>, or an equivalent), so the handler never throws for an expected outcome. This article implements the exception handler, then says when each one is the right choice.

To download the source code for this article, you can visit our GitHub repository.

What Are IRequest<TResponse> and IRequestHandler in MediatR?

IRequest<TResponse> marks a message and declares what sending it produces. IRequestHandler<TRequest, TResponse> is the single class that handles that message.

The interface is declared with a covariant type parameter, IRequest<out TResponse>. Covariance means an IRequest<Derived> is usable where an IRequest<Base> is expected, which is what lets a base request type constrain a family of responses.

The pairing is one-to-one and enforced at registration. One request type, one handler, resolved from the container by its closed generic type. A request with no meaningful response implements the non-generic IRequest, whose handler returns Unit.

Send() dispatches a request to its single handler and returns TResponse. Publish() is the other half of the library: a notification goes to every handler registered for it, and returns nothing.

Getting the type parameters wrong is the most common MediatR error, because the mismatch surfaces at resolution time rather than compile time.

One thing to settle before we build on the library. From v13.0.0, published in July 2025, MediatR is dual-licensed: the Reciprocal Public License 1.5, which asks us to release the source of what we build with it, or a commercial licence. Version 12.5.0 and earlier stay Apache-2.0 permanently, so [12.5.0,13.0.0) is the safe pin. The commercial arm carries a free Community tier, and it takes all four of its conditions: annual gross revenue or non-profit budget under $5,000,000 USD, no more than $10,000,000 USD in outside capital, not a government or quasi-government entity, and not a university using the library for institutional or operational software. Paid tiers are published, per product: Standard (1–10 developers) $499 a year or $50 a month, Professional (11–50) $1,499 or $150, Enterprise (unlimited) $3,999 or $400. Our article on CQRS and MediatR in ASP.NET Core covers the same ground for the library as a whole.

The change is observable, not just legal. Constructing the mediator on MediatR 14 without a licence key logs a warning: “You do not have a valid license key for the Lucky Penny software MediatR. This is allowed for development and testing scenarios. If you are running in production you are required to have a licensed version.” Nothing stops working, but the RPL-1.5 arm is a source-reciprocity path, not a licence to run unlicensed in production.

How Do We Handle Exceptions Globally in MediatR?

By implementing IRequestExceptionHandler<TRequest, TResponse, TException> once, with open generics, and registering it for every request type at once.

The interface has a single Handle() method taking the request that failed, the exception, a state object, and a cancellation token. Inside it we log the exception and build a response, then call SetHandled() on the state object with that response. That call is what stops the exception propagating: MediatR returns our response to the caller instead of letting the exception reach the pipeline’s edge.

The response we pass does the suppressing, not the call itself: SetHandled(null) still rethrows, and only a non-null response makes Send() return normally.

Registration is one line with open generics, mapping IRequestExceptionHandler<,,> to our implementation, so one handler covers every request type.

For the response to be constructible generically, the handler needs a constraint: a shared base response type with a new() constraint, so it can create one and fill in the failure.

This is not the only place to put a safety net. ASP.NET Core offers middleware-based global error handling that sits outside MediatR entirely, and newer versions add IExceptionHandler, the framework’s own hook. The MediatR route is the one to pick when the failure is a property of the request pipeline rather than of the HTTP request.

The Implementation

We will be using MediatR’s IRequestExceptionHandler<TRequest, TResponse, TException> interface:

public interface IRequestExceptionHandler<in TRequest, TResponse, in TException>
    where TRequest : notnull
    where TException : Exception
{
    Task Handle(
        TRequest request,
        TException exception,
        RequestExceptionHandlerState<TResponse> state,
        CancellationToken cancellationToken);
}

This is essentially an interface that defines how to handle exceptions of a specific type TException that might occur when handling a particular type of request TRequest together with the return type TResponse.

As we can see, the IRequestExceptionHandler interface has three type parameters: TRequest, TResponse, and TException.

The TRequest type parameter must implement the IRequest<TResponse> interface, which is used to define a request that can be handled by a request handler, and also specifies the type of response returned.

Next, the TResponse type parameter represents the response type returned when we handle the request.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

Finally, the TException type represents the type of exception our middleware will handle later. We can also specify the type of exception we want to catch more concrete, for more accurate handling.

Mismatching those three is where most of the pain lives, and the compiler is not always the one to tell us. Our article on the “TRequest cannot be used as type parameter” error walks through the constraint violation that produces it.

Following up on that, we define our custom request:

public class BaseRequest<TResponse> : IRequest<TResponse> where TResponse : BaseResponse { }

public class GetWeatherRequest : BaseRequest<WeatherResponse> { }

We also need a response:

public class BaseResponse
{
    public bool HasError { get; set; }
    public string Message { get; set; } = null!;
}

public class WeatherResponse : BaseResponse { }

Now, let’s add it all together and create our GlobalRequestExceptionHandler class, which implements the aforementioned IRequestExceptionHandler interface.

Firstly, we need to install a package:

dotnet add package Ben.Demystifier

With this package installed, we can start creating our GlobalRequestExceptionHandler class:

public class GlobalRequestExceptionHandler<TRequest, TResponse, TException>
  : IRequestExceptionHandler<TRequest, TResponse, TException>
      where TResponse : BaseResponse, new()
      where TException : Exception
{
    private readonly ILogger<GlobalRequestExceptionHandler<TRequest, TResponse, TException>> _logger;

    public GlobalRequestExceptionHandler(
       ILogger<GlobalRequestExceptionHandler<TRequest, TResponse, TException>> logger)
    {
        _logger = logger;
    }

    public Task Handle(TRequest request, TException exception, RequestExceptionHandlerState<TResponse> state,
        CancellationToken cancellationToken)
    {
        var ex = exception.Demystify();

        _logger.LogError(ex, "Something went wrong while handling request of type {@requestType}", typeof(TRequest));

        var response = new TResponse
        {
            HasError = true,
            Message = "A server error ocurred",
        };

        state.SetHandled(response);

        return Task.CompletedTask;
    }
}

IRequestExceptionHandler requires us to implement the Handle() method, which takes four parameters: the request that caused the exception, the thrown exception itself, a state object that allows us to set the response for the request and a cancellationToken.

With the help of the where keyword, we specify our previously created response.

Inside the Handle() method we start by invoking the Demystify() method on the caught exception. This method is part of the Ben.Demystifier NuGet package we installed. This helps us to understand the exceptions better, preventing confusion on potential long stack traces.

The sample pins Ben.Demystifier 0.4.1, which is Apache-2.0. Its job has narrowed since this article first appeared: on .NET 10 the runtime already keeps the compiler’s state-machine frames out of an async stack trace, so Demystify() now mostly rewrites what is left into C# syntax, naming generic arguments, marking async methods, and giving lambdas and local functions readable names. Useful, but no longer the difference between a readable trace and an unreadable one. If we would rather not take the dependency, the trace we get without it is described in our guide to exception handling in C#.

Afterward, we simply log the raised exception.

Next, we want to use our state object of the RequestExceptionHandlerState<TReponse> type, mark the exception as handled, and pass our response as an argument to the SetHandled() method. This will return the created response in case of an error.

The argument matters more than the call. Passing null marks the request handled but leaves MediatR nothing to return, so the original exception is rethrown; only a non-null response makes Send() come back normally.

Registering Our Global Exception Handler

Once we’ve created our exception handler, we need to register it as a service within the dependency injection container inside our Program.cs:

builder.Services.AddTransient(
    typeof(IRequestExceptionHandler<,,>),
    typeof(GlobalRequestExceptionHandler<,,>));

This basically means that whenever an instance of IRequestExceptionHandler<,,> with a specific type of argument is needed, provide an instance of GlobalRequestExceptionHandler<,,>.

One scope note for MediatR 14. It expects an ILoggerFactory in the container, and ASP.NET Core registers one for us, so a Web API like this sample needs nothing extra. Host MediatR somewhere else, though, in a console app, a worker, or a test fixture built on a bare ServiceCollection, and we have to call services.AddLogging() ourselves before AddMediatR(), or the container throws when it resolves the mediator.

Testing the Global Exception Handler

Let’s attempt to generate an exception when we are using MediatR in a controller:

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private readonly IMediator _mediator;

    public WeatherForecastController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet(Name = "GetWeather")]
    public async Task<ActionResult<WeatherResponse>> GetWeather()
    {
        var result = await _mediator.Send(new GetWeatherRequest());

        return result.HasError ? Problem(result.Message) : Ok(result);
    }
}

Our application consists of a single WeatherForecastController together with an IMediator instance. We also want to return a Problem() when an error has been raised, which is indicated by the property HasError on our result.

That pairing of a BaseResponse carrying a HasError flag with a caller that branches on it is a hand-rolled result type, which is worth naming because it is halfway to the result pattern in .NET, the alternative the last section of this article weighs against throwing.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

Once the HTTP request reaches the WeatherForecastController, it passes it to the handler:

public class GetWeatherHandler : IRequestHandler<GetWeatherRequest, WeatherResponse>
{
    public Task<WeatherResponse> Handle(GetWeatherRequest request, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
}

To emulate the behavior of an unexpected exception, inside the Handle() method, we throw a new NotImplementedException(). We can use any type of exception; the purpose here is to imitate unexpected errors.

Let’s run our application and call our WeatherForecast endpoint through Swagger:

Generic Exception Handling with MediatR: Swagger request to the WeatherForecastController

After that, we can inspect the response:

Generic Exception Handling with MediatR: Error response after executing our endpoint.

As we expected the response resulted in a 500 Internal Server Error.

Let’s review our console log for recorded exceptions:

Generic Exception Handling with MediatR: Error in console log showing the exception raised when requesting weather forecast

It appears that we have encountered an exception of the type System.NotImplementedException, which is exactly what we’ve thrown inside our GetWeatherHandler invoked by MediatR, together with the entire stack trace.

Should a Handler Return ErrorOr<Success> or Throw?

Return a result for outcomes we expect. Throw for the ones we do not.

A handler that can legitimately fail (the record was not there, the input did not validate, the version conflicted) is describing an outcome, not an accident. Declaring IRequestHandler<GetWeatherRequest, ErrorOr<Success>> puts that in the signature, so the caller sees both possibilities and handles them with a Match() over the result.

The exception handler in this article covers the other case: the database is down, a dependency threw, a bug reached production. Nothing at the call site should be expected to anticipate those, and a single handler that logs and converts them is exactly right.

Using exceptions for expected failures costs more than performance. It hides the failure from the signature, so the compiler cannot help, and it pushes control flow through a mechanism designed for the unexpected. Microsoft’s exception guidance agrees: “A common error case can be considered a normal flow of control.”

Most codebases end up with both.

Throw, with an exception handlerReturn ErrorOr<Success> or a result type
Handler signatureIRequestHandler<TRequest, TResponse>IRequestHandler<TRequest, ErrorOr<Success>>
Failure is visible in the signatureNoYes
Caller has to remember to checkNo; it cannot reach the happy pathYes; the compiler helps, it does not force
Cost of a failureAn exception: stack capture and unwindingNo allocation of its own — ErrorOr<T> is a readonly struct
SuitsGenuinely exceptional conditions (a broken dependency, a bug)Expected outcomes: not found, validation failed, conflict
Mapping to HTTPOne place, in the exception handler or middlewareAt the call site, usually a Match() over the result
Extra dependencyNoneA result-type package such as ErrorOr 2.1.1

The result arm of that table needs a package, and ErrorOr 2.1.1 is the common choice. ErrorOr<T> is a readonly struct there, so returning a failure costs no allocation of its own, and Result.Success gives us the Success value that ErrorOr<Success> carries on the happy path.

Conclusion

Wrapping up, in this article we’ve covered a generic global exception-handling mechanism in an ASP.NET Core application when using the MediatR library, preventing application crashes and unauthorized exposure of sensitive details to users.

The mechanism is one open-generic IRequestExceptionHandler, one registration line, and one SetHandled() call with a real response in it. Reach for it when a failure is genuinely unexpected, and return a result instead when the failure is part of what the handler is for.

Tested with .NET 10.0.10.