Skip to main content
SEMastery

How to Replace Exceptions with the Result Pattern in .NET

Learn how to replace exceptions with the Result pattern in .NET for clearer, faster, and safer error handling. Simple guide with C# examples.

12 min readUpdated March 22, 2026

Imagine you order food on an app. Sometimes the order goes fine. Sometimes the restaurant is closed, or the dish is sold out. A good app does not crash and shut your phone down when the dish is sold out. It just shows a calm message: "Sorry, that item is finished. Pick another one."

That calm message is the idea behind the Result pattern. Instead of your code "panicking" (throwing an exception) every time something normal goes wrong, it quietly returns an answer that says either "here is your result" or "here is what went wrong." This guide will teach you how to replace exceptions with the Result pattern in .NET, step by step, in plain English.

A real-life analogy you already know

Think about a train ticket counter at a busy Indian railway station.

When you ask for a ticket, the clerk has two normal answers:

  1. "Here is your ticket." (success)
  2. "Sorry, this train is full. No seats." (a normal, expected "no")

The clerk does not ring a giant fire alarm and evacuate the station just because one train is full. A full train is normal. It happens every day. The clerk simply hands you a slip that says "WAITLISTED" and you move on.

Now compare that to a real emergency, like a fire in the building. That is when the alarm should ring and everyone should react fast.

In code:

  • A full train (expected "no") is the Result pattern.
  • A fire (something truly broken) is an exception.

Most beginners use the fire alarm (exceptions) for everything, even for a full train. That is the habit we want to fix.

Two kinds of problems: the everyday 'no' and the real emergency.

What is wrong with using exceptions for everything?

Exceptions are great, but they have a cost. Let us look at the problems honestly.

1. Hidden surprises

Look at this method:

public User GetUser(int id)
{
    var user = _db.Find(id);
    if (user is null)
    {
        throw new UserNotFoundException(id);
    }
    return user;
}

The method signature says it returns a User. It does not say "by the way, I might throw an exception." The caller has no warning. They might forget to handle it. The failure is hidden in the body.

2. They are slow when they fire

Throwing an exception is not free. The runtime has to walk back up the call stack and build a heavy object that holds the stack trace. Benchmarks from the .NET community show that when an error happens, the Result pattern can be roughly 40 times faster and use far less memory than throwing. One report measured about 18.6 KB allocated per thrown exception versus around 704 B for a Result. If your code throws on every bad input, that adds up fast and gives the garbage collector more work.

3. They are easy to misuse as control flow

Using exceptions to steer normal program flow (like "user typed a wrong email") makes code hard to read. Exceptions are meant for the exceptional, not the everyday.

TopicExceptions for everythingResult pattern
Shows up in the signature?No, it is hiddenYes, you can see it
Speed on failureSlow (stack walk)Fast (just data)
Memory on failureHeavy objectLight object
Good for unit testsHarder to set upEasy to mock
Best used forTruly broken thingsExpected business "no"

What does the Result pattern look like?

The core idea is tiny. A Result is an object that holds two things:

  • Did it work? (a bool like IsSuccess)
  • If not, what went wrong? (an Error)

Here is a small, hand-written version you can drop into any project. No library needed.

public record Error(string Code, string Message)
{
    public static readonly Error None = new(string.Empty, string.Empty);
}
 
public class Result
{
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public Error Error { get; }
 
    protected Result(bool isSuccess, Error error)
    {
        IsSuccess = isSuccess;
        Error = error;
    }
 
    public static Result Success() => new(true, Error.None);
    public static Result Failure(Error error) => new(false, error);
 
    public static Result<T> Success<T>(T value) => new(value, true, Error.None);
    public static Result<T> Failure<T>(Error error) => new(default, false, error);
}
 
public class Result<T> : Result
{
    private readonly T? _value;
 
    protected internal Result(T? value, bool isSuccess, Error error)
        : base(isSuccess, error) => _value = value;
 
    // Only let people read the value when it actually succeeded.
    public T Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("No value for a failed result.");
}

That is the whole engine. Result for actions that return nothing, and Result<T> for actions that return a value.

The shape of a Result object: a flag plus either a value or an error.

Rewriting our example with Result

Now let us fix that GetUser method. Instead of throwing, it returns a Result<User>.

public Result<User> GetUser(int id)
{
    var user = _db.Find(id);
    if (user is null)
    {
        return Result.Failure<User>(
            new Error("User.NotFound", $"No user found with id {id}."));
    }
    return Result.Success(user);
}

See the difference? The signature now tells the truth. It says: "I give you a Result<User>. It might be a success, it might be a failure. You must check." The caller cannot ignore it by accident.

Here is how a caller uses it:

var result = GetUser(42);
 
if (result.IsFailure)
{
    Console.WriteLine($"Could not get user: {result.Error.Message}");
    return;
}
 
var user = result.Value; // safe to read now
Console.WriteLine($"Hello, {user.Name}!");

The flow is calm and clear. No try/catch needed for a normal "user not found."

Calling a method that returns a Result

Call method
Get Result
Check IsSuccess
Read value or show error

Steps

1

Call method

GetUser(42)

2

Get Result

Result<User> comes back

3

Check IsSuccess

true or false?

4

Read value or show error

Branch safely

The caller always checks success before reading the value.

When to use Result and when to throw

This is the most important rule in the whole guide, so read it twice.

Use the Result pattern for expected failures. Use exceptions for unexpected failures.

An expected failure is something a normal user can cause and you already planned for:

  • The email they typed is not valid.
  • The product is out of stock.
  • The record they asked for does not exist.
  • A business rule says "you cannot withdraw more than your balance."

An unexpected failure is something that should almost never happen and means something is truly broken:

  • The database server is down.
  • The disk is full.
  • A bug caused a null reference where it should be impossible.

For those, throwing is correct. You let the exception bubble up to a global handler (like ASP.NET Core middleware) that logs it and returns a clean 500 response.

Deciding between Result and exception for any failure.

The two ideas are not enemies. A healthy .NET app uses both. Think of Result as the everyday tool and exceptions as the emergency tool.

Using Result in an ASP.NET Core API

Let us see this in a real web endpoint. Say we have a minimal API that fetches a user. The route is GET /users/{id}.

app.MapGet("/users/{id:int}", (int id, UserService service) =>
{
    Result<User> result = service.GetUser(id);
 
    return result.IsSuccess
        ? Results.Ok(result.Value)
        : Results.NotFound(new { error = result.Error.Code, message = result.Error.Message });
});

Notice how the endpoint reads the Result and turns a failure into a proper 404 Not Found with a clean JSON body. No exception was thrown, no stack trace was built, and the API stayed fast.

We can map different error codes to different HTTP status codes. This keeps your API honest and friendly.

Error codeMeaningHTTP status
User.NotFoundRecord does not exist404 Not Found
User.InvalidEmailInput failed validation400 Bad Request
User.ForbiddenNot allowed to do this403 Forbidden
Order.OutOfStockBusiness rule blocked it409 Conflict

Result to HTTP response

Service returns Result
API checks IsSuccess
Map error code
Send HTTP response

Steps

1

Service returns Result

Success or Failure

2

API checks IsSuccess

Branch on the flag

3

Map error code

NotFound to 404

4

Send HTTP response

Clean JSON body

The API layer translates an error code into the right status code.

Chaining steps without a mess of if-statements

When you have many steps, checking IsFailure after every line gets noisy. You can add small helper methods to make the code read like a smooth pipeline. These helpers are often called Map, Bind, and Match.

public static class ResultExtensions
{
    // Run the next step only if we are still on the happy path.
    public static Result<TOut> Bind<TIn, TOut>(
        this Result<TIn> result, Func<TIn, Result<TOut>> next)
    {
        return result.IsSuccess
            ? next(result.Value)
            : Result.Failure<TOut>(result.Error);
    }
 
    // Pick what to return for success vs failure in one place.
    public static TOut Match<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, TOut> onSuccess,
        Func<Error, TOut> onFailure)
    {
        return result.IsSuccess ? onSuccess(result.Value) : onFailure(result.Error);
    }
}

Now a chain of operations stays readable:

Result<string> response = service.GetUser(id)
    .Bind(user => service.LoadOrders(user))
    .Match(
        onSuccess: orders => $"Found {orders.Count} orders.",
        onFailure: error => $"Failed: {error.Message}");

If any step fails, the rest are skipped and the error travels straight to the end. This is the same idea as a relay race: the moment a runner drops the baton, the race for that team is over, and nobody keeps running.

A chain of steps short-circuits on the first failure.

Writing your own Result is fine and teaches you a lot. But for bigger apps, a tested library saves time. Here are the well-known ones in 2026.

LibraryGood forNote
FluentResultsCollecting many errors, rich metadataGreat for bulk validation and data import
ErrorOrClean, fluent, simple errorsLightweight and very popular
OneOfMany possible return types (a union)More general than just success/failure

A quick word of caution about the wider ecosystem: some libraries you may have used for messaging, like MediatR and MassTransit, moved to a commercial license in their newer versions. That is unrelated to the Result pattern itself, but worth knowing when you pick dependencies. The Result libraries above are still free and open source. Also note that .NET 10 is the current LTS release and C# 14 has shipped, while C# 15 union types (a natural fit for result-style code) are arriving in .NET 11 preview and may make this pattern even nicer in the future.

A balanced, honest view

The Result pattern is not magic, and it is fair to know its downsides too.

  • More code in the chain. Every method in the path may need to return a Result. That is more typing than just throwing.
  • A learning curve. New teammates need to learn Bind and Match.
  • Not for everything. Constructors and deep library internals often still throw.

But the wins are real: clearer signatures, faster failure paths, easier tests (mocking a Result is simple), and fewer surprise crashes. For business logic, the trade is usually worth it.

Adopting the Result pattern gradually

Pick one feature
Return Result there
Map errors at the edge
Spread to more features

Steps

1

Pick one feature

Start small

2

Return Result there

Service layer first

3

Map errors at the edge

API or UI layer

4

Spread to more features

Refactor over time

You do not need to rewrite everything at once.

A few practical tips

  • Keep your Error objects meaningful. A code like User.NotFound is far better than a plain string.
  • Do the mapping from error code to HTTP status in one place so it stays consistent.
  • Do not wrap a Result failure inside an exception and then catch it. That defeats the purpose.
  • Still use try/catch at the very top of your app to catch the truly unexpected.

Quick recap

  • The Result pattern returns "success with a value" or "failure with an error" instead of throwing.
  • It is like a railway clerk saying "train is full" calmly, instead of pulling the fire alarm.
  • Use Result for expected failures (bad input, not found, broken business rule).
  • Use exceptions for unexpected failures (database down, real bugs).
  • A Result makes the failure visible in the method signature, so callers cannot ignore it.
  • It is faster and lighter than throwing when errors happen often.
  • You can write your own Result in about 30 lines, or use FluentResults, ErrorOr, or OneOf.
  • Helpers like Bind and Match keep long chains clean and readable.
  • Map error codes to HTTP status codes in one central place for a friendly API.
  • The two approaches are partners, not rivals: everyday tool plus emergency tool.

References and further reading

Related Patterns