Improving Error Handling with the Result Pattern in MediatR

Pierre Belin
Pierre Belin
Improving Error Handling with the Result Pattern in MediatR
Table of Contents
Table of Contents

The integration of the Result pattern in MediatR is a sophisticated technique that enhances error handling and operational feedback in applications utilizing the MediatR library for in-process messaging. If you read articles about MediatR and its integration into .NET, you've probably already seen a class called Result pass by, wondering what it contains and what it's for.

This article aims to dissect the integration process and highlight its benefits, ensuring developers can implement it with a higher degree of precision and understanding.

What does the Result pattern solve?

To understand the interest of the Result pattern, let's take an example of retrieving an element from a database in relation to an id, a classic MediatR use case for a standard API with :

  • A request DTO and a response DTO
public record GetGoatQuery(int Id) : IRequest<GetGoatResponse>;
public record GetGoatResponse(int Id, string Name);
  • A repository layer contract definition for data access
public record Goat(int Id, string Name);

public interface IGoatRepository
{
    Task<Goat?> GetById(int goatId, CancellationToken cancellationToken);
}

This gives the following handler:

public class GoatQueryHandler(IGoatRepository goatRepository) : IRequestHandler<GetGoatQuery, GetGoatResponse>
{
    public async Task<GetGoatResponse> Handle(GetGoatQuery request, CancellationToken cancellationToken)
    {
        var goat = await goatRepository.GetById(request.Id, cancellationToken);
        if (goat is null)
            // WHAT SHOULD WE DO HERE

        return new GetGoatResponse();
    }
}

The aim of this example is to focus on the return of the GetById() method, by asking how to handle the case where the element you're looking for doesn't exist in the database.

There are 2 ways to handle this:

  • Either the Repository triggers an exception (e.g. NotFoundException), which will be caught in an IPipelineBehavior.
  • Or the Repository returns an incomplete or null object, and the Handler handles the case.

We could also see a case where the handler has a try catch to catch the Repository error, but that's taking the worst of the 2 previous situations as it would duplicate some of the pipeline logic.

In my experience, I increasingly dislike the use of exceptions, which we use throughout our applications to represent error cases, as well as the fact that the use of try catch has a memory cost.

There's a difference between an error and an exception: the former is a managed behavior, while the latter is not.

When you're coding, you generally (if not always) have control over what you do in the event of a failure. It's simpler to throw an exception and hope that the caller will think of handling it, when it's much simpler to define the possible success and failure cases of the method in the return type.

I won't go any further on this subject in this article. If you're interested in learning more, I recommend Guillaume Faas' great video on the subject:

But this raises a deeper question on the subject:

Even if the Repository makes a difference in the return object between a found and an unfound element, how do you handle it in the handler to provide a corresponding response?

This is where the Result pattern comes in.

How to use the Result pattern

To explain briefly, this is a pattern that allows a method to return 2 possible states:

  • the first state corresponds to a failure in the execution of the method
  • the second corresponds to successful execution.

In this way, a method can return these 2 cases without passing through exceptions, and it will be up to the caller to define its actions in the event of success or failure (more on this later in the article).

This type of pattern has a more generic name: monads.

💡
This is a subject that requires entire articles of its own, and not just one, which we'll be sure to cover in other articles.

Before continuing, let's declare the InternalError class, which will correspond to the failure object produced by the handler.

public abstract record InternalError(ErrorReason Reason, string Message);

public enum ErrorReason
{
    NotFound,
    DuplicateId,
    ...
}

public record NotFoundError(int Id) : InternalError(Reason.NotFound, $"Element {Id} not found");

InternalError is an abstract class which functions in the same way as the Exception base class, with the basic parameters defining an error, i.e. a reason and a message.

Next, we can specify how errors are to be handled, by producing overloads for each type of error in the system. The InternalError is free to be written, so you can define it according to the needs of your error management.

By adding the Result pattern, the handler can now return both a GetGoatResponse and a NotFoundError.

public class GoatQueryHandler(IGoatRepository goatRepository) : IRequestHandler<GetGoatQuery, Result<InternalError, GetGoatResponse>>
{
    public async Task<Result<InternalError, GetGoatResponse>> Handle(GetGoatQuery request, CancellationToken cancellationToken)
    {
        var goat = await goatRepository.GetById(request.Id, cancellationToken);
        if (goat is null)
            return new NotFoundError(request.Id);
        
        return new GetGoatResponse();
    }
}
💡
In monads, we generally place the element corresponding to failure on the left and the element corresponding to success on the right.

All that remains is to discover the Result class. This is a very light version for the example, comprising only the basic functionalities for its use. But don't worry, they're often very similar, if not identical in function.

public class Result<TError, TValue> where TError : InternalError
{
    private readonly IErrorReason? _error;
    private readonly object? _value;
    
    public Result(TValue value)
    {
        _value = value;
    }

    public Result(TError error)
    {
        _error = error;
    }

    public new TValue? Value => _value is TValue value ? value : default;
    public new TError? Error => _error as TError;

    public static implicit operator Result<TError, TValue>(TValue value)
    {
        return new Result<TError, TValue>(value);
    }

    public static implicit operator Result<TError, TValue>(TError error)
    {
        return new Result<TError, TValue>(error);
    }
}

The important part is the implementation of implicit operators. These simplify the writing of results by not having to redeclare the entire expected return type each time. Using the parameter type, we define whether to send a TValue or TError object to build the pattern inside.

The Handle method returns a Result<InternalError, GetGoatResponse> type. Without these operators, the contents of the method would have to be written in this way:

var goat = await goatRepository.GetById(request.Id, cancellationToken);
if (goat is null)
    return new Result<InternalError, GetGoatResponse>(new NotFoundError(request.Id));

return new Result<InternalError, GetGoatResponse>(new GetGoatResponse());

Yes, it's awful!

One might also wonder whether it wouldn't be more interesting if the repository's GetById method also returned a Result to avoid returning a null value, and it's true. If your curiosity is piqued, I suggest you read up on monads and railway-oriented programming.

Handle Result response

Before explaining the Match method of the pattern, we must declare the return of the query as IRequest<Result<T,R>> to allow MediatR to know the return type.

public record GetGoatQuery(int Id) : IRequest<Result<InternalError, GetGoatResponse>>;

The use of Match is trivial, and to understand it, here's its implementation in the Result class with only what we need

public class Result<TError, TValue> where TError : InternalError
{
    private readonly IErrorReason? _error;
    private readonly object? _value;
    public bool IsSuccess => _value is not null;

    # ... What we had in the code previously shown

    public TResult Match<TResult>(Func<TError, TResult> failure, Func<TValue, TResult> success)
    {
        // Here we can throw exception because it's a failure case, not an error
        // It's normally impossible to get an exception from here, but compiler needs it
        return IsSuccess 
          ? success(Value ?? throw new NullReferenceException()) 
          : failure(Error ?? throw new NullReferenceException());
    }
}

The Match method expects 2 delegated functions as parameters:

  • one to handle the success case
  • one for the failure case, which is the logical continuation of the implementation.

Let's imagine a case where we use this handler through an API, we might want to return a code 200 with GetGoatResponse in the case of success, and a 404 error with the contents of InternalError in the case of elements not found.

var response = await dispatcher.Send(new GetGoatQuery(id));
return response.Match(
    Results.NotFound,
    Results.Ok);

Of course, we could go even further in error handling by transforming an InternalError into a corresponding HTTP error code.

return response.Match(
    e =>
    {
        if (e.Reason == ErrorReason.NotFound)
            return Results.NotFound(e);
        return Results.BadRequest(e);
    },
    Results.Ok);

Of course, it would be very interesting to group all the error handling logic in a dedicated class that could centralize the association of a reason with an HTTP code.

Reduce verbose of Result

The addition of the Result pattern makes verbosity much more important than before. For example, the handle changes from GetGoatResponse to Result<InternalError, GetGoatResponse>.

To avoid carrying this pattern around with you, the easiest way is to build abstract classes that don't carry it around with you in all your query and handler definitions.

// For the IRequest 
public interface IResultRequest<T> : IRequest<Result<InternalError, T>>;

// For the IRequestHandler
public interface IResultRequestHandler<TInput, TOutput> : IRequestHandler<TInput, Result<InternalError, TOutput>> where TInput : IResultRequest<TOutput>;

This gives us a script similar to the initial one, with the Result pattern totally transparent in its use.

// For the request
public record GetGoatQuery(int Id) : IBaseRequest<GetGoatResponse>;

// For the handlers
public class GoatQueryHandler(IGoatRepository goatRepository) : IResultRequestHandler<GetGoatQuery, GetGoatResponse>
{
    public async Task<Result<InternalError, GetGoatResponse>> Handle(GetGoatQuery request, CancellationToken cancellationToken)
    {
        var goat = await goatRepository.GetById(request.Id, cancellationToken);
        if (goat is null)
            return new NotFoundError(request.Id);
        
        return new GetGoatResponse();
    }
}

Conclusion

The integration of the Result pattern with MediatR represents a significant leap forward in error handling and operational feedback within the .NET ecosystem. This synergy offers a refined and comprehensive approach to managing operation outcomes, empowering developers to navigate the complexities of error handling with precision and efficiency.

By embracing this integration, applications can achieve heightened reliability and maintainability, showcasing the evolving landscape of software development practices. The Result pattern stands as a powerful and expressive addition to the MediatR toolkit, enriching the development experience and fostering a more resilient application architecture.

If you want to discover how to test it, you can read this article:

MediatR: How to Quickly Test Your Handlers with Unit Tests
Ensuring code quality and reliability is crucial in software development. MediatR, a popular library for the in-process request/response pattern, simplifies component communication but requires thorough testing. Unit testing with MediatR offers a swift way to validate development, and contrary to po…

Have a goat day 🐐



Join the conversation.

Great! Check your inbox and click the link
Great! Next, complete checkout for full access to Goat Review
Welcome back! You've successfully signed in
You've successfully subscribed to Goat Review
Success! Your account is fully activated, you now have access to all content
Success! Your billing info has been updated
Your billing was not updated