Background
As a developer, whether you intend to or not, you leave an impact on others. That impact-positive or negative-often stems from the code you write, the actions you take, or the words you say. In this case, I was asked why I used the Result Pattern in a recent project, especially given the relatively minimal use of exception handling. This was contrasted with another codebase heavily reliant on exceptions to control application flow.
While many decisions in software development are subjective, I chose to address this topic because it highlights one critical, objective principle. So let’s dive in.
Design objectivity
In C#, specifically (though this may differ in other languages), exceptions are intended to handle unexpected behaviour-not to manage regular application control flow. If you'd prefer to hear it straight from Microsoft, you can read their definition and their best practices. By design, we should avoid using exceptions for normal program logic and reserve them for genuinely unforeseen situations or cases where a meaningful recovery action is necessary.
(Apparently i've not implemented embedding links, so errr enjoy raw links)
https://learn.microsoft.com/en-us/dotnet/standard/exceptions/
https://learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions
Why the result pattern?
Let’s start with what the Result Pattern is, rather than why it matters.
The Result Pattern is a design approach that allows operations to be handled in a fully deterministic way. Typically, it represents outcomes explicitly as either a success or a failure, without relying on exceptions being thrown outside the method that implements it. This results in a pure function-one with no side effects-and offers a clear, structured way to represent the outcome of an operation. It enables developers to consistently handle both success and error scenarios.
The key takeaway is that this pattern eliminates side-effect-driven logic. An operation either succeeds or fails-there’s no fallback on exception handling as a control mechanism. This contrasts with many traditional applications where the pattern is “succeed or throw,” meaning exceptions can bubble up from any downstream dependency, leading to ambiguous and scattered error handling.
Ultimately, it’s not about the Result Pattern itself-it’s about avoiding the misuse of exceptions to manage normal control flow. You’re free to use any approach that enforces this principle.
The objective take
You could still argue that all of this is highly opinionated, as with many debates you could file this under philosophical, so lets talk facts. Exceptions are objectively slow.
I used benchmark dotnet to crunch some numbers.
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("");
}
Result Pattern Example
public class Result<T>
{
public T Value { get; }
public string? Error { get; }
public bool IsSuccess => Error == null;
private Result(T value, string error)
{
Value = value;
Error = error;
}
public static Result<T> Success(T value) => new Result<T>(value, null);
public static Result<T> Failure(string error) => new Result<T>(default, error);
}
Test Classes
public class CustomerRepository
{
public Customer? AddCustomerWithException(string name)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException("Name cannot be null or empty", nameof(name));
}
try
{
// Futher processing
return new Customer { Name = name };
} catch (Exception e)
{
//Maybe even a throw instead of null return?
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);
}
}
}
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
Using exceptions for regular control flow is approximately 950% slower than avoiding them. You might not care in smaller applications, but at enterprise scale, that difference compounds with every request.
The objective conclusion
Throwing an exception unwinds the stack and allocates an object for a thing that wasn't unexpected at all. It costs you 950% on the benchmark, it scatters try-catch blocks through code that has no business catching anything, and it lets an error get swallowed three frames up from where it happened. Reserve exceptions for what their name promises. Everything else, a failure that you already knew was possible, deserves a return value that says so.