Skip to main content
SEMastery

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.

11 min readUpdated March 24, 2026

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.

Figure 1: With exceptions, failure is invisible in the signature. The caller cannot see it and may forget to handle it.

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 catch becomes 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

Method can fail
Exception: hidden + thrown
Result: visible + returned
Caller handles both

Steps

1

Failure happens

An expected error like 'not found' occurs

2

Exception way

Throws — invisible in the signature, easy to miss

3

Result way

Returns a Result — failure is part of the type

4

Handle

Caller checks IsSuccess and handles each path

Exceptions hide failure and jump out of the flow. A Result makes failure a visible, normal return value the caller must handle.

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.

Figure 2: Railway-Oriented Programming. Stay on the green success track while steps pass; switch to the red failure track the moment one fails.

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

ExceptionsResult pattern
Failure visible in signature?NoYes
Caller forced to consider failure?NoYes
Performance for frequent errorsSlow (throwing is costly)Fast
Control flowHidden jumpsStraight, readable
Best forTruly unexpected problemsExpected, 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:

SituationUse…
Validation failed (bad email, missing field)Result
Item not foundResult
User not allowed to do thisResult
Database server is unreachableException
A real bug / impossible stateException
Out of memory, disk fullException

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

Pick one feature
Return a Result
Add HTTP mapper
Expand outward
Keep exceptions for bugs

Steps

1

Pick one

Choose a single feature with expected failures (e.g. validation)

2

Return Result

Change that method to return Result<T> instead of throwing

3

Map once

Write one helper to turn a Result into an HTTP response

4

Expand

Apply the pattern to more features as the team learns it

5

Keep exceptions

Still throw for truly unexpected, broken situations

Start small. Convert one expected-failure spot, add a mapping helper, then spread the pattern outward as the team gets comfortable.

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:

Figure 3: A request flows through Result-returning steps, and a single mapper turns the final Result into the right HTTP status code.

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

Related Patterns