Exceptions or results for expected failures?

ยท 1 min read

"Customer not found" isn't exceptional, but most .NET code throws for it anyway. A simple result type makes expected failures part of the method's signature.

Most .NET codebases handle every kind of failure with exceptions. A database that's down throws. So does a customer that doesn't exist, an order that's already shipped and a discount code that expired. The last three aren't exceptional at all: they're normal outcomes the business expects.

Using exceptions for them has costs:

  • The signature hides them. Order Ship(Guid orderId) doesn't tell you it can fail because the order was cancelled. You find out by reading the implementation or from a production log.
  • Control flow gets hard to follow. A throw deep in a service is caught in middleware three layers up, which maps it to an HTTP status code.
  • They're slow for hot paths. Throwing captures a stack trace. For an expected outcome that happens thousands of times a minute, that's wasted work.

A minimal result type

You don't need a library to start:

public record Error(string Code, string Message);

public record Result<T>
{
    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    private Result(T? value, Error? error) => (Value, Error) = (value, error);

    public static Result<T> Success(T value) => new(value, null);
    public static Result<T> Failure(Error error) => new(default, error);
}
public async Task<Result<Shipment>> ShipAsync(Guid orderId, CancellationToken ct)
{
    var order = await db.Orders.FindAsync([orderId], ct);
    if (order is null)
        return Result<Shipment>.Failure(new("order.not_found", "Order not found."));
    if (order.Status == OrderStatus.Cancelled)
        return Result<Shipment>.Failure(new("order.cancelled", "Cancelled orders can't be shipped."));

    var shipment = order.Ship();
    await db.SaveChangesAsync(ct);
    return Result<Shipment>.Success(shipment);
}

The return type now says "this can fail", and the caller has to look at the result to get the value.

Mapping to HTTP

At the edge, translate errors to status codes in one place:

app.MapPost("/orders/{id:guid}/ship", async (Guid id, ShippingService shipping, CancellationToken ct) =>
{
    var result = await shipping.ShipAsync(id, ct);
    if (result.IsSuccess) return Results.Ok(result.Value);

    return result.Error!.Code switch
    {
        "order.not_found" => Results.NotFound(result.Error),
        _ => Results.Conflict(result.Error)
    };
});

Keep exceptions for exceptional things

Results don't replace exceptions. A database timeout, a null reference or a misconfigured connection string should still throw: there's nothing sensible the immediate caller can do, and the global exception handler should log it and return a 500.

A useful rule: if the business would describe the outcome, return a result. If only a developer would, throw.

Libraries

If you'd rather not maintain your own type, FluentResults, ErrorOr and OneOf are popular options with helpers for chaining and matching. Pick one and use it consistently. Mixing styles is worse than either.

Takeaway

Return a result for failures the business expects, so they're visible in the method signature and handled on purpose. Keep exceptions for the truly unexpected, and translate results to HTTP status codes in one place at the edge.