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.
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:
- "Here is your ticket." (success)
- "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.
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.
| Topic | Exceptions for everything | Result pattern |
|---|---|---|
| Shows up in the signature? | No, it is hidden | Yes, you can see it |
| Speed on failure | Slow (stack walk) | Fast (just data) |
| Memory on failure | Heavy object | Light object |
| Good for unit tests | Harder to set up | Easy to mock |
| Best used for | Truly broken things | Expected 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
boollikeIsSuccess) - 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.
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
Steps
Call method
GetUser(42)
Get Result
Result<User> comes back
Check IsSuccess
true or false?
Read value or show error
Branch safely
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.
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 code | Meaning | HTTP status |
|---|---|---|
User.NotFound | Record does not exist | 404 Not Found |
User.InvalidEmail | Input failed validation | 400 Bad Request |
User.Forbidden | Not allowed to do this | 403 Forbidden |
Order.OutOfStock | Business rule blocked it | 409 Conflict |
Result to HTTP response
Steps
Service returns Result
Success or Failure
API checks IsSuccess
Branch on the flag
Map error code
NotFound to 404
Send HTTP response
Clean JSON body
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.
Popular libraries (so you do not reinvent everything)
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.
| Library | Good for | Note |
|---|---|---|
| FluentResults | Collecting many errors, rich metadata | Great for bulk validation and data import |
| ErrorOr | Clean, fluent, simple errors | Lightweight and very popular |
| OneOf | Many 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
BindandMatch. - 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
Steps
Pick one feature
Start small
Return Result there
Service layer first
Map errors at the edge
API or UI layer
Spread to more features
Refactor over time
A few practical tips
- Keep your
Errorobjects meaningful. A code likeUser.NotFoundis 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
Resultfailure inside an exception and then catch it. That defeats the purpose. - Still use
try/catchat 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
Resultmakes 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
Resultin about 30 lines, or use FluentResults, ErrorOr, or OneOf. - Helpers like
BindandMatchkeep 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
- Andrew Lock — Replacing exceptions-as-control-flow with the Result pattern
- Andrew Lock — Is the Result pattern worth it?
- Milan Jovanovic — Functional Error Handling in .NET With the Result Pattern
- FluentResults on GitHub
- .NET: Exceptions vs Result Pattern — Performance Benchmark (DEV)
- Anton DevTips — How To Replace Exceptions with Result Pattern in .NET
Related Patterns
How to Write Elegant Code with C# Pattern Matching
Learn C# pattern matching the easy way. Use is, switch expressions, property and list patterns to write clean, readable, and elegant .NET code.
Mastering Exception Handling in C#: A Comprehensive Guide
Learn C# exception handling the friendly way: try, catch, finally, custom exceptions, filters, throw vs throw ex, and real best practices for .NET 10.
Why I Switched to Primary Constructors for DI in C#
A friendly guide on why primary constructors made my C# dependency injection cleaner, with simple examples, diagrams, tables, and honest trade-offs.
5 Awesome C# Refactoring Tips to Write Cleaner Code
Learn 5 simple C# refactoring tips with examples in modern C# 14. Make your code cleaner, safer, and easier to read using guard clauses and more.
Why I Write Tall LINQ Queries: Readable C# Pipelines
Learn why writing tall, one-operator-per-line LINQ queries in C# makes your code easier to read, debug, and review. Beginner friendly with diagrams.
From Anemic Models to Behavior-Driven Models: A Practical DDD Refactor in C#
Learn to refactor anemic C# classes into rich, behavior-driven domain models using DDD. A simple, step-by-step guide with diagrams and real code.