Errors Without Exceptions

Return expected failures as Result values, map them to TypedResults in one place, and keep exceptions for bugs and broken infrastructure.

SyntaxResult<OrderDto> result = orders.Place(request); return result.Match<Results<Created<OrderDto>, ProblemHttpResult>>( order => TypedResults.Created($"/orders/{order.Id}", order), error => error.ToProblem());

Two kinds of failure reach an API handler. Expected outcomes are part of the business: the product does not exist, stock is short, the coupon expired. The caller can act on them, so they belong in the method's signature. Unexpected failures are bugs and broken infrastructure: a null reference, a database timeout, a violated invariant. Nobody nearby can recover, and an exception that unwinds to UseExceptionHandler is the right tool.

Why not throw for expected outcomes

  • The signature lies. Task<Order> PlaceAsync(...) says nothing about out-of-stock, so callers learn the failure modes from production logs.
  • Cost. A throw captures a stack trace and unwinds frames. That is noise for one request and a visible CPU share when a third of checkout attempts hit out-of-stock during a sale.
  • Distance. An IExceptionHandler far from the code decides the status, and a renamed exception type quietly becomes a 500.

A small Result type

C# has no built-in discriminated union, so teams write about thirty lines: Result<T> holds either a value or an Error with a code, a message, a kind (NotFound, Conflict, Validation) and optional field errors. Implicit conversions let a method write return order; or return Error.Conflict(...). A Match method that takes both branches makes it impossible to read the value without deciding what happens on failure, which closes the main hole of a bare IsSuccess flag. Small open source libraries do the same; the type is short enough to own.

Mapping to HTTP in one place

Map the error kind to a status and a problem in one function, and give endpoints a union return type such as Results<Created<OrderDto>, ProblemHttpResult>. The domain stays free of HTTP, the mapping is tested once, and typed results keep handler unit tests and OpenAPI metadata precise.

FailureMechanismResponse
Unknown productResult: NotFound404 problem
Not enough stockResult: Conflict409 problem with its own type
Quantity out of rangeResult: Validation, or AddValidation()400 with errors
Row changed by another request on saveException caught at the repository and converted to Conflict409 problem
Database down, timeoutException500 or 503 from the exception handler
Client disconnectedOperationCanceledException499, logged at Debug

When exceptions are still right

Programming errors (ArgumentException, InvalidOperationException for broken invariants), infrastructure failures, cancellation, and anything the immediate caller cannot handle. Do not wrap those in Result.Fail(ex.Message): it drops the stack trace, hides the failure from error alerts and often leaks internal messages to clients.

Failure modes

An ignored result: unlike an exception, a discarded Result lets execution continue, so prefer Match and never return results from methods whose callers can drop them unnoticed. Results around every call, including repositories that cannot fail in an expected way, turn each method into plumbing. Use them where the business decides, at the application service boundary.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return 409 out-of-stock from OrderService.Place as a value, so a flash sale with thousands of rejected orders does not throw thousands of exceptions.
  • Treat a card decline (402 from the payment provider) as a normal checkout outcome while provider 5xx responses still throw and alert.
  • Unit test that voiding a paid invoice returns a 409 ProblemHttpResult by asserting the typed result, without an HTTP server.

More examples

Unit testing the endpoint's typed results

Because the endpoint returns a <code>Results&lt;...&gt;</code> union, a unit test can assert the exact outcome type through <code>.Result</code> without starting a server or parsing JSON. The <code>switch</code> ends with a throw for an unknown enum value: adding a new error without mapping it is a programming error, which is exactly what exceptions are for.

Example · csharp
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Xunit;

public enum StockError { UnknownProduct, OutOfStock }

public interface IStockService
{
    StockError? Reserve(string sku, int quantity);
}

public static class ReservationEndpoints
{
    public static Results<NoContent, NotFound, Conflict<string>> Reserve(string sku, int quantity, IStockService stock) =>
        stock.Reserve(sku, quantity) switch
        {
            null => TypedResults.NoContent(),
            StockError.UnknownProduct => TypedResults.NotFound(),
            StockError.OutOfStock => TypedResults.Conflict($"Not enough {sku} in stock."),
            _ => throw new InvalidOperationException("Unhandled stock error.")
        };
}

public sealed class ReservationEndpointTests
{
    private sealed class FakeStock(StockError? outcome) : IStockService
    {
        public StockError? Reserve(string sku, int quantity) => outcome;
    }

    [Fact]
    public void Out_of_stock_maps_to_409()
    {
        var result = ReservationEndpoints.Reserve("KB-104", 5, new FakeStock(StockError.OutOfStock));

        var conflict = Assert.IsType<Conflict<string>>(result.Result);
        Assert.Equal(StatusCodes.Status409Conflict, conflict.StatusCode);
    }

    [Fact]
    public void Success_maps_to_204()
    {
        var result = ReservationEndpoints.Reserve("KB-104", 1, new FakeStock(null));

        Assert.IsType<NoContent>(result.Result);
    }
}

Converting an infrastructure exception at the boundary

The boundary decides which failures are expected. A 402 decline happens every day and the caller must branch on it, so it becomes a value. A 503 from the provider or a timeout cannot be handled by the checkout code, so it keeps throwing, gets retried by the HTTP resilience handler and, if still failing, is logged once as an error. Retrying a POST charge is safe only because the <code>Idempotency-Key</code> tells the provider it is the same attempt; without one, disable retries for this call and reconcile an ambiguous timeout with the provider instead. A closed record hierarchy is a light alternative to a generic <code>Result&lt;T&gt;</code>.

Example · csharp
using System.Net;
using System.Net.Http.Json;

public sealed record ChargeRequest(string PaymentAttemptId, string CustomerId, decimal Amount, string Currency);
public sealed record ChargeResponse(string ChargeId);

public abstract record PaymentOutcome
{
    public sealed record Approved(string ChargeId) : PaymentOutcome;
    public sealed record Declined(string Reason) : PaymentOutcome;
}

public sealed class PaymentGateway(HttpClient http)
{
    public async Task<PaymentOutcome> ChargeAsync(ChargeRequest request, CancellationToken cancellationToken)
    {
        using var message = new HttpRequestMessage(HttpMethod.Post, "/v1/charges")
        {
            Content = JsonContent.Create(request)
        };
        // Same key on every retry of this attempt, so the provider charges the card at most once.
        message.Headers.Add("Idempotency-Key", request.PaymentAttemptId);
        using var response = await http.SendAsync(message, cancellationToken);

        // A decline is a business answer the checkout flow must handle: return it.
        if (response.StatusCode == HttpStatusCode.PaymentRequired)
        {
            return new PaymentOutcome.Declined("Card declined by issuer.");
        }

        // 5xx, timeouts and DNS failures are not: let them throw to the resilience pipeline and exception handler.
        response.EnsureSuccessStatusCode();
        var body = await response.Content.ReadFromJsonAsync<ChargeResponse>(cancellationToken)
            ?? throw new InvalidOperationException("Payment provider returned an empty body.");
        return new PaymentOutcome.Approved(body.ChargeId);
    }
}

Problem results with precise OpenAPI metadata

<code>ProblemHttpResult</code> writes through <code>IProblemDetailsService</code>, so every error keeps <code>application/problem+json</code>, the <code>traceId</code> and the <code>CustomizeProblemDetails</code> members. It cannot say which statuses occur, so <code>.ProducesProblem(404)</code> and <code>.ProducesProblem(409)</code> put them in the OpenAPI document. Typed members such as <code>NotFound&lt;ProblemDetails&gt;</code> document themselves but serialise as plain JSON without <code>traceId</code>, which breaks the one-shape contract. The store is a <code>ConcurrentDictionary</code> with <code>TryRemove</code> because requests run concurrently.

Example · csharp
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();

var invoices = new ConcurrentDictionary<string, Invoice>
{
    ["INV-2026-0042"] = new("INV-2026-0042", 1250.00m, Paid: false)
};

app.MapPost("/invoices/{number}/void",
    Results<Ok<Invoice>, ProblemHttpResult> (string number) =>
    {
        if (!invoices.TryGetValue(number, out var invoice))
        {
            return TypedResults.Problem(title: "Invoice not found", statusCode: StatusCodes.Status404NotFound);
        }
        if (invoice.Paid)
        {
            return TypedResults.Problem(
                type: "https://errors.example.com/invoice-paid",
                title: "A paid invoice cannot be voided",
                statusCode: StatusCodes.Status409Conflict);
        }
        return invoices.TryRemove(number, out var voided)
            ? TypedResults.Ok(voided)
            : TypedResults.Problem(title: "Invoice not found", statusCode: StatusCodes.Status404NotFound);
    })
    .ProducesProblem(StatusCodes.Status404NotFound)
    .ProducesProblem(StatusCodes.Status409Conflict);

app.Run();

public record Invoice(string Number, decimal Amount, bool Paid);

Discussion

  • Be the first to comment on this lesson.