· Adam Ferguson  · 7 min read

Elevating API Error Handling with FluentResults in .NET 9

Discover a more elegant and efficient way to handle API errors in .NET 9 using FluentResults, improving performance, code clarity, and decoupling business logic for wider application use.

TLDR;

Improve .NET 9 API error handling using FluentResults for better performance, code clarity, and service layer decoupling. See the GitHub repository for the complete example.

Introduction

In the realm of building robust and maintainable applications, error handling is paramount. While traditional approaches in .NET often involve throwing and catching exceptions to manage HTTP status codes in APIs, this can lead to performance bottlenecks and a deviation from the intended purpose of exceptions. In this article, we’ll explore a more elegant and efficient solution: implementing the result pattern using the FluentResults NuGet package in a .NET 9 environment. This approach not only simplifies error handling but also promotes a key architectural principle: decoupling business logic from specific application concerns.

The Problem with Traditional Exception Handling

The conventional method of handling API errors in .NET often intertwines business logic with the specifics of HTTP status codes. This tight coupling creates challenges for code reuse and maintainability.

  1. A specific error scenario occurs (e.g., resource not found, validation failure).
  2. A custom exception is thrown to signal the error.
  3. Global exception handling middleware catches the exception.
  4. The middleware translates the exception type into the appropriate HTTP status code and a Problem Details response.

While this approach works for APIs, it has two primary drawbacks, especially concerning architectural flexibility:

  • Performance Overhead: Exceptions are designed for exceptional situations, not for routine error handling. Throwing and catching exceptions involves stack unwinding and other performance-intensive operations.
  • Tight Coupling: The service layer’s logic becomes tightly coupled with the API layer’s specific needs (HTTP status codes). This makes it difficult to reuse the service layer in other contexts (e.g., a background service, a console application) where HTTP status codes don’t apply. This is no longer only about the status codes, but is about handling the same errors differently, depending on the application type that leverages this code.

The Result Pattern: Decoupling Business Logic for Greater Flexibility

The result pattern provides an alternative, more functional approach that allows for a clean separation of concerns. Instead of throwing exceptions tailored for a specific application (like an API), methods in the service layer return a “result” object. This result encapsulates either the successful outcome of the operation or the details of any errors that occurred. The key is that the service layer remains agnostic about how these errors will be handled by the consuming application. Unfortunately, C# doesn’t have a native discriminated union feature like some functional languages which would make this much easier natively.

The FluentResults NuGet package simplifies implementing the result pattern in .NET. It offers a flexible and expressive way to represent the outcome of an operation, including success, failure, and potential error reasons.

FluentResults: A Practical Implementation

Let’s examine how FluentResults can be used in a .NET 9 Web API. The provided repository demonstrates this using a simple Penguin API.

Domain Errors:

First, we define custom error types that represent specific domain-related errors. These error types are domain-specific, not API-specific:

src/Dotnet9.WebApi.ResultPattern.Demo/Domain/Errors.cs
using FluentResults;
public abstract class DomainError : Error
{
public string ErrorCode { get; }
protected DomainError(string message, string errorCode) : base(message)
{
ErrorCode = errorCode;
}
}
public class ValidationError : DomainError
{
public string PropertyName { get; }
public ValidationError(string propertyName, string message)
: base($"Validation failed for '{propertyName}': {message}", "422")
{
PropertyName = propertyName;
}
}
public class NotFoundError : DomainError
{
public string EntityName { get; }
public object Id { get; }
public NotFoundError(string entityName, object id)
: base($"'{entityName}' with id '{id}' not found.", "404")
{
EntityName = entityName;
Id = id;
}
}
// ... other error classes

Service Layer with Results (Decoupled):

The service layer methods return Resultor Result objects, focusing solely on whether the business operation succeeded or failed. They are not concerned with HTTP status codes or API-specific error representations:

src/Dotnet9.WebApi.ResultPattern.Demo/Controllers/PenguinsController.cs
using Dotnet9.WebApi.ResultPattern.Demo.Contracts.Responses;
using Dotnet9.WebApi.ResultPattern.Demo.Domain;
using FluentResults;
public class PenguinService : IPenguinService
{
// ...
public async Task<Result<PenguinResponseDto>> GetPenguinByIdAsync(Guid penguinId)
{
var penguin = await _dbContext.Penguins.FindAsync(penguinId);
if (penguin == null)
{
return Result.Fail(new NotFoundError(nameof(penguin), penguinId));
}
var penguinResponse = new PenguinResponseDto(penguin.Id, penguin.Name, penguin.Species, penguin.Age);
return Result.Ok(penguinResponse);
}
}

API Controller (Application-Specific Handling):

In the API controller, we use the ToActionResult() extension method (and the custom FluentResultsEndpointProfile) to translate the Result into the appropriate IActionResult. This is where we map domain errors to HTTP status codes, creating a specific API representation of the errors:

using Dotnet9.WebApi.ResultPattern.Demo.Contracts.Responses;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/[controller]")]
public class PenguinsController : ControllerBase
{
private readonly IPenguinService _penguinService;
public PenguinsController(IPenguinService penguinService)
{
_penguinService = penguinService;
}
[HttpGet("{id:guid:required}")]
public async Task<IActionResult> GetPenguin(Guid id)
{
return await _penguinService
.GetPenguinByIdAsync(id)
.ToActionResult();
}
}

FluentResultsEndpointProfile Customization:

The magic is handled by FluentResultsEndpointProfile which is registered in your program.cs file and takes the results and determines what IActionResult should be returned based on the error type.

src/Dotnet9.WebApi.ResultPattern.Demo/FluentResults/FluentResultsEndpointProfile.cs
using Dotnet9.WebApi.ResultPattern.Demo.Contracts.Responses;
using Dotnet9.WebApi.ResultPattern.Demo.Domain;
using FluentResults;
using FluentResults.Extensions.AspNetCore;
using Microsoft.AspNetCore.Mvc;
namespace Dotnet9.WebApi.ResultPattern.Demo.FluentResults;
public class FluentResultsEndpointProfile : DefaultAspNetCoreResultEndpointProfile
{
private Func<HttpContext>? _httpContextProvider;
public void SetHttpContextProvider(Func<HttpContext> httpContextProvider)
{
_httpContextProvider = httpContextProvider;
}
public override ActionResult TransformFailedResultToActionResult(FailedResultToActionResultTransformationContext context)
{
var result = context.Result;
if (result.HasError<ValidationError>(out var validationErrors))
{
return new BadRequestObjectResult(
validationErrors.Select(e => new ErrorResponseDto(e.Message, e.ErrorCode)));
}
if (result.HasError<NotFoundError>(out var notFoundErrors))
{
var notFoundError = notFoundErrors.First();
return new NotFoundObjectResult(
new ErrorResponseDto(notFoundError.Message, notFoundError.ErrorCode));
}
if (result.HasError<ConflictError>(out var conflictErrors))
{
var conflictError = conflictErrors.First();
return new ConflictObjectResult(
new ErrorResponseDto(conflictError.Message, conflictError.ErrorCode));
}
if (result.HasError<UnauthorizedError>(out var unauthorizedErrors))
{
var unauthorizedError = unauthorizedErrors.First();
return new UnauthorizedObjectResult(
new ErrorResponseDto(unauthorizedError.Message, unauthorizedError.ErrorCode));
}
if (result.HasError<ForbiddenError>(out var forbiddenErrors))
{
var forbiddenError = forbiddenErrors.First();
return new ObjectResult(
new ErrorResponseDto(forbiddenError.Message, forbiddenError.ErrorCode))
{
StatusCode = StatusCodes.Status403Forbidden
};
}
if (result.HasError<ThrottlingError>(out var throttlingErrors))
{
var error = throttlingErrors.First();
var response = new ErrorResponseDto(error.Message, error.ErrorCode,
new Dictionary<string, object> { { "RetryAfter", error.RetryAfter } });
return new ObjectResult(response)
{
StatusCode = StatusCodes.Status429TooManyRequests
};
}
if (result.HasError<InternalServerError>(out var serverErrors))
{
var error = serverErrors.First();
return new ObjectResult(
new ErrorResponseDto(error.Message, error.ErrorCode))
{
StatusCode = StatusCodes.Status500InternalServerError
};
}
if (result.HasError<DomainError>(out var domainErrors))
{
var domainError = domainErrors.First();
return new BadRequestObjectResult(
new ErrorResponseDto(domainError.Message, domainError.ErrorCode));
}
return new ObjectResult(new ErrorResponseDto("An unexpected error occurred", "500"))
{
StatusCode = StatusCodes.Status500InternalServerError
};
}
}

Background Service (Another Application):

Now, imagine we also have a .NET 9 background service that needs to use the PenguinService. In this service, we would not use ToActionResult(). Instead, we’d directly inspect the Result object and handle errors in a way that’s appropriate for the background service.

// Hypothetical background service:
public class PenguinProcessingService
{
private readonly IPenguinService _penguinService;
public PenguinProcessingService(IPenguinService penguinService)
{
_penguinService = penguinService;
}
public async Task ProcessPenguin(Guid id)
{
var result = await _penguinService.GetPenguinByIdAsync(id);
if (result.IsSuccess)
{
// Process the penguin data
Console.WriteLine($"Processing penguin: {result.Value.Name}");
}
else
{
// Handle errors in a way appropriate for a background service.
foreach (var error in result.Errors)
{
// Log the error, retry, or take other actions. No HTTP status codes here!
Console.WriteLine($"Error processing penguin {id}: {error.Message}");
}
}
}
}

Benefits of using FluentResults

  • Improved Performance: Avoiding exceptions for routine error handling leads to faster and more predictable API response times.

  • Clearer Code: The result pattern makes the intent of the code more explicit. It’s clear from the method signature that it might fail and needs its result to be handled.

  • Centralized Error Handling: The API controller provides a centralized way to handle API error responses, promoting consistency and maintainability within the API.

  • Testability: Results are much easier to test than code that relies on exceptions

  • Decoupling Business Logic: The service layer is now independent of specific application needs (like HTTP status codes). This makes the service layer reusable across different application types (APIs, background services, desktop applications, etc.).

  • Flexibility: Different applications can handle errors in different ways, according to their specific requirements.

Conclusion

The FluentResults NuGet package provides a powerful and elegant way to implement the result pattern in .NET. By embracing this pattern, you can improve the performance, clarity, and maintainability of your code, leading to more robust and reliable applications. Crucially, it helps to decouple your business logic from specific application concerns, creating a more flexible and reusable architecture. If you’re looking for a better way to handle API error responses and promote a decoupled design in .NET 9, FluentResults is definitely worth exploring. This improved separation of concerns allows your service layer to focus on what it does best: executing business logic and accurately reflecting the outcomes, leaving the specifics of error presentation to the consuming applications.

Back to Blog

Related Posts

View All Posts »
Creating and publishing a .NET NuGet package

Creating and publishing a .NET NuGet package

Learn how to build a .NET library, pack for distribution, and seamlessly publish to your choice of public or private NuGet feeds. Learn how to leverage the power of GitHub Actions and Git tags to automate your workflow, ensuring efficient and reliable deployment of your .NET packages

Implementing Multi-tenancy in SaaS

Implementing Multi-tenancy in SaaS

Discover how to implement robust multi-tenancy in your SaaS application using .NET 8 Web API, Entity Framework Core, and PostgreSQL, ensuring secure data isolation and scalable architecture for cloud-based services

Inspiration behind Alertu

Inspiration behind Alertu

Alertu was born from firsthand experience with the challenges of cloud monitoring at major enterprises like ASOS, where the flood of email alerts and resulting alert fatigue inspired the creation of a streamlined, centralized solution for more effective cloud infrastructure management