Dev Notes

Minimal APIs with the Result Pattern in .NET 8

Throwing exceptions for control flow is expensive and makes code hard to reason about. The Result pattern is a lightweight alternative that makes success and failure explicit in the type system.

A simple Result type

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(T value) { IsSuccess = true; Value = value; }
    private Result(string error) { IsSuccess = false; Error = error; }

    public static Result<T> Ok(T value) => new(value);
    public static Result<T> Fail(string error) => new(error);
}

Using it in a service

public class OrderService(AppDbContext db)
{
    public async Task<Result<Order>> GetOrderAsync(int id)
    {
        var order = await db.Orders.FindAsync(id);
        if (order is null)
            return Result<Order>.Fail($"Order {id} not found.");

        return Result<Order>.Ok(order);
    }
}

No exceptions for the “not found” case — that’s a business outcome, not an exceptional condition.

Wiring it to a Minimal API

app.MapGet("/orders/{id:int}", async (int id, OrderService svc) =>
{
    var result = await svc.GetOrderAsync(id);

    return result.IsSuccess
        ? Results.Ok(result.Value)
        : Results.NotFound(result.Error);
});

Or with a cleaner extension method:

public static IResult ToHttpResult<T>(this Result<T> result) =>
    result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);

// Usage:
app.MapGet("/orders/{id:int}", async (int id, OrderService svc) =>
    (await svc.GetOrderAsync(id)).ToHttpResult());

Why not throw?

  • Exceptions break the “happy path” reading of code.
  • They’re ~100x slower than returning a value.
  • Result types make the caller explicitly handle both cases — no silent swallowing.

For genuinely exceptional conditions (DB connection lost, OOM) — those should still throw. The Result pattern is for expected failure modes.


Comments