.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
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
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
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