The Result Pattern in .NET: Error Handling Without Exceptions
Learn the Result pattern in .NET for clean, explicit error handling. Replace hidden exceptions with type-safe return values using simple examples, railway-oriented diagrams, code, and clear advice on when to use it.
Two train tracks: success and failure
Imagine a train journey with two parallel tracks. One is the green success track, and the other is the red failure track. The train starts on the green track. As long as each station goes well, it keeps rolling along the green track to its destination. But the moment something goes wrong at any station, the train switches onto the red track — and once on the red track, it skips all the remaining stations and goes straight to the end, carrying the bad news with it.
This simple picture is the heart of the Result pattern. Instead of letting errors explode as exceptions, a method returns a small package called a Result. That package says either "success, here is the value" (green track) or "failure, here is what went wrong" (red track). The caller can always see which track they are on and act accordingly.
This style is also called Railway-Oriented Programming, and it leads to code that is clearer, faster, and far easier to reason about. Let us understand why, and how to build it in .NET.
The problem with exceptions for everyday errors
Exceptions feel convenient, but they hide an important truth. Look at this method:
public User GetUser(int id)
{
var user = _db.Users.Find(id);
if (user is null)
throw new UserNotFoundException(id);
return user;
}The signature says it returns a User. But that is only half the story — it might also throw. A developer calling this has no way to know from the signature that it can fail. They have to read the implementation, or get surprised by a crash in production when they forget the try/catch.
There are three deeper problems:
- Hidden control flow. Exceptions jump out of the normal path. You cannot follow the code top-to-bottom and be sure where it goes.
- They are slow. Throwing and catching an exception is expensive. Using them for ordinary, expected failures (like "email already taken") wastes performance.
- They are easy to forget. Nothing forces the caller to handle them, so a missed
catchbecomes a runtime crash.
For expected failures — validation, "not found", "not allowed" — exceptions are the wrong tool. That is exactly where the Result pattern shines.
The idea: make failure a return value
The Result pattern says: do not throw for expected failures. Instead, return a value that describes success or failure. A simple Result type looks like this:
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public Error? Error { get; }
private Result(bool isSuccess, T? value, Error? error) =>
(IsSuccess, Value, Error) = (isSuccess, value, error);
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(Error error) => new(false, default, error);
}
public record Error(string Code, string Message);Now the method tells the whole truth in its signature:
public Result<User> GetUser(int id)
{
var user = _db.Users.Find(id);
return user is null
? Result<User>.Failure(new Error("User.NotFound", $"No user with id {id}"))
: Result<User>.Success(user);
}Anyone reading Result<User> immediately knows two things: it returns a User on success, and it can fail. No hidden surprises.
Exceptions vs the Result Pattern
Steps
Failure happens
An expected error like 'not found' occurs
Exception way
Throws — invisible in the signature, easy to miss
Result way
Returns a Result — failure is part of the type
Handle
Caller checks IsSuccess and handles each path
Consuming a Result
The caller checks which track they are on and responds:
var result = GetUser(42);
if (result.IsSuccess)
return Results.Ok(result.Value);
else
return Results.NotFound(result.Error!.Message);It is a little more typing than just calling a method and hoping, but that is the point: the failure is right there in front of you, impossible to ignore. The compiler and the code both remind you to handle it.
Railway-Oriented Programming: chaining steps
The real beauty appears when you have several steps that each can fail. Think of registering a user: validate the input, check the email is not taken, save to the database, send a welcome email. Each step can fail.
With exceptions, this becomes a tangle of nested try/catch. With Results, you chain the steps like train stations on the success track. If any step fails, the train switches to the failure track and skips the rest.
You can express this chaining with helper methods like Bind (run the next step only if the previous succeeded) and Map (transform a success value):
public Result<UserDto> Register(RegisterRequest request) =>
ValidateInput(request)
.Bind(CheckEmailNotTaken)
.Bind(SaveUser)
.Map(user => new UserDto(user.Id, user.Email));Read it top to bottom: each step runs only if the one before succeeded. If CheckEmailNotTaken fails, SaveUser and the mapping never run — the error flows straight to the end. The code reads like a clear recipe, with no nested error handling cluttering it.
You do not have to write Bind and Map yourself. The popular library CSharpFunctionalExtensions provides a battle-tested Result type with these helpers, and can even wrap exceptions for you so you can adopt the pattern gradually.
Result pattern vs exceptions: a clear comparison
| Exceptions | Result pattern | |
|---|---|---|
| Failure visible in signature? | No | Yes |
| Caller forced to consider failure? | No | Yes |
| Performance for frequent errors | Slow (throwing is costly) | Fast |
| Control flow | Hidden jumps | Straight, readable |
| Best for | Truly unexpected problems | Expected, recoverable failures |
The takeaway: they are not enemies. They cover different situations. Use the right one for the right kind of failure.
When to use each
This is the most important rule, so let us make it very clear:
| Situation | Use… |
|---|---|
| Validation failed (bad email, missing field) | Result |
| Item not found | Result |
| User not allowed to do this | Result |
| Database server is unreachable | Exception |
| A real bug / impossible state | Exception |
| Out of memory, disk full | Exception |
The simple sentence to remember: "Exceptions for the exceptional, Results for the expected." If a failure is a normal part of how your app behaves — and the caller should reasonably handle it — use a Result. If a failure means something is genuinely broken and unexpected, an exception is right.
Turning a Result into an HTTP response
In a web API, the Result pattern maps very naturally onto HTTP status codes. You can write one small helper that converts any failed Result into the correct response, based on the error code:
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess
? Results.Ok(result.Value)
: result.Error!.Code switch
{
"User.NotFound" => Results.NotFound(result.Error.Message),
"User.Invalid" => Results.BadRequest(result.Error.Message),
"User.Forbidden" => Results.Forbid(),
_ => Results.Problem(result.Error.Message),
};Now every endpoint stays clean — it returns a Result, and this single helper turns it into the right HTTP answer. No scattered try/catch, no guessing which status code to send.
The Result pattern pairs beautifully with C# 15 union types, which can model "a User or an Error" as a closed set the compiler checks. As unions mature, they make Result-style code even safer and more concise.
Adopting the Result pattern gradually
You do not have to rewrite your whole app in one weekend. The Result pattern can be adopted slice by slice, starting where it helps most.
A Gentle Path to Adopting Results
Steps
Pick one
Choose a single feature with expected failures (e.g. validation)
Return Result
Change that method to return Result<T> instead of throwing
Map once
Write one helper to turn a Result into an HTTP response
Expand
Apply the pattern to more features as the team learns it
Keep exceptions
Still throw for truly unexpected, broken situations
A common, safe starting point is your validation logic, because validation failures are the most clearly "expected" of all errors. Once your team sees how clean Result<T> makes validation, the rest of the app tends to follow naturally.
Here is how a full request reads once the pattern is in place — the endpoint stays tiny, and all the decision-making is visible:
Notice there is no try/catch anywhere in this flow. The success and failure paths are both ordinary return values, and the mapper picks the right HTTP status. The code is calm and predictable.
The honest trade-offs
The Result pattern is not free of cost. Be aware of two things:
- More typing at call sites. Every caller must check
IsSuccess. This is the price of making failure explicit — and usually a price worth paying — but it is more verbose than just calling a method. - A learning curve.
Bind,Map, and railway thinking feel unfamiliar at first, especially for developers used to exceptions. Introduce it gradually and explain the "two tracks" idea to your team.
For small scripts or quick prototypes, plain exceptions may be simpler. But for real business apps with lots of expected, recoverable failures, the clarity and safety of Results almost always win.
Quick recap
- The Result pattern returns a value describing success or failure instead of throwing exceptions for expected errors.
- It makes failure visible in the signature, is faster than throwing, and is hard to forget — unlike exceptions.
- Railway-Oriented Programming chains steps on a success track, switching to the failure track the moment any step fails.
- Use Results for expected failures (validation, not found, not allowed) and exceptions for the truly exceptional (broken infrastructure, bugs).
- Libraries like CSharpFunctionalExtensions give you a ready-made Result type, and C# 15 union types make the idea even stronger.
Picture the two train tracks. Keep your code on the green track when all goes well, switch cleanly to the red track when it does not, and your error handling becomes calm, visible, and predictable — exactly what good software needs. Start with one feature, let your team feel how much clearer it reads, and grow the pattern from there. Over time, the difference is striking: fewer surprise crashes in production, faster code on the hot paths, and methods whose signatures finally tell the truth about how they can fail.
References and further reading
- Functional Error Handling with the Result Pattern — Milan Jovanović — a popular, practical .NET guide.
- Railway Oriented Programming — F# for Fun and Profit — Scott Wlaschin's classic explanation of the two-track idea.
- CSharpFunctionalExtensions (GitHub) — a widely used library that brings the Result type to C#.
Related Patterns
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.
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.
8 Tips to Write Clean Code in C# and .NET
Learn 8 simple, beginner-friendly tips to write clean C# and .NET code with clear names, small methods, good error handling, and easy-to-read structure.
Functional Programming in C#: The Practical Parts You Will Actually Use
A warm, beginner-friendly guide to functional programming in C#: records, immutability, pattern matching, switch expressions, pure functions, and LINQ.
SOLID Principles in C# and .NET: A Beginner-Friendly Guide
Learn the 5 SOLID principles in C# and .NET with simple words, real-life examples, diagrams, and clean code you can copy and try yourself today.
How to Build a High-Performance Cache in C# Without External Libraries
Build a fast, thread-safe, size-limited LRU cache in C# using only the .NET base class library. Clear diagrams, code, and student-friendly explanations.