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.