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.
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();
}
}
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:
Have a goat day 🐐
Join the conversation.