How I do error handling in my REST APIs

Vertical Slice Architeture

As I make more and more REST APIs, I find the need to bubble up an object that will translate to an Http 200 OK result or an RFC 7807 formatted error. In one of my home projects, digitalhome, I manage chores and have a Blazor WebAssembly frontend that communicates with various backend microservices over REST and GraphQL.

Even though this is a house project with a maximum of two concurrent users, I always write my home projects such that they can scale out into a high-performance, high-load environment.

This post is about how I’ve organized the results in my Vertical Slice Architecture. To this end, my results are named DhResult (digitalhome result), and the status codes are defined in an enum named DhStatus as such:

public enum DhStatus
{    
    Ok = 200,
    Created = 201,
    NoContent = 204,
    BadRequest = 400,
    Unauthorized = 401,
    Forbidden = 403,
    NotFound = 404,
    Conflict = 409,
    InternalServerError = 500
}

Right off the bat, this allows me to set the correct HTTP status code from deep within my application. Additional codes can be added as the need progresses. Next up, I have an abstract ApiError class defined as such:

public abstract record ApiError(int Code, DhStatus Status, Func<string> ErrorMessageFunc)
{
    public string ErrorMessage => ErrorMessageFunc();
}

This lets me apply an internal error code, an HTTP status, and a Func<string> that accepts the error message. The internal error code allows me to use logic upwards in a slice. The lazy execution of setting the ErrorMessage lets me defer that memory allocation to the moment that it is used. From a benchmark that I ran, this gives me a 10x speed compared to setting the string right away and a significant drop in memory allocation overall.

The result-object returned from my methods is a DhResult with a few convenience methods added for good measure. First, the non-generic version of a DhResult:

public class DhResult
{
    public ApiError? Error { get; protected set; }
    public DhStatus Status { get; protected set; }
 
    protected DhResult(DhStatus status)
    {
        Status = status;
    }
 
    protected DhResult(ApiError error)
    {
        Error = error;
        Status = error.Status;
    }
 
    public bool IsOk => Error is null;
    public bool IsFailed => Error is not null;
 
    public static DhResult FromError(ApiError error) => new (error);
    public static DhResult<TResponse> FromError<TResponse>(ApiError error) => new(error);
 
    public static DhResult Ok(DhStatus status = DhStatus.Ok) => new DhResult(status);
    public static DhResult<TValue> Ok<TValue>(TValue value, DhStatus status = DhStatus.Ok) => new(value, status);
}

Secondly, the generic version of the same, for when I’m returning a value:

public class DhResult<TValue> : DhResult
{
    public TValue? Value { get; set; }
 
    public DhResult(TValue value, DhStatus status = DhStatus.Ok) : base(status)
    {
        Value = value;
    }
 
    public DhResult(ApiError error) : base(error)
    {
    }
}

Usage

I use MediatR as the engine in all of my controllers. I know that this is debated, but it is how I roll. My controllers have a single responsibility: to take a request, fire it off as a notification, and convert the resulting DhResult<T> into something meaningful for REST. One such example is the command AssignChore which assigns a chore to a person in our household:

public sealed partial class AssignChoreHandler : CommandHandler<AssignChore, AssignedChore>
{
    private ILogger<AssignChoreHandler> _logger;
    private readonly IRepo<AssignedChore> _assignedChores;
 
    public AssignChoreHandler(ILogger<AssignChoreHandler> log, IValidator<AssignChore> validator, IRepo<AssignedChore> assignedChores) 
        : base(log, validator)
    {
        _logger = log;
        _assignedChores = assignedChores;
    }
 
    public sealed record UnableToUpsertChore(Guid ChoreId)
        : ApiError(1, DhStatus.InternalServerError, () => $"Unable to assign chore {ChoreId}: Upsert failed");
 
    public override async ValueTask<DhResult<AssignedChore>> ExecuteCommand(AssignChore command, CancellationToken cancellationToken)
    {
 
        AssignedChore assignment = new(Guid.NewGuid())
        {
            ChoreId = command.ChoreId,
            MemberId = command.MemberId,
            CreatedBy = command.AssignedBy,
            Created = DateTime.UtcNow,
            LastUpdated = DateTime.UtcNow,
            UpdatedBy = command.AssignedBy
        };
 
        var assignedChore = await _assignedChores.Upsert(assignment, cancellationToken);
        if (assignedChore is null)
        {
            return Fail(_logger.LogAndReturnApiError(new UnableToUpsertChore(command.ChoreId)));
        }
 
        return Ok(assignedChore);
    }
}

The CommandHandler base-class is not part of this blog post. It is a part of a set of wrappers around MediatR that better isolate the differences between Queries and Commands. They invoke any AbstractValidators that may exist for the given command or query. Let me know if you want to know more about this in a separate blog post.

As you can see in the AssignChoreHandler, I have an extension method to my ILogger instance, which follows the pattern of LoggerMessage. I may clean this up sometime shortly. The logger extension looks like this:

public static partial class SeriLoggerExtensions
{
    public static ApiError LogAndReturnApiError(
        this ILogger logger, 
        ApiError apiError, 
        Exception? exception = null, 
        [CallerFilePath] string callerFile = "",
        [CallerLineNumber] int lineNumber = 0)
    {
        var handlerName = Path.GetFileNameWithoutExtension(callerFile);
        var apiErrorName = apiError.GetType().Name;
        LogApiError(logger, handlerName, lineNumber, apiErrorName, apiError.Code, apiError.ErrorMessage, exception);
        return apiError;
    }
 
    [LoggerMessage(1, LogLevel.Error, "{HandlerName} line {LineNumber} failed with error '{ApiError}' (code: {Code}): {ErrorMessage}", SkipEnabledCheck = true)]
    public static partial void LogApiError(ILogger logger, string handlerName, int lineNumber, string apiError, int code, string errorMessage, Exception? exception);
}

If an ApiError is to be used elsewhere, it is declared in a shared library. I feel this gives me a strong connection between my handler classes and the API errors they can produce.

On the Controller side

My controllers are all written as partial classes and split into the feature folder, where they live with the command/query, validator, and handler for the task it represents. Thus, the file ChoresController.Assigning.cs is just this:

public sealed partial class ChoresController : HomeApiController
{
    /// <summary>
    /// Assign a chore to a member of the household
    /// </summary>
    /// <param name="choreId">Id of the chore</param>
    /// <param name="memberId">Household memberId to assign to</param>
    [HttpPost("{choreId}/assignments/{memberId}", Name = "Assign Chore")]
    [Authorize(Roles = "Owner")]
    [Produces("application/json")]
    [ProducesResponseType(typeof(AssignedChore), 200)]
    [ProducesResponseType(401)]
    [ProducesResponseType(500)]
    public async Task<IResult> AssignChore(Guid choreId, Guid memberId, CancellationToken cancellationToken)
    {
        var command = new AssignChore(choreId, memberId, User.GetId());
        var commandResult = await _mediator.Send(command, cancellationToken);
 
        return ToResult(commandResult);
    }
}

Notice how the controller extends the class HomeApiController which is a shared class where the method ToResult() does the final conversion into a RESTful result:

protected static IResult ToResult<TResponse>(DhResult<TResponse> result)
{
    return result.Status switch
    {
        DhStatus.Ok => Results.Ok(result.Value),
        DhStatus.NoContent => Results.NoContent(),
        DhStatus.NotFound => Results.NotFound(result?.Error?.ErrorMessage),
        _ => GenerateProblemResult(result)
    };
}
 
private static IResult GenerateProblemResult(DhResult result)
{
    var problemDetails = new ProblemDetails
    {
        Type = $"https://digitalhome.com/knownerrors/{result.Error!.GetType().Name}Error",
        Title = PascalCaseToSentence(result.Error.GetType().Name),
        Status = (int)result.Status,
        Detail = $"DigitalHome error {result.Error.Code}{result.Error.ErrorMessage}"
    };
 
    return Results.Problem(problemDetails);
}

A similar, non-generic ToResult() method is also there, but I omitted it to keep this post short and to the point. As you can see, I’m first performing a switch statement on all the success status codes and mapping those into the corresponding IResult from ASP.Net.

The “else” case will generate a ProblemResult in the RFC7807 format. The HTTP status code is set by the DhStatus value directly, resulting in something like this as an HTTP Response:

{
  "type": "https://digitalhome.com/knownerrors/UnableToUpsertChore",
  "title": "Unable to upsert chore",
  "status": 500,
  "detail": "DigitalHome error 1: Unable to upsert chore 9fc04b97-b70f-4dd8-8ad4-d66516df714f"
}

And on the logging side:

[08:56:17 ERR] AssignChoreCommandHandler line 39 failed with error 'UnableToUpsertChore' (code: 1): Unable to assign chore 15a313d8-72cb-44c4-8646-5c4d29ddc4af: Upsert failed

On the debugging side, this gives me all the information I need to start debugging should something go wrong.

With these constructs in place, I write code at incredible speeds. The code adheres nicely to SRP, and using a Vertical Slice Architecture keeps my code organized and clean.
My Blazor clients can handle and display errors because the format is now known in advance and lends itself well to snack bar notifications, error dialogs, and such. All ApiErrors are structurally logged on the microservices using Serilog (I’m currently outputting logs to ElasticSearch/Kibana, but that will change soon).


I focus my unit tests on the handlers I have in the solution, which are now primed for quick & easy tests, while MediatR leaves me only with verifying access control in the controller in test automation.

In case you wondered, I also have system tests, but that is for a different blog post.

Let me know what you think; drop me a comment if you agree or disagree with this approach, or ask questions if you need clarification. I’ll even take youtube video requests for my youtube channel 🙂

Cheers!

About digitaldias

Software Engineer during the day, photographer, videographer and gamer in the evening. Also a father of 3. Pedro has a strong passion for technology, and gladly shares his findings with enthusiasm.

View all posts by digitaldias →

One Comment on “How I do error handling in my REST APIs”

  1. I was just looking through my RSS feeds the other day and thought “I haven’t seen anything from Pedro in a while.” I hope you’re doing well!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.