How should you handle errors in your code?
This has been a topic of many discussions, and I want to share my opinion.
One school of thought suggests using exceptions for flow control. This is not a good approach because it makes the code harder to reason about. The caller must know the implementation details and which exceptions to handle.
Exceptions are for exceptional situations.
Today, I want to show you how to implement error handling using the Result pattern.
It's a functional approach to error handling, making your code more expressive.
Exceptions For Flow Control
Using exceptions for flow control is an approach to implement the fail-fast principle.
As soon as you encounter an error in the code, you throw an exception — effectively terminating the method, and making the caller responsible for handling the exception.
The problem is the caller must know which exceptions to handle. And this isn't obvious from the method signature alone.
Another common use case is throwing exceptions for validation errors.
Here's an example in the FollowerService
:
public sealed class FollowerService
{
private readonly IFollowerRepository _followerRepository;
public FollowerService(IFollowerRepository followerRepository)
{
_followerRepository = followerRepository;
}
public async Task StartFollowingAsync(
User user,
User followed,
DateTime createdOnUtc,
CancellationToken cancellationToken = default)
{
if (user.Id == followed.Id)
{
throw new DomainException("Can't follow yourself");
}
if (!followed.HasPublicProfile)
{
throw new DomainException("Can't follow non-public profile");
}
if (await _followerRepository.IsAlreadyFollowingAsync(
user.Id,
followed.Id,
cancellationToken))
{
throw new DomainException("Already following");
}
var follower = Follower.Create(user.Id, followed.Id, createdOnUtc);
_followerRepository.Insert(follower);
}
}
Use Exceptions for Exceptional Situations
A rule of thumb I follow is to use exceptions for exceptional situations. Since you already expect potential errors, why not make it explicit?
You can group all application errors into two groups:
- Errors you know how to handle
- Errors you don't know how to handle
Exceptions are an excellent solution for the errors you don't know how to handle. And you should catch and handle them at the lowest level possible.
What about the errors you know how to handle?
You can handle them in a functional way with the Result pattern. It's explicit and clearly expresses the intent that the method can fail. The drawback is the caller has to manually check if the operation failed.
Expressing Errors Using the Result Pattern
The first thing you will need is an Error
class to represent application errors.
-
Code
- unique name for the error in the application -
Description
- contains developer-friendly details about the error
public sealed record Error(string Code, string Description)
{
public static readonly Error None = new(string.Empty, string.Empty);
}
Then, you can implement the Result
class using the Error
to describe the failure. This implementation is very bare-bones, and you could add many more features. In most cases, you also need a generic Result<T>
class, which will wrap a value inside.
Here's what the Result
class looks like:
public class Result
{
private Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None ||
!isSuccess && error == Error.None)
{
throw new ArgumentException("Invalid error", nameof(error));
}
IsSuccess = isSuccess;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
}
The only way to create a Result
instance is by using static methods:
-
Success
- creates a success result -
Failure
- creates a failure result with the specifiedError
If you want to avoid building your own Result
class, take a look at the FluentResults library.
Applying the Result Pattern
Now that we have the Result
class let's see how to apply it in practice.
Here's a refactored version of the FollowerService
. Notice a few things:
- No more throwing exceptions
- The
Result
return type is explicit - It's clear which errors the method returns
Another benefit of error handling using the Result pattern is that it's easier to test.
public sealed class FollowerService
{
private readonly IFollowerRepository _followerRepository;
public FollowerService(IFollowerRepository followerRepository)
{
_followerRepository = followerRepository;
}
public async Task<Result> StartFollowingAsync(
User user,
User followed,
DateTime utcNow,
CancellationToken cancellationToken = default)
{
if (user.Id == followed.Id)
{
return Result.Failure(FollowerErrors.SameUser);
}
if (!followed.HasPublicProfile)
{
return Result.Failure(FollowerErrors.NonPublicProfile);
}
if (await _followerRepository.IsAlreadyFollowingAsync(
user.Id,
followed.Id,
cancellationToken))
{
return Result.Failure(FollowerErrors.AlreadyFollowing);
}
var follower = Follower.Create(user.Id, followed.Id, utcNow);
_followerRepository.Insert(follower);
return Result.Success();
}
}
Documenting Application Errors
You can use the Error
class to document all possible errors in your application.
One approach is to create a static class called Errors
. It will have nested classes inside containing the specific errors. The usage would look like Errors.Followers.NonPublicProfile
.
However, the approach I like to use is to create a specific class containing the errors.
Here's the FollowerErrors
class documenting the possible errors for the Follower
entity:
public static class FollowerErrors
{
public static readonly Error SameUser = new Error(
"Followers.SameUser", "Can't follow yourself");
public static readonly Error NonPublicProfile = new Error(
"Followers.NonPublicProfile", "Can't follow non-public profiles");
public static readonly Error AlreadyFollowing = new Error(
"Followers.AlreadyFollowing", "Already following");
}
Instead of static fields, you can also use static methods returning an error. You would call this method with a concrete argument to get an Error
instance.
public static class FollowerErrors
{
public static Error NotFound(Guid id) => new Error(
"Followers.NotFound", $"The follower with Id '{id}' was not found");
}
Converting Results Into API Responses
The Result
object will eventually reach the Minimal API (or controller) endpoint in ASP.NET Core. Minimal APIs return an IResult
response, and controllers return an IActionResult
response. Regardless, you must convert the Result
instance into a valid API response.
The straightforward approach is checking the Result
state and returning an HTTP response. Here's an example where we check the Result.IsFailure
flag:
app.MapPost(
"users/{userId}/follow/{followedId}",
(Guid userId, Guid followedId, FollowerService followerService) =>
{
var result = await followerService.StartFollowingAsync(
userId,
followedId,
DateTime.UtcNow);
if (result.IsFailure)
{
return Results.BadRequest(result.Error);
}
return Results.NoContent();
});
However, this is an excellent opportunity for a more functional approach. You can implement the Match
extension method to provide a callback for each Result
state. The Match
method will execute the respective callback and return the result.
Here's the implementation of Match
:
public static class ResultExtensions
{
public static T Match(
this Result result,
Func<T> onSuccess,
Func<Error, T> onFailure)
{
return result.IsSuccess ? onSuccess() : onFailure(result.Error);
}
}
And this is how you would use the Match
method in a Minimal API endpoint:
app.MapPost(
"users/{userId}/follow/{followedId}",
(Guid userId, Guid followedId, FollowerService followerService) =>
{
var result = await followerService.StartFollowingAsync(
userId,
followedId,
DateTime.UtcNow);
return result.Match(
onSuccess: () => Results.NoContent(),
onFailure: error => Results.BadRequest(error));
});
Much more concise. Don't you think so?
Summary
If you take one thing with you from this week's issue, it should be this: exceptions are for exceptional situations. Moreover, you should only use exceptions for errors you don't know how to handle. In all other cases, expressing the error clearly with the Result pattern is more valuable.
Using the Result
class allows you to:
- Express the intent that a method could fail
- Encapsulate an application error inside
- Provide a functional way to handle errors
Additionally, you can document all application errors with the Error
class. This is helpful for developers to know which errors they need to handle.
You can even convert this to actual documentation. For example, I wrote a simple program that scans the project for all Error
fields. It then converts this into a table format and uploads it to a Confluence page.
So I encourage you to try the Result pattern and see how it can improve your code.
See you next week.
P.S. Whenever you're ready, there are 2 ways I can help you:
Pragmatic Clean Architecture: This comprehensive course will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 1,050+ students here.
Patreon Community: Think like a senior software engineer with access to the source code I use in my YouTube videos and exclusive discounts for my courses. Join 880+ engineers here.
Top comments (0)