In modern software development, handling errors and exceptional scenarios gracefully is crucial for building robust applications.
While exceptions are a common mechanism in .NET for error handling, they can introduce performance overhead and complicate code flow.
Today we will explore how to replace exceptions with the Result pattern in .NET, enhancing code readability, maintainability, and performance.
On my website: antondevtips.com I share .NET and Architecture best practices.
Subscribe to become a better developer.
Download the source code for this blog post for free.
Introduction to Exception Handling in .NET
Exception handling is a fundamental concept in .NET programming, allowing developers to manage runtime errors gracefully.
The typical approach involves using try, catch, and finally blocks to capture and handle exceptions.
Let's explore an application that creates a Shipment and uses exceptions for control flow:
public async Task<ShipmentResponse> CreateAsync(
CreateShipmentCommand request,
CancellationToken cancellationToken)
{
var shipmentAlreadyExists = await context.Shipments
.Where(s => s.OrderId == request.OrderId)
.AnyAsync(cancellationToken);
if (shipmentAlreadyExists)
{
throw new ShipmentAlreadyExistsException(request.OrderId);
}
var shipment = request.MapToShipment(shipmentNumber);
context.Shipments.Add(shipment);
await context.SaveChangesAsync(cancellationToken);
return shipment.MapToResponse();
}
Here ShipmentAlreadyExistsException
is thrown if a shipment already exists in the database.
In the Minimal API endpoint, this exception is handled as follows:
public void MapEndpoint(WebApplication app)
{
app.MapPost("/api/v1/shipments", Handle);
}
private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IShipmentService service,
CancellationToken cancellationToken)
{
try
{
var command = request.MapToCommand();
var response = await service.CreateAsync(command, cancellationToken);
return Results.Ok(response);
}
catch (ShipmentAlreadyExistsException ex)
{
return Results.Conflict(new { message = ex.Message });
}
}
While this approach works, it has the following drawbacks:
- code is unpredictable, by looking at
IShipmentService.CreateAsync
you can't know for sure if the method throws exceptions or not - you need to use catch statements, and you can no longer read code from top to bottom line-by-line, you need to jump your view back and forward
- exceptions can lead to performance issues in some applications as they are pretty slow (they became faster in recent .NET 9 but still they are slow)
Remember, exceptions are for exceptional situations. They are not the best option for a control flow.
Instead, I want to show you a better approach with a Result Pattern.
Understanding the Result Pattern
The Result pattern is a design pattern that encapsulates the result of an operation, which can either be a success or a failure.
Instead of throwing exceptions, methods return a Result
object indicating whether the operation succeeded or failed, together with any relevant data or error messages.
Result Object consists of the following parts:
- IsSuccess/IsError: A boolean indicating if the operation was successful or not.
- Value: The result value when the operation is successful.
- Error: An error message or object when the operation fails.
Let's explore a simple example on how to implement a Result
object:
public class Result<T>
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; }
public string? Error { get; }
private Result(bool isSuccess, T? value, string? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public static Result<T> Success(T value)
{
return new Result<T>(true, value, null);
}
public static Result<T> Failure(string error)
{
return new Result<T>(false, default(T), error);
}
}
Here we have defined a generic Result
class that can hold either a successful value or an error.
To create Result
object you can use either Success
or Failure
static method.
Let's rewrite the previous Create Shipment
endpoint implementation with our Result pattern:
public async Task<Result<ShipmentResponse>> CreateAsync(
CreateShipmentCommand request,
CancellationToken cancellationToken)
{
var shipmentAlreadyExists = await context.Shipments
.Where(s => s.OrderId == request.OrderId)
.AnyAsync(cancellationToken);
if (shipmentAlreadyExists)
{
return Result.Failure<ShipmentResponse>($"Shipment for order '{request.OrderId}' is already created");
}
var shipment = request.MapToShipment(shipmentNumber);
context.Shipments.Add(shipment);
await context.SaveChangesAsync(cancellationToken);
var response = shipment.MapToResponse();
return Result.Success(response);
}
Here the method returns Result<ShipmentResponse>
that wraps ShipmentResponse
inside a Result class.
When a shipment already exists in the database, we return Result.Failure
with a corresponding message.
When a request succeeds, we return Result.Success
.
Here is how you handle a Result Object in the endpoint:
public void MapEndpoint(WebApplication app)
{
app.MapPost("/api/v1/shipments", Handle);
}
private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IShipmentService service,
CancellationToken cancellationToken)
{
var command = request.MapToCommand();
var response = await service.CreateAsync(command, cancellationToken);
return response.IsSuccess ? Results.Ok(response.Value) : Results.Conflict(response.Error);
}
You need to check if the response is successful or failed and return an appropriate HTTP result.
Now the code looks more predictable and reads more easily, right?
The current Result
object implementation is very simplified, in real applications you need more features within it.
You can spend some time and build one for you and reuse in all projects. Or you can use a ready Nuget package.
There are a plenty Nuget packages with Result Pattern implementation, let me introduce you to a few most popular:
My favourite one is error-or
, let's explore it.
Result Pattern with Error-Or
As author of the package stated: Error-Or is a simple, fluent discriminated union of an error or a result.
Result class in this library is called ErrorOr<T>
.
Here is how you can use it for control flow:
public async Task<ErrorOr<ShipmentResponse>> Handle(
CreateShipmentCommand request,
CancellationToken cancellationToken)
{
var shipmentAlreadyExists = await context.Shipments
.Where(s => s.OrderId == request.OrderId)
.AnyAsync(cancellationToken);
if (shipmentAlreadyExists)
{
return Error.Conflict($"Shipment for order '{request.OrderId}' is already created");
}
var shipment = request.MapToShipment(shipmentNumber);
context.Shipments.Add(shipment);
await context.SaveChangesAsync(cancellationToken);
return shipment.MapToResponse();
}
Here the return type is ErrorOr<ShipmentResponse>
that indicates whether an error is returned or ShipmentResponse
.
Error
class has built-in errors for the following types, providing a method for each error type:
public enum ErrorType
{
Failure,
Unexpected,
Validation,
Conflict,
NotFound,
Unauthorized,
Forbidden,
}
The library allows you to create custom errors if needed.
In the API endpoint you can handle the error similar to what we did before with our custom Result
Object:
public void MapEndpoint(WebApplication app)
{
app.MapPost("/api/v2/shipments", Handle);
}
private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IShipmentService service,
CancellationToken cancellationToken)
{
var command = request.MapToCommand();
var response = await mediator.Send(command, cancellationToken);
if (response.IsError)
{
return Results.Conflict(response.Errors);
}
return Results.Ok(response.Value);
}
When working with Error-Or I like creating a static method ToProblem
that transforms error to the appropriate status code:
public static class EndpointResultsExtensions
{
public static IResult ToProblem(this List<Error> errors)
{
if (errors.Count == 0)
{
return Results.Problem();
}
return CreateProblem(errors);
}
private static IResult CreateProblem(List<Error> errors)
{
var statusCode = errors.First().Type switch
{
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
};
return Results.ValidationProblem(errors.ToDictionary(k => k.Code, v => new[] { v.Description }),
statusCode: statusCode);
}
}
This way a previous code will be transformed to:
var response = await mediator.Send(command, cancellationToken);
if (response.IsError)
{
return response.Errors.ToProblem();
}
return Results.Ok(response.Value);
Pros and Cons of Using the Result Pattern
Benefits of Using the Result Pattern:
- Explicit Error Handling: the caller must handle the success and failure cases explicitly. From the method signature, it's obvious that an error may be returned.
- Improved Performance: reduces the overhead associated with exceptions.
- Better Testing: simplifies unit testing as it's much easier to mock Result Object than throwing and handling exceptions.
- Safety: a result object should contain information that can be exposed to the outside world. While you can save all the details using Logger or other tools.
Potential Drawbacks:
- Verbosity: can introduce more code compared to using exceptions as you need to mark all methods in the stacktrace to return Result Object
- Not Suitable for All Cases: exceptions are still appropriate for truly exceptional situations that are not expected during normal operation.
Result pattern looks great, but should we just forget about exceptions?
Absolutely not! Exceptions still have their usage. Let's discuss it.
When To Use Exceptions
Exceptions are for exceptional cases, and I see the following use cases where they might be a good fit:
- Global Exception Handling
- Library Code
- Domain Entities Guard Validation
Let's have a closer look at these 3 cases:
Global Exception Handling
In your asp.net core applications, you definitely need to handle exceptions.
They can be thrown anywhere: database access, network calls, I/O operations, libraries, etc.
You need to be prepared that exceptions will occur and handle them gracefully.
For this I implement IExceptionHandler
which is available starting from .NET 8:
internal sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Exception occurred: {Message}", exception.Message);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server error"
};
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Library Code
In libraries, usually exceptions are thrown when something goes wrong.
Here are the reasons for this:
- most of the developers are familiar with exceptions and know how to handle them
- libraries don't want to be opinionated and use a one or another Result Pattern library or implement their own version
But remember when building libraries use exceptions as the last available option.
Often it is better to return a null, an empty collection, boolean false value than throwing an exception.
Domain Entities Guard Validation
When following Domain-Driven Design Principles (DDD), you construct your domain models using constructors or factory methods.
If you pass data for Domain Object creation and this data is invalid (but it should never be) - you may throw an exception.
This will be an indication that your input validation, mapping or other application layers have a bug which should be addressed.
Summary
Replacing exceptions with the Result pattern in .NET can lead to more robust and maintainable code.
By explicitly handling success and failure cases, developers can write clearer code that is easier to test and understand.
While it may not be suitable for all scenarios, incorporating the Result pattern can significantly improve error handling in your applications.
On my website: antondevtips.com I share .NET and Architecture best practices.
Subscribe to become a better developer.
Download the source code for this blog post for free.
Top comments (0)