Background
I was asked in a review why I'd used the Result pattern on a project, and why there was so little exception handling in it. The question came from someone who'd spent the last two years in a codebase where exceptions were the control flow, and it's a fair question. Most of what we argue about is taste. This one isn't.
Design objectivity
In C#, exceptions are for unexpected behaviour, not for regular control flow. That isn't my opinion, it's what the framework documentation says [1][2]. Reserve them for genuinely unforeseen situations, or for cases where there's a meaningful recovery to make.
A user submitting an empty name is not unforeseen. You wrote the validation, so you foresaw it.
Why the result pattern?
Start with what it is rather than why it matters.
The Result pattern makes the outcome of an operation part of the return type. Success or failure, in the signature, visible at the call site. Nothing gets thrown out of the method to be caught by somebody three frames up who has no idea what to do about it.
The key part is that the caller can't ignore the failure by accident. An operation either succeeds or fails, and the compiler puts the failure in front of you. Compare that to "succeed or throw", where the failure travels up through code that never asked to deal with it and gets caught by whichever try-catch happens to be in the way.
It isn't really about the Result pattern. Use anything that keeps expected failures in the return type.
The objective take
You could argue that everything above is philosophy. So here are some numbers. Exceptions are objectively slow.
I used BenchmarkDotNet to measure the difference.
Test setup
public class Program
{
public static void Main(string[] args)
{
BenchmarkRunner.Run<ExceptionsVsResultPattern>();
}
}
[SimpleJob(RuntimeMoniker.HostProcess)]
public class ExceptionsVsResultPattern
{
private readonly CustomerRepository _customerRepository = new CustomerRepository();
[Benchmark]
public void Exceptions() => _ = _customerRepository.AddCustomerWithException("");
[Benchmark]
public void ResultPattern() => _ = _customerRepository.AddCustomerWithResultPattern("");
}
The result type
public class Result<T>
{
public T? Value { get; }
public string? Error { get; }
public bool IsSuccess => Error is null;
private Result(T? value, string? error)
{
Value = value;
Error = error;
}
public static Result<T> Success(T value) => new(value, null);
public static Result<T> Failure(string error) => new(default, error);
}
The two implementations
Both methods handle the same expected failure, an empty name, and both return to the caller rather than letting anything escape. That's the fair comparison, because a thrown exception has to be caught somewhere and the catch is part of the cost.
public class CustomerRepository
{
public Customer? AddCustomerWithException(string name)
{
try
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException("Name cannot be null or empty", nameof(name));
}
// Further processing
return new Customer { Name = name };
}
catch (ArgumentException)
{
return null;
}
}
public Result<Customer> AddCustomerWithResultPattern(string name)
{
try
{
if (string.IsNullOrEmpty(name))
{
return Result<Customer>.Failure("Name cannot be null or empty");
}
// Further processing
return Result<Customer>.Success(new Customer { Name = name });
}
catch (Exception ex)
{
// Handle your unexpected things here and keep it encapsulated.
return Result<Customer>.Failure(ex.Message);
}
}
}
Look at what the first one returns. null, for a failure you already had a message for. The caller now gets to guess whether that means "invalid name", "already exists" or "the database is down".
The results
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3915)
AMD Ryzen 9 7900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK 9.0.102
[Host] : .NET 8.0.13 (8.0.1325.6609), X64 RyuJIT AVX-512F+CD+BW+DQ+VL
| Method | Mean | Error | StdDev | |-------------- |-------------:|-----------:|-----------:| | Exceptions | 2,818.817 ns | 21.2729 ns | 17.7638 ns | | ResultPattern | 2.968 ns | 0.0760 ns | 0.0747 ns |
Analysis
Roughly 950 times slower. Not 950 percent, 950 times, and the gap is the stack walk and the allocation, so it gets worse the deeper the stack is when you throw.
Three microseconds sounds like nothing until it's on the validation path of every request. You might not care in a small application. At enterprise scale it stacks up with every request that hits a rule you already knew about.
The objective conclusion
Throwing unwinds the stack and allocates an object to report a thing that wasn't unexpected at all. It costs you three orders of magnitude on the benchmark, it scatters try-catch through code that has no business catching anything, and it lets an error get swallowed three frames from where it happened. Reserve exceptions for what their name promises. A failure you already knew was possible deserves a return value that says so.
Sources
- Handling and throwing exceptions in .NET
- Best practices for exceptions