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.
The Problem With Exceptions as Control Flow
// 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
DuplicateEmailExceptionspecifically, 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
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:
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
[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:
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:
// 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:
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:
// 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:
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
Errorrecords with codes to map failures to HTTP status codes without leaking domain detail ErrorOrlibrary 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.