The Result Pattern for Error Handling in .NET

Exceptions should be exceptional. Using them for expected business failures — validation errors, not-found conditions, insufficient funds — is both a performance cost and a readability problem. The caller has no idea what might be thrown without reading the implementation. The Result pattern makes error handling explicit, composable, and visible in the type system.

The Problem with Exceptions

Example.cs
public async Task<Order> GetOrderAsync(Guid id)
{
    var order = await _db.Orders.FindAsync(id);
    if (order is null)
        throw new NotFoundException($"Order {id} not found");

    if (order.CustomerId != _currentUser.Id)
        throw new ForbiddenException("You don't own this order");

    return order;
}

The caller has to guess what exceptions might fly out. The method signature says it returns an Order, but it might throw two different exception types. Nothing in the type system warns you.

A Simple Result Type

Result.cs
public class Result<T>
{
    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess => Error is null;
    public bool IsFailure => !IsSuccess;

    private Result(T value)
    {
        Value = value;
        Error = null;
    }

    private Result(Error error)
    {
        Value = default;
        Error = error;
    }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Failure(Error error) => new(error);

    public static implicit operator Result<T>(T value) => Success(value);
    public static implicit operator Result<T>(Error error) => Failure(error);
}

public record Error(string Code, string Message)
{
    public static readonly Error None = new(string.Empty, string.Empty);

    public static Error NotFound(string message) => new("NotFound", message);
    public static Error Validation(string message) => new("Validation", message);
    public static Error Forbidden(string message) => new("Forbidden", message);
    public static Error Conflict(string message) => new("Conflict", message);
}

Using Results in Services

Now the method signature tells you everything:

Example.cs
public async Task<Result<Order>> GetOrderAsync(Guid id)
{
    var order = await _db.Orders.FindAsync(id);

    if (order is null)
        return Error.NotFound($"Order {id} not found");

    if (order.CustomerId != _currentUser.Id)
        return Error.Forbidden("You don't own this order");

    return order;
}

The implicit conversions make this clean. The return type honestly communicates: this might succeed with an Order, or it might fail with an Error.

A Result Without a Value

For operations that don't return data:

Result.cs
public class Result
{
    public Error? Error { get; }
    public bool IsSuccess => Error is null;
    public bool IsFailure => !IsSuccess;

    private Result(Error? error = null) => Error = error;

    public static Result Success() => new();
    public static Result Failure(Error error) => new(error);

    public static implicit operator Result(Error error) => Failure(error);
}
Example.cs
public async Task<Result> CancelOrderAsync(Guid orderId)
{
    var order = await _db.Orders.FindAsync(orderId);

    if (order is null)
        return Error.NotFound($"Order {orderId} not found");

    if (order.Status == OrderStatus.Shipped)
        return Error.Validation("Cannot cancel a shipped order");

    order.Cancel();
    await _db.SaveChangesAsync();

    return Result.Success();
}

Mapping Results to HTTP Responses

In your API layer, map error codes to HTTP status codes:

ResultExtensions.cs
public static class ResultExtensions
{
    public static IResult ToHttpResult<T>(this Result<T> result) =>
        result.IsSuccess
            ? Results.Ok(result.Value)
            : ToErrorResult(result.Error!);

    public static IResult ToHttpResult(this Result result) =>
        result.IsSuccess
            ? Results.NoContent()
            : ToErrorResult(result.Error!);

    private static IResult ToErrorResult(Error error) => error.Code switch
    {
        "NotFound" => Results.NotFound(new { error = error.Message }),
        "Validation" => Results.BadRequest(new { error = error.Message }),
        "Forbidden" => Results.Forbid(),
        "Conflict" => Results.Conflict(new { error = error.Message }),
        _ => Results.Problem(error.Message)
    };
}

Usage in endpoints:

Example.cs
app.MapGet("/api/orders/{id:guid}", async (Guid id, OrderService service) =>
{
    var result = await service.GetOrderAsync(id);
    return result.ToHttpResult();
});

app.MapPost("/api/orders/{id:guid}/cancel", async (Guid id, OrderService service) =>
{
    var result = await service.CancelOrderAsync(id);
    return result.ToHttpResult();
});

Composing Results

Add a Map method for transforming successful results:

Example.cs
public Result<TOut> Map<TOut>(Func<T, TOut> transform) =>
    IsSuccess
        ? Result<TOut>.Success(transform(Value!))
        : Result<TOut>.Failure(Error!);

public async Task<Result<TOut>> MapAsync<TOut>(Func<T, Task<TOut>> transform) =>
    IsSuccess
        ? Result<TOut>.Success(await transform(Value!))
        : Result<TOut>.Failure(Error!);
Example.cs
var result = await service.GetOrderAsync(orderId);
var dtoResult = result.Map(order => new OrderDto(order.Id, order.Total));

Collecting Validation Errors

For operations with multiple possible validation failures, extend the pattern:

Example.cs
public record ValidationError : Error
{
    public IReadOnlyList<string> Details { get; }

    public ValidationError(IReadOnlyList<string> details)
        : base("Validation", "One or more validation errors occurred")
    {
        Details = details;
    }
}

Using Existing Libraries

You don't have to build this yourself. Libraries like FluentResults and ErrorOr provide battle-tested implementations:

Example.cs
// Using ErrorOr
public async Task<ErrorOr<Order>> GetOrderAsync(Guid id)
{
    var order = await _db.Orders.FindAsync(id);
    if (order is null)
        return Errors.Order.NotFound(id);

    return order;
}

When to Use Results

Use the Result pattern in your domain and application layers for expected failures — validation errors, business rule violations, not-found conditions. Keep exceptions for truly unexpected situations: network failures, corrupted data, programming errors.

The Result pattern makes your code honest. The return type tells callers exactly what to expect, the compiler ensures they handle both paths, and you never have to wonder "what exceptions does this method throw?"