In the expanding world of APIs, meaningful error responses can be just as crucial as well-structured success responses, and here, the Result Pattern can help us greatly.
To introduce the Result Pattern, we will go through a series of iterations, presenting different ways of passing information about the success or failure of a given operation. We will do so by building a simple API. At any given moment, we can look up the complete solution on GitHub, so for brevity, we will omit some parts of the code and concentrate on the crucial parts. Our implementation uses the Repository Pattern and service layer.
But before we start with the Result Pattern, let’s check a few other ways we can use to return results in our apps.
VIDEO: Result Pattern in ASP.NET Core Web API.
Null Checking
We can use a null value to pass information about failure from the service to the controller.
We can implement our new service class NullCheckingContactService
: (for brevity, some code is omitted):
public class NullCheckingContactService { // ... public ContactDto? GetById(Guid id) { var contact = _contactRepository.GetById(id); if (contact is null) { return null; } return new ContactDto(contact.Id, contact.Email); } // ... }
Our GetById()
method would return the null value for the non-existing contacts.
Our new controller NullCheckingContactController
looks like this:
[ApiController] [Route("api/v2/contacts")] public class NullCheckingContactController : ControllerBase { // ... [HttpGet("{id}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<ContactDto> GetById(Guid id) { var contactDto = _contactService.GetById(id); if (contactDto is null) { return NotFound(); } return Ok(contactDto); } // ... }
Here, we check whether the service returned the null value and respond correctly. For the GetById()
method, we will return the 404 Not Found
response.
While this solution is simple, it has severe limitations. We have to handle null values in the controller actions and respond appropriately. Very little information is passed from the service to the controller, and we must know precisely when null means that the contact was not found and when we couldn’t create it – not to mention why creation failed.
Exceptions For Control Flow
Instead of returning the null value, our service can throw an exception (of the appropriate type) to signal that something went wrong.
First, we must create exception classes, such as a RecordNotFoundException
for signaling that the contact wasn’t found:
public class RecordNotFoundException : Exception { public RecordNotFoundException(string message) : base(message) { } }
Now we can create our new service ExceptionsForFlowControlContactService
:
public class ExceptionsForFlowControlContactService { // ... public ContactDto GetById(Guid id) { var contact = _contactRepository.GetById(id); if (contact is null) { throw new RecordNotFoundException($"contact with id {id} not found"); } return new ContactDto(contact.Id, contact.Email); } // ... }
To handle the exception, we can create exception-handling middleware. We won’t delve into the details, but for more information check out our Global Exception Handling in Web API article.
This solution has advantages: error messages are informative, we can handle different exception types, controller actions are simple, and introducing custom exception types can ensure more consistent responses.
Of course, there is another solution if you don’t want to use the exception flow: the Result Pattern.
The Result Pattern
What is the Result Pattern? It is a way of returning a response containing the operation’s outcome and any data it returned. Let’s elaborate on that a little further.
CustomError
First, we will create the CustomError
record to represent the operation outcome:
public sealed record CustomError(string Code, string Message) { private static readonly string RecordNotFoundCode = "RecordNotFound"; private static readonly string ValidationErrorCode = "ValidationError"; public static readonly CustomError None = new(string.Empty, string.Empty); public static CustomError RecordNotFound(string message) { return new CustomError(RecordNotFoundCode, message); } public static CustomError ValidationError(string message) { return new CustomError(ValidationErrorCode, message); } }
For any error, we can provide information about its category (Code
) and detailed description (Message
).
While we could create a new static class and, in it, create different categories of errors, for simplicity’s sake, we made the static factory methods RecordNotFound()
, ValidationError()
, and the static field None
(representing no error).
CustomResult
Now, we can create the CustomResult
class that represents our result:
public class CustomResult<T> { private readonly T? _value; private CustomResult(T value) { Value = value; IsSuccess = true; Error = CustomError.None; } private CustomResult(CustomError error) { if (error == CustomError.None) { throw new ArgumentException("invalid error", nameof(error)); } IsSuccess = false; Error = error; } public bool IsSuccess { get; } public bool IsFailure => !IsSuccess; public T Value { get { if (IsFailure) { throw new InvalidOperationException("there is no value for failure"); } return _value!; } private init => _value = value; } public CustomError Error { get; } public static CustomResult<T> Success(T value) { return new CustomResult<T>(value); } public static CustomResult<T> Failure(CustomError error) { return new CustomResult<T>(error); } }
This is a generic class that we can use to return results of any type. We have created two private constructors. The first will create an object in case of success, and the other in case of failure. There are two public properties: IsSuccess
and IsFailure
, which we can use to test whether a given operation is completed successfully. To retrieve the data, we use the Value
property. Finally, there are two static methods: Success()
(to create a CustomResult
object if everything was OK) and Failure()
to return the appropriate error.
Let’s see it in action by creating TheResultPatternContactService
class:
public class TheResultPatternContactService { // ... public CustomResult<ContactDto> GetById(Guid id) { var contact = _contactRepository.GetById(id); if (contact is null) { var message = $"contact with id {id} not found"; return CustomResult<ContactDto>.Failure(CustomError.RecordNotFound(message)); } return CustomResult<ContactDto>.Success(new ContactDto(contact.Id, contact.Email)); } public CustomResult<ContactDto> Create(CreateContactDto createContactDto) { if (_contactRepository.GetByEmail(createContactDto.Email) is not null) { var message = $"contact with email {createContactDto.Email} already exists"; return CustomResult<ContactDto>.Failure(CustomError.ValidationError(message)); } var contact = new Contact { Email = createContactDto.Email }; var createdContact = _contactRepository.Create(contact); return CustomResult<ContactDto>.Success(new ContactDto(createdContact.Id, createdContact.Email)); } }
Here, we see that we can return complete results, regardless of the state of the operation.
Now, we will create the TheResultPatternContactController
class:
[ApiController] [Route("api/v4/contacts")] public class TheResultPatternContactController : ControllerBase { // ... [HttpGet("{id}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<ContactDto> GetById(Guid id) { var result = _contactService.GetById(id); if (result.IsFailure) { return NotFound(result.Error.Message); } return Ok(result.Value); } [HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public IActionResult Create(CreateContactDto createContactDto) { var result = _contactService.Create(createContactDto); if (result.IsFailure) { return BadRequest(result.Error.Message); } var contactDto = result.Value; return CreatedAtAction(nameof(GetById), new {id = contactDto.Id}, contactDto); } }
As we can see with the Result Pattern, we can easily differentiate whether a given operation was completed successfully, and if it failed, we can retrieve a detailed explanation.
This approach has many benefits: error messages are informative, the controller’s actions have minimal to no logic, and the execution path is more accessible to follow (compared to using exceptions for control flow).
As always, there are cons, but they are relatively minor this time. We should ensure that we are using this pattern when needed. There is no native support for it (yet).
Using FluentResults for Result Pattern
While we can reinvent the wheel and create custom result classes, using existing libraries such as FluentResults is more practical. While our custom implementation is trivial, this library is way more powerful.
First, we will need to add it to our project:
PM> Install-Package FluentResults
Now, we can create our error classes by extending the Error
class provided by the library:
public class RecordNotFoundError : Error { public RecordNotFoundError(string message) : base(message) { } } public class ValidationError : Error { public ValidationError(string message) : base(message) { } }
One class represents a not found error, and the other is for validation errors.
Then, we can create our (final) FluentResultsContactService
class:
public class FluentResultsContactService { // ... public Result<ContactDto> GetById(Guid id) { var contact = _contactRepository.GetById(id); if (contact is null) { return new RecordNotFoundError($"contact with id {id} not found"); } return Result.Ok(new ContactDto(contact.Id, contact.Email)); } public Result<ContactDto> Create(CreateContactDto contact) { if (_contactRepository.GetByEmail(contact.Email) is not null) { return new ValidationError("contact with this email already exists"); } var createdContact = _contactRepository.Create(new Contact {Email = contact.Email}); return Result.Ok(new ContactDto(createdContact.Id, createdContact.Email)); } }
Here, we will return an appropriate error if anything goes wrong. If everything is OK, we return Result.Ok()
with the proper value.
Now, let’s create the FluentResultsContactController
class:
[ApiController] [Route("api/v5/contacts")] public class FluentResultsContactController : ControllerBase { // ... [HttpGet("{id}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<ContactDto> GetById(Guid id) { var result = _contactService.GetById(id); if (result.IsFailed) { return NotFound(result.Errors); } return Ok(result.Value); } [HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public IActionResult Create(CreateContactDto createContactDto) { var result = _contactService.Create(createContactDto); if (result.IsFailed) { return BadRequest(result.Errors); } return CreatedAtAction(nameof(GetById), new {id = result.Value.Id}, result.Value); } }
We can check if a given operation failed by checking the IsFailed
property and then retrieving any errors with the Errors
property. If everything goes as planned, we can use Value
property.
Conclusion
Now we know why we might want to use the Result Pattern and how to do it properly. We have seen different ways to pass results from our services into controllers and how that can influence our ability to signal error conditions. Each method has pros and cons, and we should strive to balance the two, leading us to the Result Pattern as the perfect strategy.