Error handling is one of those design choices that quietly shapes an
entire codebase.
In .NET, we usually reach for exceptions by default. It's what the
framework teaches, what most samples show, and what feels "natural" at
first.
But as systems grow, many teams start to feel friction: - Too many
try/catch blocks - Unclear method contracts - Hard-to-follow control
flow - Performance issues under load - Business logic hidden behind
thrown exceptions
This is where the Result pattern enters the picture.
Both approaches are valid. The key is understanding what problem each
one is meant to solve.
The Familiar Way: Exception Flow
This is classic .NET:
public User GetUser(Guid id)
{
var user = _repo.Find(id);
if (user == null)
throw new UserNotFoundException(id);
return user;
}
Caller:
try
{
var user = service.GetUser(id);
}
catch (UserNotFoundException)
{
// handle it
}
There's nothing "wrong" with this. Exceptions are deeply built into the
runtime and the language.
But there's an assumption baked into this style:
Something unusual or truly unexpected happened.
The Alternative: The Result Pattern
The Result pattern treats failures as normal outcomes, not
interruptions.
public Result<User> GetUser(Guid id)
{
var user = _repo.Find(id);
if (user == null)
return Result.Fail<User>("User not found");
return Result.Ok(user);
}
Caller:
var result = service.GetUser(id);
if (result.IsFailure)
{
// handle it
return;
}
var user = result.Value;
Now the method is honest about what can happen. No surprises.
The Real Difference
This is the mental model that clears up most confusion:
Exceptions Results
For things that shouldn't For things that **happen as part of
normally happen the domain**
Break the flow Keep the flow linear
Implicit contract Explicit contract
Expensive when thrown Cheap to return
Hard to compose Easy to compose
A Rule of Thumb That Helps
Use exceptions for system failures.\
Use results for business outcomes.
Once you start thinking this way, most design decisions become obvious.
When Exceptions Make Perfect Sense
Use exceptions when the caller cannot realistically recover:
- Database connection dropped
- File system unavailable
- Network timeout
- Serialization failure
- A library throws
- A programmer broke an invariant
Example:
public async Task<string> ReadConfigAsync()
{
return await File.ReadAllTextAsync("config.json");
}
You shouldn't wrap this in a Result. If this fails, something is wrong
with the environment, not the business logic.
When Results Are the Better Choice
Use Results when the caller is expected to handle the situation:
- Validation failures
- Entity not found
- Business rule violations
- Authorization problems
- Duplicate records
- Insufficient balance
These are not exceptional. They are part of how the system works.
public Result Withdraw(Money amount)
{
if (Balance < amount)
return Result.Fail("Insufficient funds");
Balance -= amount;
return Result.Ok();
}
Throwing here would be misleading. Nothing "broke".
Why Exceptions Get Painful in Business Logic
As a codebase grows, exception-driven business flow starts to hurt:
- The method signature lies --- it doesn't show what can happen
- You end up wrapping everything in
try/catch - Performance suffers when exceptions are used frequently
- Composing operations becomes awkward
- Async and LINQ flows become harder to reason about
Compare this:
var user = GetUser(id);
var account = GetAccount(user);
var result = Process(account);
You don't know where it might blow up.
With Results:
return GetUser(id)
.Bind(GetAccount)
.Bind(Process);
The flow is obvious and safe.
A Simple Result Implementation
You don't need a big library. A small type is enough:
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public string Error { get; }
protected Result(bool isSuccess, string error)
{
IsSuccess = isSuccess;
Error = error;
}
public static Result Ok() => new(true, string.Empty);
public static Result Fail(string error) => new(false, error);
}
public class Result<T> : Result
{
public T Value { get; }
protected Result(T value, bool isSuccess, string error)
: base(isSuccess, error)
{
Value = value;
}
public static Result<T> Ok(T value) => new(value, true, string.Empty);
public static new Result<T> Fail(string error) => new(default!, false, error);
}
And a helper for composition:
public static Result<K> Bind<T, K>(this Result<T> result, Func<T, Result<K>> func)
{
if (result.IsFailure)
return Result<K>.Fail(result.Error);
return func(result.Value);
}
How Mature .NET Systems Use Both
It's not either/or.
A healthy architecture looks like this:
Controllers → Results
Application Layer → Results
Domain Layer → Results
Infrastructure → Exceptions
Exceptions stay at the edges. Results live where business decisions
happen.
Example in an ASP.NET Core Controller
[HttpGet("{id}")]
public IActionResult Get(Guid id)
{
var result = _service.GetUser(id);
if (result.IsFailure)
return NotFound(result.Error);
return Ok(result.Value);
}
No try/catch. Clear intent.
A Note on Performance
Exceptions are expensive when thrown. In high-traffic APIs, using them
for normal control flow can become a real bottleneck.
Results avoid this entirely.
The Practical Guideline
If the caller is expected to handle the outcome → return a
Result.
If the caller cannot reasonably recover → throw an Exception.
That's it.
Closing Thought
This isn't about being clever or following a trend. It's about making
your code:
- Easier to read
- Easier to reason about
- More explicit
- More composable
- More predictable under load
Once you start separating system failures from business outcomes,
your APIs become much clearer --- both for you and for everyone else who
reads your code.
Top comments (0)