Let’s say you are building an API, and a client asks for an order that doesn’t exist. What do you do? In a lot of .NET codebases, the answer is to throw an OrderNotFoundException and let some middleware turn it into a 404. It works, but haven’t we all ended up in a codebase where you can’t tell which method throws what, without opening every single one of them?
The Result pattern is one way to fix this. Instead of throwing the failure, the method returns it. The method returns a Result<T>, which is either a success that carries a value, or a failure that carries an error. This way, the failure is visible right in the method signature, instead of hiding behind a throw somewhere. You use it for expected failures like “order not found”, and you keep exceptions for things that are genuinely unexpected.
The idea itself is simple, and it takes only about fifty lines of code. The harder question is whether you should use it at all, and most articles skip that part completely. So in this article, I will walk you through it step by step. I will build a Result<T> for a .NET 10 Minimal API, wire it to async EF Core services, map it to RFC 9457 ProblemDetails, benchmark it against throwing exceptions, write tests for it, and then go through the six places where it can cause you trouble. All the code in this article runs, and you can grab the full source code from GitHub.
If you are in a hurry, here is the short answer. Adopt the Result pattern because it makes your method signatures honest about failure, and not because it makes your code faster. Performance is the reason most people give for using this pattern, but it’s actually the weakest one, and on .NET 10 it’s even weaker than it was two years ago. I measured it, and at typical API failure rates you won’t notice any difference at all. It only starts to matter when failures happen inside a tight loop.
I tested everything in this article on .NET SDK 10.0.303 with runtime 10.0.11. Let’s get into it.
What is the Result pattern in C#?
Here is a simple way to think about it. Imagine you order a dish at a restaurant, and the kitchen has run out of it. The waiter comes back to your table and tells you so. Nobody pulls the fire alarm. Running out of a dish is a normal thing that happens in a restaurant, and it gets handled like one.
The Result pattern treats expected failures the same way. Instead of a method that returns Order and might throw an OrderNotFoundException, you write a method that returns Result<Order>. Now the return type itself tells you that the method can fail.
Let’s compare the two signatures:
// The signature lies. Nothing here says this can fail.Order GetById(Guid id);
// The signature is honest. Failure is part of the return type.Result<Order> GetById(Guid id);The first version tells the caller nothing about failure. The failure shows up at runtime, from a throw that could be several files away, and the only way to know about it is to read the implementation. The second version puts the failure right in the type, so you see it in IntelliSense, during code review, and at every call site that unwraps the value.
But let’s be clear about what this gives you, because this is where a lot of articles promise too much. The compiler does not force the caller to handle the failure. result.Value compiles whether or not anyone checked IsSuccess. What you gain is visibility, and nothing actually forces anyone to do the check. That is the weakest point of this pattern, and I’ll come back to it later in the article.
Why is using exceptions for control flow a problem?
Before building anything, let’s look at the problem we are trying to solve. When you throw an exception for an outcome you already expect, you are hiding a branch in your code. Here is a service that uses exceptions the way a lot of production code does:
public Order GetById(Guid id){ var order = _repository.Find(id);
if (order is null) { throw new OrderNotFoundException($"No order with the id {id}."); }
return order;}If you think about it, a missing order is one of the two normal answers to “fetch me this order”. It’s not an exceptional situation at all. When you throw for it, three things go wrong:
- The signature no longer describes what the method does.
- Anyone reading the caller can’t see the failure path.
- The handling ends up in middleware, far away from the code that knows what a missing order actually means.
Note that the Result pattern only reduces the exceptions that you throw yourself. It doesn’t do anything about the ones that EF Core and HttpClient throw at you, so you still need a global exception handler in your application.
Global Exception Handling in ASP.NET Core
IExceptionHandler, middleware ordering, and a centralized error pipeline for .NET 10. You still need this alongside the Result pattern.
Throwing for routine branches is also one of the common .NET API anti-patterns that you should design away.
How do you build a Result type in .NET 10?
Now that we understand why we need this, let’s build it. I’ll start with the error type, and then build the result on top of it. The error is the part that most implementations get wrong. They use a plain string for the error, which means the endpoint has no way to pick the right status code without parsing English text.
Create a new file for the error type, and add the following:
public enum ErrorType{ Failure = 0, Validation = 1, NotFound = 2, Conflict = 3, Forbidden = 4}
public readonly record struct Error(string Code, string Description, ErrorType Type){ public static readonly Error None = new(string.Empty, string.Empty, ErrorType.Failure);
public static Error Validation(string code, string description) => new(code, description, ErrorType.Validation); public static Error NotFound(string code, string description) => new(code, description, ErrorType.NotFound); public static Error Conflict(string code, string description) => new(code, description, ErrorType.Conflict); public static Error Forbidden(string code, string description) => new(code, description, ErrorType.Forbidden);}These three fields do a lot of work for you:
Codeis a stable, machine-readable identifier that clients can branch on. It doesn’t change when someone rewords a message.Descriptionis the human-readable message.Typeis what the HTTP layer reads to pick the right status code. This keeps all knowledge about status codes out of your domain code.
Next, let’s build the Result<T> itself. I am using a readonly record struct here, so that it doesn’t allocate on the heap:
public readonly record struct Result<TValue>{ private readonly TValue? _value;
private Result(TValue? value, bool isSuccess, Error error) { _value = value; IsSuccess = isSuccess; Error = error; }
public bool IsSuccess { get; } public bool IsFailure => !IsSuccess; public Error Error { get; }
public TValue Value => IsSuccess ? _value! : throw new InvalidOperationException("Cannot read the value of a failed result.");
public static Result<TValue> Success(TValue value) => new(value, true, Error.None); public static Result<TValue> Failure(Error error) => new(default, false, error);
public static implicit operator Result<TValue>(TValue value) => Success(value); public static implicit operator Result<TValue>(Error error) => Failure(error);
public TOut Match<TOut>(Func<TValue, TOut> onSuccess, Func<Error, TOut> onFailure) => IsSuccess ? onSuccess(_value!) : onFailure(Error);}There are two details in this code that are worth pausing on.
First, the implicit conversions save you from a lot of boilerplate. Without them, every single return would look like Result<Order>.Success(order). With them, you can simply write return order; or return OrderErrors.NotFound(id);, and the compiler wraps it for you. Quite handy, yeah?
Second, the Value property throws if you read it on a failed result. You might be wondering why I am throwing an exception in an article about avoiding exceptions. It’s deliberate. Reading the value of a failed result is a bug in your code, and not an expected outcome, so an exception is exactly the right thing here. In fact, the whole pattern is built on this one rule: expected failures are returned, and bugs are thrown.
You need one more type. Plenty of operations succeed without returning anything, like cancelling an order, deleting a record, or sending an email. For those, add a non-generic Result:
public readonly record struct Result{ private Result(bool isSuccess, Error error) { IsSuccess = isSuccess; Error = error; }
public bool IsSuccess { get; } public bool IsFailure => !IsSuccess; public Error Error { get; }
public static Result Success() => new(true, Error.None); public static Result Failure(Error error) => new(false, error);}And that’s the whole implementation! It’s just two structs and an Error record, about fifty lines in total, and you don’t need anything else to start using the pattern.
Collect your errors in one place
If you write Error.NotFound("Orders.NotFound", "...") calls all over your codebase, the same failure will soon end up with four slightly different messages. To avoid this, I keep all the errors of a feature in one static class:
public static class OrderErrors{ public static Error NotFound(Guid id) => Error.NotFound("Orders.NotFound", $"No order was found with the id {id}.");
public static readonly Error EmailRequired = Error.Validation("Orders.EmailRequired", "A customer email is required.");
public static readonly Error TotalMustBePositive = Error.Validation("Orders.TotalMustBePositive", "The order total must be greater than zero.");
public static readonly Error AlreadyCancelled = Error.Conflict("Orders.AlreadyCancelled", "The order has already been cancelled.");}Now every failure that the orders feature can produce lives in one place. It’s easy to search for, easy to test, and you can’t accidentally spell it differently at each call site.
Let’s use these in a service. Real services are async and talk to a database, so this one uses EF Core 10 and takes a CancellationToken. Thanks to the implicit conversions, the code stays clean:
public sealed class OrderService(OrdersDbContext db){ public async Task<Result<Order>> GetByIdAsync(Guid id, CancellationToken cancellationToken) { var order = await db.Orders .AsNoTracking() .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
return order is not null ? order : OrderErrors.NotFound(id); }
public async Task<Result<Order>> CreateAsync( CreateOrderRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.CustomerEmail)) { return OrderErrors.EmailRequired; }
if (request.Total <= 0) { return OrderErrors.TotalMustBePositive; }
var order = new Order { Id = Guid.CreateVersion7(), CustomerEmail = request.CustomerEmail, Total = request.Total };
db.Orders.Add(order); await db.SaveChangesAsync(cancellationToken);
return order; }}As you can see, there is no throw, and no Result<Order>.Success(...) wrappers cluttering the code. It’s just plain returns. The implicit conversion works the same way inside a Task<Result<Order>>, so async doesn’t cost you anything when you return a result. It does cost you something when you chain several calls together, and I cover that in catch #4 below.
How do you map a Result to an HTTP response?
So the service returns a Result. How does that become a proper HTTP response? For this, I’ll write one extension method that every endpoint uses. It’s the only place in the whole application that knows how domain errors turn into status codes:
public static ProblemHttpResult ToProblem(this Error error) => error.Type switch{ ErrorType.Validation => TypedResults.Problem( title: "Validation failed", detail: error.Description, statusCode: StatusCodes.Status400BadRequest, extensions: new Dictionary<string, object?> { ["errorCode"] = error.Code }),
ErrorType.NotFound => TypedResults.Problem( title: "Resource not found", detail: error.Description, statusCode: StatusCodes.Status404NotFound, extensions: new Dictionary<string, object?> { ["errorCode"] = error.Code }),
ErrorType.Conflict => TypedResults.Problem( title: "Conflict", detail: error.Description, statusCode: StatusCodes.Status409Conflict, extensions: new Dictionary<string, object?> { ["errorCode"] = error.Code }),
ErrorType.Forbidden => TypedResults.Problem( title: "Forbidden", detail: error.Description, statusCode: StatusCodes.Status403Forbidden, extensions: new Dictionary<string, object?> { ["errorCode"] = error.Code }),
_ => TypedResults.Problem( title: "An error occurred", detail: error.Description, statusCode: StatusCodes.Status500InternalServerError, extensions: new Dictionary<string, object?> { ["errorCode"] = error.Code })};TypedResults.Problem produces an RFC 9457 application/problem+json body. The extensions parameter lets you add the machine-readable errorCode, so you don’t have to invent your own response format.
Now let’s write the endpoints. Notice the return type:
orders.MapGet("/{id:guid}", async Task<Results<Ok<Order>, ProblemHttpResult>> ( Guid id, OrderService service, CancellationToken cancellationToken) =>{ var result = await service.GetByIdAsync(id, cancellationToken);
return result.IsSuccess ? TypedResults.Ok(result.Value) : result.Error.ToProblem();});
orders.MapPost("/", async Task<Results<Created<Order>, ProblemHttpResult>> ( CreateOrderRequest request, OrderService service, CancellationToken cancellationToken) =>{ var result = await service.CreateAsync(request, cancellationToken);
return result.Match<Results<Created<Order>, ProblemHttpResult>>( onSuccess: order => TypedResults.Created($"/orders/{order.Id}", order), onFailure: error => error.ToProblem());});Note that I declared async Task<Results<...>> on the lambda, instead of a bare async handler. Also, Minimal APIs bind the CancellationToken parameter for you from HttpContext.RequestAborted, so when a client disconnects, the database work stops instead of running for nothing.
Here is something that not many developers notice. Results<Ok<Order>, ProblemHttpResult> is itself a union type, and it already ships with ASP.NET Core. The compiler checks that the endpoint returns one of those two shapes, and the OpenAPI generator reads them, so your Scalar document matches what the API actually returns, without a single Produces attribute. Keep this in mind before you reach for a NuGet package. At the endpoint level, the framework already gives you a checked union. Your own Result<T> is useful below that, in the service layer, where there are no HTTP types to rely on.
Minimal APIs in ASP.NET Core .NET 10
Routing, model binding, TypedResults, and the built-in validation that landed in .NET 10.
Let’s run the API and see what it actually returns. This is real output from the sample project:
$ curl -i http://localhost:5199/orders/00000000-0000-0000-0000-000000000001HTTP/1.1 404 Not FoundContent-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Resource not found","status":404,"detail":"No order was found with the id 00000000-0000-0000-0000-000000000001.","errorCode":"Orders.NotFound","traceId":"00-286f6d4a9535b2027711f61e98ea475d-abb56cb0d07878fb-00"}$ curl -i -X POST http://localhost:5199/orders -H "Content-Type: application/json" \ -d '{"customerEmail":"","total":0}'HTTP/1.1 400 Bad RequestContent-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Validation failed","status":400,"detail":"A customer email is required.","errorCode":"Orders.EmailRequired","traceId":"00-2ad1a67441e29569df0e6ca57744cd56-5b76bdab47ebb665-00"}You get a consistent response shape, correct status codes, a stable error code for clients, and a trace ID for debugging. Not a single exception was thrown to produce either of these responses, and the contract matches what a well-behaved REST API should return.
Returning more than one validation error
Did you notice something about that 400 response? It reports the missing email, but says nothing about the total, which was also invalid. Result<T> carries exactly one Error by design, so it stops at the first failure. That is the right behavior for a not-found or a conflict, but it’s the wrong behavior for a form, where the user wants to see every problem at once.
My suggestion is to not force Result<T> to hold a list. Instead, validate separately, return all the errors, and map them:
public IReadOnlyList<Error> Validate(CreateOrderRequest request){ var errors = new List<Error>();
if (string.IsNullOrWhiteSpace(request.CustomerEmail)) { errors.Add(OrderErrors.EmailRequired); }
if (request.Total <= 0) { errors.Add(OrderErrors.TotalMustBePositive); }
return errors;}
public static ProblemHttpResult ToValidationProblem(this IReadOnlyList<Error> errors) => TypedResults.Problem(new HttpValidationProblemDetails( errors .GroupBy(error => error.Code) .ToDictionary(group => group.Key, group => group.Select(e => e.Description).ToArray())) { Title = "Validation failed", Status = StatusCodes.Status400BadRequest });Passing an HttpValidationProblemDetails to TypedResults.Problem is important, because it keeps the return type as ProblemHttpResult, so the endpoint union stays Results<Created<Order>, ProblemHttpResult>. If you use TypedResults.ValidationProblem instead, you get a different result type, and you’d have to add it to every union. The RFC 9457 body is the same either way:
$ curl -i -X POST http://localhost:5199/orders/bulk-validated -H "Content-Type: application/json" -d '{"customerEmail":"","total":0}'HTTP/1.1 400 Bad RequestContent-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Validation failed","status":400,"errors":{"Orders.EmailRequired":["A customer email is required."],"Orders.TotalMustBePositive":["The order total must be greater than zero."]},"traceId":"00-a5d05077e47e5ffbb03ce1282ee7b936-63bf06d2affc8ae5-00"}I keyed the dictionary by error code instead of field name, but you can go either way here. Most clients expect field names, while codes don’t change when someone rewords a message. Just pick one and use it everywhere.
ProblemDetails in ASP.NET Core
The full RFC 9457 error contract, custom extensions, and how to standardize error responses across an API.
Does the Result pattern actually make your API faster?
Almost every article on this topic justifies the pattern by saying “exceptions are expensive”. But how expensive are they, really? I wanted actual numbers, so I measured it with BenchmarkDotNet 0.15.8. The benchmark compares returning a Result against throwing and catching an exception, at two call-stack depths. I used MethodImplOptions.NoInlining so that the stack frames are real. Here is the machine I ran it on:
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9278)Intel Core Ultra 9 275HX 2.70GHz, 24 logical and 24 physical cores.NET SDK 10.0.303, .NET 10.0.11, X64 RyuJIT x86-64-v3| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| Result, depth 1 | 0.73 ns | 1.00 | 0 B |
| Result, depth 10 | 8.78 ns | 12.2 | 0 B |
| Exception, depth 1 | 1,113.25 ns | 1,548 | 320 B |
| Exception, depth 10 | 3,670.85 ns | 5,104 | 1,336 B |
| Result, success | 1.09 ns | 1.5 | 0 B |
| Try/catch, no throw | 0.83 ns | 1.2 | 0 B |
Here are the four things that stood out to me from these results.
A try/catch that doesn’t throw is basically free. It took 0.83 ns, compared to 0.73 ns for the plain return. Only the actual throw costs anything.
The ratio is enormous. Throwing is roughly 1,500 times slower than returning a Result at depth 1, and about 420 times slower at depth 10.
Depth matters more than people expect. Going from 1 frame to 10 took the exception from 1.11 to 3.67 microseconds, and the allocation from 320 B to 1,336 B, because the stack trace grows with the stack. The Result path stayed at zero bytes. There is one caveat about that zero, though. The struct itself doesn’t allocate, and my benchmark uses a constant error message. If you build the message with string interpolation, like OrderErrors.NotFound(id) does, you pay for that string. Static readonly errors stay free.
The absolute numbers are small. This is what the ratio hides. Let’s do some quick math. If your API serves 1,000 requests a second and 5% of them are 404s, that is 50 throws a second, or 0.18 milliseconds of CPU per second. That is 0.018% of one core, and you will never notice it on a dashboard.
Now let’s change the workload. Say you are validating 100,000 rows in a bulk import, and 10% of them fail:
- Throwing: 10,000 exceptions at 3.67 microseconds each is about 37 milliseconds and roughly 13 MB of garbage.
- Returning a Result: 10,000 results at 8.78 nanoseconds each is about 0.09 milliseconds and zero allocation.
Now that is a real difference, and it’s the only case where I’ve seen the performance argument actually hold up.
There is one more thing you should know, and most articles on this topic were written before it happened. CoreCLR replaced its exception handling implementation in .NET 9 with the model from NativeAOT. Microsoft’s wording in What’s new in the .NET 9 runtime is that it is “2-4 times faster, per some exception handling micro-benchmarks”, enabled by default everywhere except Windows x86. So the numbers people keep repeating come from an older implementation that doesn’t apply anymore.
My take: performance is the weakest reason to adopt the Result pattern, even though it’s the reason most people give. Adopt it because
Result<Order>is an honest signature, andOrderis not. If your failures happen inside a loop that runs thousands of times, treat the performance gain as a bonus.
When should you use the Result pattern?
So when should you actually use it? Here’s the decision matrix that I follow:
| Situation | Use | Why |
|---|---|---|
| Expected domain failure (not found, invalid, conflict) | Result | It is a normal outcome and belongs in the signature |
| Bug or broken invariant, meaning a rule the code assumes is always true (null where null is impossible) | Throw | The caller cannot do anything useful with it |
| Infrastructure failure (database down, timeout) | Throw | Handle it globally, retry it, or fail the request |
| Failure inside a loop over thousands of items | Result | The only place the performance gap is real |
| A method that genuinely cannot fail | Neither | Don’t wrap Result<T> around something that always succeeds |
| Small API, one or two failure modes | Probably neither | A nullable return plus a 404 is less machinery |
| A public library others consume | Result | Callers get compile-time notice instead of a runtime surprise |
People usually ignore the second-to-last row. If your service has three endpoints and just one failure mode, Order? and a null check will serve you better than a whole new type. Don’t add a pattern just because it’s popular. The same thinking applies to plenty of other patterns too.
Repository Pattern in .NET 10 - Do You Really Need It?
The same is-this-abstraction-worth-it question, applied to data access.
What are the catches nobody mentions?
I would still adopt this pattern. But there are six things you should know before you do, and most articles don’t mention them.
1. Nothing stops a caller from ignoring a Result
This is the real cost of moving away from exceptions. You can’t ignore an exception, but you can easily ignore a Result:
// Compiles. Runs. Silently does nothing when the cancel fails._orderService.Cancel(id);There is a partial fix that not many people know about. CA1806 “Do not ignore method results” is enabled as a suggestion by default in .NET 10, and extends to your own methods through .editorconfig:
[*.cs]dotnet_code_quality.CA1806.additional_use_results_methods = M:MyApp.Orders.OrderService.Cancel(System.Guid)dotnet_diagnostic.CA1806.severity = errorThis has a limit, though. The CA1806 configuration matches on the method name or the fully qualified signature, and not on “any method returning Result<T>”. Listing methods one by one works for a handful of critical operations, but it’s not practical across a whole codebase. Use it where a silently ignored result would be expensive, and rely on code review for the rest.
2. You give up the free stack trace
When you throw an exception, you get a stack trace for free, which tells you exactly where it came from. Result.Failure(OrderErrors.NotFound(id)) tells you nothing about which of six call sites produced it. If you replace exceptions with results and change nothing else, your logs will get worse. So log at the point where the failure happens instead of where it’s handled, with enough structured context to trace the path later.
Structured Logging with Serilog in ASP.NET Core
Structured properties, enrichers, and request context - the things you need once failures stop carrying a stack trace.
3. It spreads through the call stack
Once one method returns a Result<T>, every method that calls it has to unwrap it or pass it along, and C# has no ? operator like Rust to make that a single character. Half-adopting the pattern, where some layers return results and others throw, is worse than picking either approach, because anyone reading the code has to know which convention applies where. Pick a boundary and stick to it. In a Clean Architecture solution that is usually the application layer.
4. Async gets awkward
Returning a Task<Result<T>> is easy. Chaining several of them together is where people start complaining. Let me show you both options, so you can judge for yourself. Take a workflow that fetches an order, cancels it, and issues a refund.
The explicit version is what most teams end up writing:
public async Task<Result<Refund>> CancelAndRefundAsync(Guid orderId, CancellationToken cancellationToken){ var order = await GetByIdAsync(orderId, cancellationToken); if (order.IsFailure) return order.Error;
var cancelled = await CancelAsync(orderId, cancellationToken); if (cancelled.IsFailure) return cancelled.Error;
var refreshed = await GetByIdAsync(orderId, cancellationToken); if (refreshed.IsFailure) return refreshed.Error;
var refund = await RefundAsync(refreshed.Value, cancellationToken); if (refund.IsFailure) return refund.Error;
return refund.Value;}That is eight lines of plumbing for four real steps. It’s verbose, but it’s still completely clear when you read it six months later.
The functional version makes it shorter, but you need an extension method for it:
public static async Task<Result<TOut>> BindAsync<TIn, TOut>( this Task<Result<TIn>> task, Func<TIn, Task<Result<TOut>>> next){ var result = await task; return result.IsFailure ? result.Error : await next(result.Value);}
// The same workflow as a pipeline.public Task<Result<Refund>> CancelAndRefundChainedAsync(Guid orderId, CancellationToken cancellationToken) => GetByIdAsync(orderId, cancellationToken) .BindAsync(async order => { var cancelled = await CancelAsync(order.Id, cancellationToken); return cancelled.IsFailure ? Result<Order>.Failure(cancelled.Error) : await GetByIdAsync(order.Id, cancellationToken); }) .BindAsync(order => RefundAsync(order, cancellationToken));Look at the middle step. CancelAsync returns the non-generic Result, which doesn’t fit BindAsync<TIn, TOut>, so it has to be adapted by hand, and the pipeline is no longer a clean chain. If you write the overload that fixes this, you’ll soon want MapAsync, then TapAsync, and then a sync version of each.
My advice is to write the explicit version. It’s not the prettiest code, but it works just fine, and anyone on your team can read it. Only revisit this if you have really long chains, and read the C# 15 section below before you invest in an extension library.
5. Collections force a choice
IEnumerable<Result<Order>> and Result<IEnumerable<Order>> mean different things. The first says each item may have failed, the second says the whole operation may have. Both are valid, but they need different handling at the call site, and mixing them up will confuse your team. Decide which one you mean, and write it down.
6. Everything else still throws
EF Core throws, HttpClient throws, and System.Text.Json throws. The Result pattern only reduces the exceptions that you raise. You still need the global exception handler, and if you follow any article that says otherwise, you will end up with a 500 and an unhelpful response body in production.
How do you test a Result?
This is where the pattern really pays off. Testing becomes a lot simpler.
Testing a method that throws means knowing which exception type to expect and wrapping the call in Assert.Throws. Testing a method that returns a Result means asserting on a return value like any other:
[Fact]public async Task GetByIdAsync_returns_a_NotFound_error_when_the_order_is_missing(){ var result = await _service.GetByIdAsync(Guid.CreateVersion7(), TestContext.Current.CancellationToken);
Assert.True(result.IsFailure); Assert.Equal("Orders.NotFound", result.Error.Code); Assert.Equal(ErrorType.NotFound, result.Error.Type);}One tip here: always assert on Error.Code, and never on Error.Description. The code is the contract. The description is just text that a product manager might reword next quarter, and a test that asserts on English text will break for no good reason. So the Code field has a second job, apart from giving clients something to branch on.
The OrderErrors class I created earlier helps here too. OrderErrors.AlreadyCancelled is a single symbol, so if you rename the error, a test that asserts on it fails to compile, instead of quietly passing against an old string. The full test suite is in the repo - xUnit v3 against an in-memory SQLite database.
Should you use a library or write your own?
You don’t have to write your own Result type. There are some popular libraries for this. Here is where the main options stood when I checked NuGet and GitHub on 31 August 2026:
| Package | Latest | Published | Downloads | License | Best for |
|---|---|---|---|---|---|
| ErrorOr | 2.1.1 | May 2026 | 11.1M | MIT | Production APIs, simplest API surface |
| FluentResults | 4.0.0 | Jun 2025 | 34.5M | MIT | Accumulating multiple errors |
| Ardalis.Result | 10.1.0 | Oct 2024 | 10.1M | MIT | Built-in HTTP status mapping |
| CSharpFunctionalExtensions | 3.7.0 | Mar 2026 | 36.9M | MIT | Teams already doing functional C# |
| OneOf | 3.0.271 | May 2024 | 70.5M | MIT | Generic unions, not just success/failure |
All of them are free. That’s worth mentioning, since so many popular .NET libraries moved to commercial licensing recently. If you pair this pattern with a mediator, that is where you actually need to think about licensing.
Build Your Own CQRS Dispatcher in .NET 10 (No MediatR)
A FrozenDictionary dispatcher that handlers returning Result<T> plug straight into, without the MediatR licensing question.
My recommendation:
- For a single service or a small API, write it yourself. The whole implementation is the fifty lines above.
- For a production API that you expect to maintain, use ErrorOr. It has the smallest API surface and the most recent release cadence.
- Use FluentResults only if you need several errors from one operation, which usually means bulk validation or an import pipeline.
- Skip the functional toolkits unless your team already writes code that way, because you’d be adopting a whole style of C# just to get a two-state type.
Also, if most of your failures are input validation errors, you might not need a Result type for them at all. .NET 10 added built-in validation for Minimal APIs. Call builder.Services.AddValidation() and add <InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces> to the .csproj so the source generator wires itself in. After that, request models with data annotations get a ProblemDetails 400 before your handler even runs, which covers some of what people build Result types for.
FluentValidation in ASP.NET Core .NET 10
Rule sets, async validators, and where FluentValidation still beats the built-in data annotation validation.
What do C# 15 union types change?
This part should affect your decision, because it’s coming very soon.
Union types are a C# 15 feature. C# 15 ships with .NET 11, and Microsoft’s docs say the final .NET 11 release is expected in November 2026. You can use them today in the .NET 11 preview SDK, and What’s new in C# 15 gives the syntax:
public record class Cat(string Name);public record class Dog(string Name);public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);So what is a union? A union declares that a value is exactly one of a fixed set of case types. Each case converts implicitly to the union, and the compiler checks that a switch expression covers every case, so no default arm is needed:
Pet pet = new Dog("Rex");
string name = pet switch{ Dog d => d.Name, Cat c => c.Name, Bird b => b.Name,};Now compare that with what I built in this article. A result is a value that is exactly one of two case types, success or error. That is a union, just written by hand. Once the language supports unions, a simple declaration replaces the whole implementation, and you get a guarantee that the hand-written version can’t give you: nothing above stops a developer reading .Value without checking .IsSuccess, but the compiler will stop them forgetting a union case.
There are three caveats, because this is still preview software. First, I haven’t run any of this code here on .NET 10.0.303, so those snippets come from Microsoft’s docs and not from my own build. Second, Microsoft states that “some features from the proposal specification aren’t yet implemented”, and the runtime’s UnionAttribute and IUnion types only landed in .NET 11 Preview 5, so the exact shape of a generic Result<T> union is still moving. Third, early analysis of the preview reports that case values are stored as object, which would box value types. Treat all of this as provisional until .NET 11 is final.
So what does this mean today? Adopt the pattern now. Methods that return an outcome instead of throwing are exactly what unions will fit into later, and that migration is straightforward. What I would avoid is investing in a big chain of Bind, Map, Tap and Ensure extensions, because that’s the layer that unions and pattern matching will make unnecessary. Keep the type simple and the call sites explicit, and the upgrade will be a small change instead of a rewrite.
Key takeaways
- The real value is the honest method signature, and not performance.
Result<Order>tells you something thatOrderdoesn’t. - On .NET 10.0.11 a throw costs 1.11 microseconds at depth 1 and 3.67 microseconds ten frames deep. A
Resultreturn costs under 9 nanoseconds and allocates nothing. - At normal API failure rates, you won’t notice that gap. It only matters when failures happen thousands of times in a loop.
Results<Ok<T>, ProblemHttpResult>is already a compiler-checked union at the endpoint layer. YourResult<T>belongs below it.- The biggest weakness is that a Result can be ignored. CA1806 with
additional_use_results_methodshelps for named methods, not a whole codebase. - Async costs nothing when you return a result, but it costs something when you chain calls. Write the explicit
if (result.IsFailure) return result.Error;version before you build aBindlibrary. - Tests get simpler. Assert on
Error.Code, never onError.Description, so a reworded message never breaks a test. - C# 15 unions ship with .NET 11, expected November 2026, and replace the hand-written type with a declaration the compiler checks exhaustively. Adopt the pattern now, and keep the implementation simple.
Frequently Asked Questions
What is the Result pattern in C#?
The Result pattern is an approach where a method returns an object representing either success with a value or failure with an error, instead of throwing an exception. In C# this is usually a Result or Result<T> type with an IsSuccess flag, a Value, and an Error. It makes failure part of the method signature, so the caller can see it without reading the implementation. Note that C# does not force the caller to check it.
Is the Result pattern better than exceptions?
It is better for expected failures such as a missing record, a validation error, or a conflict, because those are normal outcomes that belong in the return type. Exceptions remain the right tool for bugs, broken invariants, and infrastructure failures. Most production code needs both.
When should I still throw an exception in .NET?
Throw when the caller cannot reasonably recover: a broken invariant, a null where null is impossible, an unreachable database, or a configuration error at startup. Also keep throwing for anything a global exception handler should turn into a 500 response.
Should I build my own Result type or use a NuGet package?
Hand-roll it for a single service or a small API, since a complete implementation is around fifty lines. For a production API you will maintain long term, ErrorOr is the smallest and most actively released option. Choose FluentResults if you need to accumulate multiple errors from one operation.
How do I map a Result to the right HTTP status code in a Minimal API?
Give your Error type an ErrorType enum with values like Validation, NotFound and Conflict, then write one extension method that switches on that enum and returns TypedResults.Problem with the matching status code. Every endpoint calls that one method, which keeps the error contract consistent.
Does the Result pattern actually make my API faster?
Rarely. Measured on .NET 10.0.11 with BenchmarkDotNet, a thrown and caught exception costs about 1.11 microseconds one frame deep and 3.67 microseconds ten frames deep, against under 9 nanoseconds for a Result return. At a few hundred failures per second that is invisible. It matters only when failures occur thousands of times inside a loop.
What stops a developer from ignoring a returned Result?
Nothing in the C# language does. Code analysis rule CA1806 can be extended to specific methods through the additional_use_results_methods option in an editorconfig file, and its severity raised to error. That covers a small set of critical methods, not every method returning a Result.
Will C# 15 union types replace the Result pattern?
They replace the hand-written implementation, not the pattern. C# 15 ships with .NET 11, whose final release Microsoft expects in November 2026, and a union declares that a value is exactly one of a fixed set of case types with compiler-checked exhaustive matching. Code that already returns results rather than throwing migrates easily, so adopting the pattern now is still the right call.
Troubleshooting
Cannot read the value of a failed result. You called .Value without checking .IsSuccess. That exception is intentional, and it points to a bug at the call site. Use Match, or check IsSuccess first.
The implicit conversion does not compile. If TValue is itself an Error, or the target type is ambiguous, the compiler can’t pick a conversion. Fall back to the explicit Result<T>.Success(value) and Result<T>.Failure(error) factories.
OpenAPI shows only one response type. Declare Results<Ok<Order>, ProblemHttpResult> on the handler instead of returning IResult. IResult hides the response shapes, so the generator has nothing to read.
Microsoft.OpenApi reports a high severity vulnerability on build. Microsoft.AspNetCore.OpenApi 10.0.0 pulls in Microsoft.OpenApi 2.0.0, which has an advisory against it. Bumping to 10.0.11 cleared it for me.
Cannot implicitly convert type 'Error' to 'Task<Result<T>>'. The implicit conversion produces a Result<T>, not a Task<Result<T>>. Inside an async method return OrderErrors.NotFound(id); compiles fine. In a method that returns Task<Result<T>> without async, wrap it: Task.FromResult<Result<Order>>(OrderErrors.NotFound(id)).
ProblemDetails responses come back empty on some status codes. Register AddProblemDetails() and call UseStatusCodePages(). Without them, framework-generated responses such as a 405 have no body.
Summary
That’s quite everything about the Result pattern in .NET 10! To sum it up, the Result pattern is worth adopting mainly for one reason: it puts failure in the method signature, where the next developer can see it without reading the implementation. The performance argument only holds inside loops, and on .NET 10 the gain is smaller than most people think.
My suggestion is to build the small version: a readonly record struct, an Error with a code and a type, one error catalog per feature, and one extension method that turns errors into ProblemDetails. Write explicit failure checks in your async workflows and skip the chaining helpers. Keep exceptions for the things you can’t plan for, and keep the global exception handler. When C# 15 unions arrive, that small version becomes a declaration that the compiler checks for you.
The complete project, including the benchmark, is in the GitHub repo. Clone it and run the benchmarks on your own machine to see whether the numbers hold for your workload.
Are you already using the Result pattern in production, or are you still using exceptions? What are your opinions about this? Let me know in the comments below.
If you found this article helpful, share it with your colleagues.
Happy Coding :)
What's your take?
Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.