Back to blog
Backend Systemsintermediate

Stop Throwing Exceptions for Business Logic — Use Result

Exceptions are for exceptional situations — not for 'email already taken' or 'insufficient funds'. The Result pattern makes failure an explicit part of your function signatures and kills hidden control flow.

LearnixoApril 14, 20265 min read
.NETC#Result PatternError HandlingClean ArchitectureMediatR
Share:𝕏

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

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.