DEV Community

Jairo Blanco
Jairo Blanco

Posted on

Result Pattern vs Exception Flow in .NET

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;
}
Enter fullscreen mode Exit fullscreen mode

Caller:

try
{
    var user = service.GetUser(id);
}
catch (UserNotFoundException)
{
    // handle it
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Caller:

var result = service.GetUser(id);

if (result.IsFailure)
{
    // handle it
    return;
}

var user = result.Value;
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The method signature lies --- it doesn't show what can happen
  2. You end up wrapping everything in try/catch
  3. Performance suffers when exceptions are used frequently
  4. Composing operations becomes awkward
  5. Async and LINQ flows become harder to reason about

Compare this:

var user = GetUser(id);
var account = GetAccount(user);
var result = Process(account);
Enter fullscreen mode Exit fullscreen mode

You don't know where it might blow up.

With Results:

return GetUser(id)
    .Bind(GetAccount)
    .Bind(Process);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)