.NET & C# Development · Lesson 44 of 92

Result Pattern — Stop Using Exceptions for Flow Control

The Problem With Exceptions as Control Flow

C#
// Caller has to guess what exceptions to catch
public async Task<User> RegisterAsync(RegisterRequest request)
{
    if (await _users.ExistsAsync(request.Email))
        throw new DuplicateEmailException("Email already registered."); // business rule, not exceptional

    var user = new User(request.Email, request.Name);
    await _users.AddAsync(user);
    return user;
}

Problems:

  • The method signature lies — it says it returns User, but it can throw
  • Caller must catch DuplicateEmailException specifically, or it propagates as a 500
  • Exceptions are ~100x slower than returning a value (though that matters less than the design issue)
  • You can't see all failure modes without reading the implementation

A Simple Result Type

C#
public class Result<T>
{
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public T? Value { get; }
    public Error Error { get; }

    private Result(T value)
    {
        IsSuccess = true;
        Value     = value;
        Error     = Error.None;
    }

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

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

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

    // Factory methods keep error definitions close to the domain
    public static Error NotFound(string resource) =>
        new("NOT_FOUND", $"{resource} was not found.");

    public static Error Conflict(string message) =>
        new("CONFLICT", message);

    public static Error Validation(string message) =>
        new("VALIDATION", message);
}

Now rewrite the registration:

C#
public async Task<Result<User>> RegisterAsync(RegisterRequest request)
{
    if (await _users.ExistsAsync(request.Email))
        return Result<User>.Failure(Error.Conflict("Email is already registered."));

    var user = new User(request.Email, request.Name);
    await _users.AddAsync(user);
    return Result<User>.Success(user);
}

The failure mode is now part of the signature. The caller is forced to handle it.

Mapping Result to IActionResult

C#
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
    var result = await _userService.RegisterAsync(request);

    if (result.IsFailure)
    {
        return result.Error.Code switch
        {
            "CONFLICT"   => Conflict(new { error = result.Error.Message }),
            "VALIDATION" => BadRequest(new { error = result.Error.Message }),
            "NOT_FOUND"  => NotFound(new { error = result.Error.Message }),
            _            => StatusCode(500, new { error = "Unexpected error." })
        };
    }

    return CreatedAtAction(nameof(GetUser), new { id = result.Value!.Id }, result.Value);
}

Or add an extension to reduce the boilerplate:

C#
public static class ResultExtensions
{
    public static IActionResult ToActionResult<T>(
        this Result<T> result, ControllerBase controller, Func<T, IActionResult> onSuccess)
    {
        if (result.IsSuccess)
            return onSuccess(result.Value!);

        return result.Error.Code switch
        {
            "NOT_FOUND"  => controller.NotFound(new { error = result.Error.Message }),
            "CONFLICT"   => controller.Conflict(new { error = result.Error.Message }),
            "VALIDATION" => controller.BadRequest(new { error = result.Error.Message }),
            _            => controller.StatusCode(500, new { error = "Internal server error." })
        };
    }
}

// Controller becomes:
var result = await _userService.RegisterAsync(request);
return result.ToActionResult(this, user =>
    CreatedAtAction(nameof(GetUser), new { id = user.Id }, user));

Using the ErrorOr Library

Building and maintaining a Result type is fine, but ErrorOr (NuGet: ErrorOr) gives you a battle-tested version:

C#
// dotnet add package ErrorOr
using ErrorOr;

public async Task<ErrorOr<User>> RegisterAsync(RegisterRequest request)
{
    if (await _users.ExistsAsync(request.Email))
        return Error.Conflict(description: "Email is already registered.");

    var user = new User(request.Email, request.Name);
    await _users.AddAsync(user);
    return user; // implicit conversion from User to ErrorOr<User>
}

In the controller, Match handles both branches:

C#
var result = await _userService.RegisterAsync(request);

return result.Match(
    user    => CreatedAtAction(nameof(GetUser), new { id = user.Id }, user),
    errors  => errors.First().Type switch
    {
        ErrorType.Conflict   => Conflict(new { error = errors.First().Description }),
        ErrorType.Validation => BadRequest(new { error = errors.First().Description }),
        _                    => Problem()
    });

Combining With MediatR

The pattern fits cleanly into a CQRS pipeline:

C#
// Command
public record RegisterUserCommand(string Email, string Name) : IRequest<ErrorOr<User>>;

// Handler
public class RegisterUserHandler : IRequestHandler<RegisterUserCommand, ErrorOr<User>>
{
    private readonly IUserRepository _users;

    public RegisterUserHandler(IUserRepository users) => _users = users;

    public async Task<ErrorOr<User>> Handle(
        RegisterUserCommand cmd, CancellationToken ct)
    {
        if (await _users.ExistsAsync(cmd.Email, ct))
            return Error.Conflict(description: "Email is already registered.");

        var user = new User(cmd.Email, cmd.Name);
        await _users.AddAsync(user, ct);
        return user;
    }
}

// Controller — thin, no business logic
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
    var result = await _mediator.Send(new RegisterUserCommand(request.Email, request.Name));

    return result.Match(
        user   => CreatedAtAction(nameof(GetUser), new { id = user.Id }, user),
        errors => errors.ToActionResult(this));
}

Chaining Results

Avoid nested if (result.IsSuccess) chains with a Then method:

C#
public Result<T> Then<T>(Func<TValue, Result<T>> next) =>
    IsFailure ? Result<T>.Failure(Error) : next(Value!);

// Usage
var result = await ValidateAsync(request)
    .Then(validated => await CheckInventoryAsync(validated))
    .Then(items     => await CreateOrderAsync(items));

When to Still Use Exceptions

Results are for expected failure paths. Keep exceptions for:

  • Truly unexpected states (null from DB when ID was just validated)
  • Infrastructure failures (connection timeout, disk full)
  • Programmer errors (argument null, index out of range)

The rule: if a business analyst would say "that's a valid thing that can happen", it's a Result. If they'd say "that should never happen", throw.

Key Takeaways

  • Exceptions used for control flow hide failure modes and make call sites fragile
  • Result<T> makes success and failure explicit in the return type
  • Use Error records with codes to map failures to HTTP status codes without leaking domain detail
  • ErrorOr library is a solid drop-in if you don't want to roll your own
  • The pattern pairs naturally with MediatR command/query handlers