Error handling is one of those topics every developer thinks they have figured out — until they are three months into a project and their codebase is littered with try/catch blocks, mysterious null returns, and exceptions being used to control the flow of business logic. Sound familiar?
In this guide, we are going to fix all of that. We will build the Result Pattern in C# from the ground up, apply it to real-world scenarios you will immediately recognise from your own work, and by the end, you will have a completely different way of thinking about errors in .NET.
The Problem A Story You Have Lived Before#
Let me tell you about a scenario that plays out in .NET codebases every single day.
You are building an e-commerce platform. A customer clicks "Place Order". Behind the scenes, your code needs to:
- Check if the user exists
- Validate the items in the cart are still in stock
- Process the payment
- Create the order record
- Send a confirmation email
Now, things can go wrong at every single one of those steps. The user might not exist. An item might have sold out. The card might be declined. What do most developers do?
They throw exceptions.
C#csharp1public async Task<Order> PlaceOrderAsync(Guid userId, List<CartItem> items) 2{ 3 var user = await _userRepository.GetByIdAsync(userId); 4 if (user is null) 5 throw new NotFoundException($"User {userId} not found"); 6 7 foreach (var item in items) 8 { 9 var product = await _productRepository.GetByIdAsync(item.ProductId); 10 if (product.Stock < item.Quantity) 11 throw new InsufficientStockException($"Not enough stock for {product.Name}"); 12 } 13 14 var paymentResult = await _paymentService.ChargeAsync(user.PaymentMethod, total); 15 if (!paymentResult.Success) 16 throw new PaymentFailedException("Card was declined"); 17 18 // ... create order, send email 19}
And then in the controller:
C#csharp1[HttpPost("orders")] 2public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request) 3{ 4 try 5 { 6 var order = await _orderService.PlaceOrderAsync(request.UserId, request.Items); 7 return Ok(order); 8 } 9 catch (NotFoundException ex) 10 { 11 return NotFound(ex.Message); 12 } 13 catch (InsufficientStockException ex) 14 { 15 return BadRequest(ex.Message); 16 } 17 catch (PaymentFailedException ex) 18 { 19 return BadRequest(ex.Message); 20 } 21 catch (Exception ex) 22 { 23 return StatusCode(500, "Something went wrong"); 24 } 25}
This is the exception-as-flow-control anti-pattern. Here is why it is a problem:
- The caller must know which exceptions to catch this is not obvious from the method signature
PlaceOrderAsyncreturnsTask<Order>but it can actually fail in 3 different ways the signature is lying to you- Exceptions are expensive they capture a stack trace, which has a real performance cost
- Your
try/catchblocks become long maintenance nightmares - Writing unit tests requires knowing which exceptions to expect
The Rule: Exceptions Are for Exceptional Situations#
Before we dive into the Result Pattern, let us establish one rule that will guide everything:
Use exceptions for things you did NOT expect. Use the Result Pattern for things you DID expect.
Think about it like a restaurant kitchen:
- A chef expects that a customer might order a dish that is sold out that is a business condition, not an emergency
- A chef does NOT expect the oven to suddenly catch fire that is exceptional, worth stopping everything for
In code terms:
- A user's card being declined is expected return a Result
- The database server being completely unreachable is exceptional throw an exception
With that rule in mind, let us build the Result Pattern.
Step 1 The Error Record#
The foundation of the Result Pattern is a way to represent errors. We start with an Error record.
C#csharp1public sealed record Error(string Code, string Description) 2{ 3 // Represents "no error" — used for successful results 4 public static readonly Error None = new(string.Empty, string.Empty); 5 6 // A convenient factory for not-found errors 7 public static Error NotFound(string code, string description) => 8 new($"NotFound.{code}", description); 9 10 // A convenient factory for validation errors 11 public static Error Validation(string code, string description) => 12 new($"Validation.{code}", description); 13 14 // A convenient factory for conflict errors 15 public static Error Conflict(string code, string description) => 16 new($"Conflict.{code}", description); 17}
Why a record? Because records give us value equality out of the box. Two Error instances with the same Code and Description are equal perfect for comparing errors in tests.
The Code is a machine-readable identifier like "Order.ItemOutOfStock" — great for logging and API responses.
The Description is human-readable great for displaying to developers or end users.
Step 2 The Result Class (Non-Generic)#
Now let us build the core Result class. This represents an operation that either succeeded or failed with a known error but does not return a value.
C#csharp1public class Result 2{ 3 protected Result(bool isSuccess, Error error) 4 { 5 // Guard against invalid combinations 6 if (isSuccess && error != Error.None) 7 throw new InvalidOperationException("A successful result cannot have an error."); 8 9 if (!isSuccess && error == Error.None) 10 throw new InvalidOperationException("A failed result must have an error."); 11 12 IsSuccess = isSuccess; 13 Error = error; 14 } 15 16 public bool IsSuccess { get; } 17 18 public bool IsFailure => !IsSuccess; 19 20 public Error Error { get; } 21 22 // The only way to create a Result — through these factory methods 23 public static Result Success() => new(true, Error.None); 24 25 public static Result Failure(Error error) => new(false, error); 26 27 // Implicit conversion from Error — makes code more concise 28 public static implicit operator Result(Error error) => Failure(error); 29}
Notice the implicit operator at the bottom. This lets you write:
C#csharp1// Instead of this: 2return Result.Failure(OrderErrors.ItemOutOfStock); 3 4// You can write this: 5return OrderErrors.ItemOutOfStock;
Much cleaner.
Step 3 The Generic Result<T> Class#
Most of the time, your operations need to return a value on success. That is what Result<T> is for.
C#csharp1public sealed class Result<T> : Result 2{ 3 private readonly T? _value; 4 5 private Result(T value) : base(true, Error.None) 6 { 7 _value = value; 8 } 9 10 private Result(Error error) : base(false, error) 11 { 12 _value = default; 13 } 14 15 public T Value 16 { 17 get 18 { 19 if (IsFailure) 20 throw new InvalidOperationException( 21 "Cannot access Value on a failed result. Check IsSuccess before accessing Value."); 22 23 return _value!; 24 } 25 } 26 27 public static Result<T> Success(T value) => new(value); 28 29 public static new Result<T> Failure(Error error) => new(error); 30 31 // Implicit conversions make usage very natural 32 public static implicit operator Result<T>(T value) => Success(value); 33 public static implicit operator Result<T>(Error error) => Failure(error); 34}
The Value property throws if you try to access it on a failed result. This is intentional — it forces you to check IsSuccess before using the value. The implicit conversions from both T and Error make the code extremely clean at the call sites, as you will see in a moment.
Step 4 Documenting Your Errors#
This is where most tutorials stop, but this step is crucial for real-world maintainability.
Instead of scattering new Error(...) calls throughout your codebase, create a dedicated error class for each domain entity. Think of it as documentation a single file where any developer can look up every possible error an entity can produce.
C#csharp1// All possible errors related to Orders, in one place 2public static class OrderErrors 3{ 4 public static readonly Error UserNotFound = Error.NotFound( 5 "Order.UserNotFound", 6 "The user placing the order does not exist."); 7 8 public static readonly Error EmptyCart = Error.Validation( 9 "Order.EmptyCart", 10 "Cannot place an order with an empty cart."); 11 12 // This one takes an argument — so it's a static method, not a field 13 public static Error ItemOutOfStock(string productName) => Error.Validation( 14 "Order.ItemOutOfStock", 15 $"'{productName}' is out of stock. Please remove it from your cart."); 16 17 public static Error InsufficientStock(string productName, int available, int requested) => 18 Error.Validation( 19 "Order.InsufficientStock", 20 $"Only {available} units of '{productName}' available, but {requested} were requested."); 21 22 public static readonly Error PaymentDeclined = new( 23 "Order.PaymentDeclined", 24 "Your payment method was declined. Please check your card details and try again."); 25 26 public static readonly Error OrderAlreadyProcessed = Error.Conflict( 27 "Order.AlreadyProcessed", 28 "This order has already been processed and cannot be modified."); 29}
C#csharp1// All possible errors related to Users 2public static class UserErrors 3{ 4 public static readonly Error NotFound = Error.NotFound( 5 "User.NotFound", 6 "No account was found with the provided credentials."); 7 8 public static readonly Error EmailAlreadyExists = Error.Conflict( 9 "User.EmailAlreadyExists", 10 "An account with this email address already exists."); 11 12 public static readonly Error InvalidPassword = new( 13 "User.InvalidPassword", 14 "The password you entered is incorrect."); 15 16 public static readonly Error AccountLocked = new( 17 "User.AccountLocked", 18 "Your account has been locked due to too many failed login attempts."); 19}
Now every possible error is discoverable, documented, and consistent. Any developer can type OrderErrors. in their IDE and immediately see every error the Order domain can produce.
Real-World Scenario 1: E-Commerce Order Service#
Let us refactor that messy PlaceOrderAsync method from the beginning using everything we have built.
C#csharp1public sealed class OrderService 2{ 3 private readonly IUserRepository _userRepository; 4 private readonly IProductRepository _productRepository; 5 private readonly IPaymentService _paymentService; 6 private readonly IOrderRepository _orderRepository; 7 8 public OrderService( 9 IUserRepository userRepository, 10 IProductRepository productRepository, 11 IPaymentService paymentService, 12 IOrderRepository orderRepository) 13 { 14 _userRepository = userRepository; 15 _productRepository = productRepository; 16 _paymentService = paymentService; 17 _orderRepository = orderRepository; 18 } 19 20 public async Task<Result<Order>> PlaceOrderAsync( 21 Guid userId, 22 List<CartItem> items, 23 CancellationToken cancellationToken = default) 24 { 25 // Step 1: Check if the user exists 26 var user = await _userRepository.GetByIdAsync(userId, cancellationToken); 27 if (user is null) 28 return OrderErrors.UserNotFound; 29 30 // Step 2: Validate the cart is not empty 31 if (items.Count == 0) 32 return OrderErrors.EmptyCart; 33 34 // Step 3: Check stock for every item 35 decimal total = 0; 36 foreach (var item in items) 37 { 38 var product = await _productRepository.GetByIdAsync(item.ProductId, cancellationToken); 39 40 if (product is null) 41 return OrderErrors.ItemOutOfStock(item.ProductName); 42 43 if (product.Stock < item.Quantity) 44 return OrderErrors.InsufficientStock(product.Name, product.Stock, item.Quantity); 45 46 total += product.Price * item.Quantity; 47 } 48 49 // Step 4: Process payment 50 var paymentResult = await _paymentService.ChargeAsync(user.PaymentMethodId, total, cancellationToken); 51 if (paymentResult.WasDeclined) 52 return OrderErrors.PaymentDeclined; 53 54 // Step 5: Create the order (happy path) 55 var order = Order.Create(userId, items, total); 56 await _orderRepository.InsertAsync(order, cancellationToken); 57 58 return order; // Implicit conversion to Result<Order>.Success(order) 59 } 60}
Look at this method now. No try/catch. No throw. Just clean, readable business logic. Every early return tells you exactly what went wrong. The method signature Task<Result<Order>> is honest — it tells every caller: "this can fail, and here is how you handle it."
Step 5 The Match Extension Method#
Now let us wire this up to an ASP.NET Core endpoint. But first, let us build a powerful helper called Match.
Match is an extension method that takes two callbacks one for success and one for failure — and executes the right one. It lets you handle both cases without ever writing an if statement.
C#csharp1public static class ResultExtensions 2{ 3 // For non-generic Result 4 public static TOut Match<TOut>( 5 this Result result, 6 Func<TOut> onSuccess, 7 Func<Error, TOut> onFailure) 8 { 9 return result.IsSuccess ? onSuccess() : onFailure(result.Error); 10 } 11 12 // For generic Result<T> 13 public static TOut Match<T, TOut>( 14 this Result<T> result, 15 Func<T, TOut> onSuccess, 16 Func<Error, TOut> onFailure) 17 { 18 return result.IsSuccess ? onSuccess(result.Value) : onFailure(result.Error); 19 } 20 21 // Async version for async endpoints 22 public static async Task<TOut> MatchAsync<T, TOut>( 23 this Task<Result<T>> resultTask, 24 Func<T, TOut> onSuccess, 25 Func<Error, TOut> onFailure) 26 { 27 var result = await resultTask; 28 return result.IsSuccess ? onSuccess(result.Value) : onFailure(result.Error); 29 } 30}
Real-World Scenario 2: ASP.NET Core API Endpoints#
Now let us see how the Result Pattern connects to your API layer. We will show both approaches — the if check and the Match method.
Minimal API style (with if check):
C#csharp1app.MapPost("/api/orders", async ( 2 PlaceOrderRequest request, 3 OrderService orderService, 4 CancellationToken cancellationToken) => 5{ 6 var result = await orderService.PlaceOrderAsync( 7 request.UserId, 8 request.Items, 9 cancellationToken); 10 11 if (result.IsFailure) 12 return Results.BadRequest(new { result.Error.Code, result.Error.Description }); 13 14 return Results.Created($"/api/orders/{result.Value.Id}", result.Value); 15});
Minimal API style (with Match — cleaner):
C#csharp1app.MapPost("/api/orders", async ( 2 PlaceOrderRequest request, 3 OrderService orderService, 4 CancellationToken cancellationToken) => 5{ 6 return await orderService 7 .PlaceOrderAsync(request.UserId, request.Items, cancellationToken) 8 .MatchAsync( 9 onSuccess: order => Results.Created($"/api/orders/{order.Id}", order), 10 onFailure: error => Results.BadRequest(new { error.Code, error.Description })); 11});
Controller style:
C#csharp1[ApiController] 2[Route("api/orders")] 3public class OrdersController : ControllerBase 4{ 5 private readonly OrderService _orderService; 6 7 public OrdersController(OrderService orderService) 8 { 9 _orderService = orderService; 10 } 11 12 [HttpPost] 13 [ProducesResponseType(typeof(Order), StatusCodes.Status201Created)] 14 [ProducesResponseType(StatusCodes.Status400BadRequest)] 15 [ProducesResponseType(StatusCodes.Status404NotFound)] 16 public async Task<IActionResult> PlaceOrder( 17 PlaceOrderRequest request, 18 CancellationToken cancellationToken) 19 { 20 var result = await _orderService.PlaceOrderAsync( 21 request.UserId, 22 request.Items, 23 cancellationToken); 24 25 return result.Match( 26 onSuccess: order => CreatedAtAction(nameof(GetById), new { id = order.Id }, order), 27 onFailure: error => BadRequest(new ProblemDetails 28 { 29 Title = "Order placement failed", 30 Detail = error.Description, 31 Extensions = { ["errorCode"] = error.Code } 32 })); 33 } 34}
The Match method makes it impossible to forget to handle the failure case — it forces you to provide both callbacks.
Step 6 Smarter Error-to-HTTP Mapping#
In the examples above, we always return BadRequest for failures. But what if OrderErrors.UserNotFound should return a 404 Not Found instead of a 400 Bad Request?
We can solve this by adding an ErrorType to our Error record and creating a smart mapping function.
C#csharp1public enum ErrorType 2{ 3 Failure, 4 NotFound, 5 Validation, 6 Conflict, 7 Unauthorized 8} 9 10public sealed record Error(string Code, string Description, ErrorType Type = ErrorType.Failure) 11{ 12 public static readonly Error None = new(string.Empty, string.Empty); 13 14 public static Error NotFound(string code, string description) => 15 new(code, description, ErrorType.NotFound); 16 17 public static Error Validation(string code, string description) => 18 new(code, description, ErrorType.Validation); 19 20 public static Error Conflict(string code, string description) => 21 new(code, description, ErrorType.Conflict); 22 23 public static Error Unauthorized(string code, string description) => 24 new(code, description, ErrorType.Unauthorized); 25}
Now add a helper that converts any Error to the right HTTP IResult:
C#csharp1public static class ErrorHttpMapper 2{ 3 public static IResult ToHttpResult(this Error error) 4 { 5 return error.Type switch 6 { 7 ErrorType.NotFound => Results.NotFound(new { error.Code, error.Description }), 8 ErrorType.Validation => Results.BadRequest(new { error.Code, error.Description }), 9 ErrorType.Conflict => Results.Conflict(new { error.Code, error.Description }), 10 ErrorType.Unauthorized => Results.Unauthorized(), 11 _ => Results.Problem(error.Description) 12 }; 13 } 14}
And now your endpoint becomes:
C#csharp1app.MapPost("/api/orders", async ( 2 PlaceOrderRequest request, 3 OrderService orderService, 4 CancellationToken cancellationToken) => 5{ 6 return await orderService 7 .PlaceOrderAsync(request.UserId, request.Items, cancellationToken) 8 .MatchAsync( 9 onSuccess: order => Results.Created($"/api/orders/{order.Id}", order), 10 onFailure: error => error.ToHttpResult()); // ← Auto-maps to correct HTTP status 11});
OrderErrors.UserNotFound has ErrorType.NotFound, so it automatically returns 404. OrderErrors.PaymentDeclined has ErrorType.Failure, so it returns a 500 Problem. Every error type maps to the right HTTP response without you writing a single if statement.
Real-World Scenario 3: User Registration Service#
Let us see the full pattern with a second complete example — user registration — which many of you will have built before.
C#csharp1public static class UserErrors 2{ 3 public static readonly Error EmailAlreadyExists = Error.Conflict( 4 "User.EmailAlreadyExists", 5 "An account with this email address already exists. Try logging in instead."); 6 7 public static readonly Error WeakPassword = Error.Validation( 8 "User.WeakPassword", 9 "Password must be at least 8 characters and contain a number and a special character."); 10 11 public static readonly Error InvalidEmailFormat = Error.Validation( 12 "User.InvalidEmailFormat", 13 "The email address format is not valid."); 14 15 public static Error NotFound(Guid id) => Error.NotFound( 16 "User.NotFound", 17 $"No user with ID '{id}' was found."); 18} 19 20public sealed class UserService 21{ 22 private readonly IUserRepository _userRepository; 23 private readonly IPasswordHasher _passwordHasher; 24 25 public UserService(IUserRepository userRepository, IPasswordHasher passwordHasher) 26 { 27 _userRepository = userRepository; 28 _passwordHasher = passwordHasher; 29 } 30 31 public async Task<Result<UserDto>> RegisterAsync( 32 string email, 33 string password, 34 CancellationToken cancellationToken = default) 35 { 36 // Validate email format 37 if (!email.Contains('@') || !email.Contains('.')) 38 return UserErrors.InvalidEmailFormat; 39 40 // Validate password strength 41 if (password.Length < 8 || !password.Any(char.IsDigit)) 42 return UserErrors.WeakPassword; 43 44 // Check if email is already taken 45 var existingUser = await _userRepository.GetByEmailAsync(email, cancellationToken); 46 if (existingUser is not null) 47 return UserErrors.EmailAlreadyExists; 48 49 // Create the user (happy path) 50 var hashedPassword = _passwordHasher.Hash(password); 51 var user = User.Create(email, hashedPassword); 52 53 await _userRepository.InsertAsync(user, cancellationToken); 54 55 return new UserDto(user.Id, user.Email, user.CreatedAt); 56 } 57}
C#csharp1// The endpoint — clean and predictable 2app.MapPost("/api/auth/register", async ( 3 RegisterRequest request, 4 UserService userService, 5 CancellationToken cancellationToken) => 6{ 7 return await userService 8 .RegisterAsync(request.Email, request.Password, cancellationToken) 9 .MatchAsync( 10 onSuccess: user => Results.Created($"/api/users/{user.Id}", user), 11 onFailure: error => error.ToHttpResult()); 12});
Look at that endpoint. It is four lines of logic. Any developer junior or senior can read it and immediately understand what it does. That is the power of the Result Pattern.
Testing with the Result Pattern#
One of the greatest benefits of the Result Pattern that is often overlooked is how much easier it makes unit testing. With exceptions, you need to use Assert.Throws and know which exception type to expect. With the Result Pattern, it is just a property check.
C#csharp1public class OrderServiceTests 2{ 3 private readonly OrderService _sut; 4 private readonly Mock<IUserRepository> _userRepositoryMock = new(); 5 private readonly Mock<IProductRepository> _productRepositoryMock = new(); 6 private readonly Mock<IPaymentService> _paymentServiceMock = new(); 7 private readonly Mock<IOrderRepository> _orderRepositoryMock = new(); 8 9 public OrderServiceTests() 10 { 11 _sut = new OrderService( 12 _userRepositoryMock.Object, 13 _productRepositoryMock.Object, 14 _paymentServiceMock.Object, 15 _orderRepositoryMock.Object); 16 } 17 18 [Fact] 19 public async Task PlaceOrder_WhenUserDoesNotExist_ReturnsUserNotFoundError() 20 { 21 // Arrange 22 _userRepositoryMock 23 .Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) 24 .ReturnsAsync((User?)null); 25 26 // Act 27 var result = await _sut.PlaceOrderAsync(Guid.NewGuid(), new List<CartItem>()); 28 29 // Assert — clean, readable, no Assert.Throws needed 30 Assert.True(result.IsFailure); 31 Assert.Equal(OrderErrors.UserNotFound, result.Error); 32 } 33 34 [Fact] 35 public async Task PlaceOrder_WhenPaymentIsDeclined_ReturnsPaymentDeclinedError() 36 { 37 // Arrange 38 _userRepositoryMock 39 .Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) 40 .ReturnsAsync(new User { Id = Guid.NewGuid() }); 41 42 _productRepositoryMock 43 .Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) 44 .ReturnsAsync(new Product { Stock = 100, Price = 25.00m, Name = "Widget" }); 45 46 _paymentServiceMock 47 .Setup(p => p.ChargeAsync(It.IsAny<Guid>(), It.IsAny<decimal>(), It.IsAny<CancellationToken>())) 48 .ReturnsAsync(new PaymentResult { WasDeclined = true }); 49 50 var items = new List<CartItem> { new() { ProductId = Guid.NewGuid(), Quantity = 1 } }; 51 52 // Act 53 var result = await _sut.PlaceOrderAsync(Guid.NewGuid(), items); 54 55 // Assert 56 Assert.True(result.IsFailure); 57 Assert.Equal(OrderErrors.PaymentDeclined, result.Error); 58 } 59 60 [Fact] 61 public async Task PlaceOrder_WhenEverythingIsValid_ReturnsSuccessWithOrder() 62 { 63 // Arrange 64 var userId = Guid.NewGuid(); 65 _userRepositoryMock 66 .Setup(r => r.GetByIdAsync(userId, It.IsAny<CancellationToken>())) 67 .ReturnsAsync(new User { Id = userId }); 68 69 _productRepositoryMock 70 .Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) 71 .ReturnsAsync(new Product { Stock = 100, Price = 25.00m, Name = "Widget" }); 72 73 _paymentServiceMock 74 .Setup(p => p.ChargeAsync(It.IsAny<Guid>(), It.IsAny<decimal>(), It.IsAny<CancellationToken>())) 75 .ReturnsAsync(new PaymentResult { WasDeclined = false }); 76 77 var items = new List<CartItem> { new() { ProductId = Guid.NewGuid(), Quantity = 2 } }; 78 79 // Act 80 var result = await _sut.PlaceOrderAsync(userId, items); 81 82 // Assert 83 Assert.True(result.IsSuccess); 84 Assert.NotNull(result.Value); 85 Assert.Equal(userId, result.Value.UserId); 86 } 87}
Every test is straightforward. No Assert.Throws. No exception type gymnastics. Just check IsSuccess or compare the Error value.
Using the ErrorOr Library A Modern Alternative#
If you do not want to build the Result Pattern from scratch, the ErrorOr library is one of the best options available in the .NET ecosystem right now. It is clean, expressive, and plays beautifully with ASP.NET Core.
Install it:
SHbash1dotnet add package ErrorOr
Define your errors:
C#csharp1public static class OrderErrors 2{ 3 public static readonly Error UserNotFound = 4 Error.NotFound("Order.UserNotFound", "The user does not exist."); 5 6 public static readonly Error PaymentDeclined = 7 Error.Failure("Order.PaymentDeclined", "Your card was declined."); 8 9 public static Error InsufficientStock(string name, int available) => 10 Error.Validation("Order.InsufficientStock", 11 $"Only {available} units of '{name}' are available."); 12}
Use it in a service:
C#csharp1public async Task<ErrorOr<Order>> PlaceOrderAsync(Guid userId, List<CartItem> items) 2{ 3 var user = await _userRepository.GetByIdAsync(userId); 4 if (user is null) 5 return OrderErrors.UserNotFound; // Implicit conversion 6 7 foreach (var item in items) 8 { 9 var product = await _productRepository.GetByIdAsync(item.ProductId); 10 if (product.Stock < item.Quantity) 11 return OrderErrors.InsufficientStock(product.Name, product.Stock); 12 } 13 14 var order = Order.Create(userId, items); 15 await _orderRepository.InsertAsync(order); 16 17 return order; // Implicit conversion to success 18}
Handle it in a Minimal API:
C#csharp1app.MapPost("/api/orders", async (PlaceOrderRequest request, OrderService orderService) => 2{ 3 var result = await orderService.PlaceOrderAsync(request.UserId, request.Items); 4 5 return result.Match( 6 order => Results.Created($"/api/orders/{order.Id}", order), 7 errors => Results.BadRequest(errors)); 8});
The ErrorOr<T> type even supports returning multiple errors at once useful for validation scenarios where you want to report all errors, not just the first one.
Using FluentResults The Full-Featured Option#
FluentResults is another excellent library with more features out of the box.
Install it:
SHbash1dotnet add package FluentResults
Custom error types:
C#csharp1public class UserNotFoundError : Error 2{ 3 public UserNotFoundError(Guid userId) 4 : base($"No user with ID '{userId}' was found.") 5 { 6 Metadata.Add("UserId", userId); 7 Metadata.Add("ErrorCode", "User.NotFound"); 8 } 9} 10 11public class PaymentDeclinedError : Error 12{ 13 public PaymentDeclinedError(string reason) 14 : base($"Payment was declined: {reason}") 15 { 16 Metadata.Add("ErrorCode", "Order.PaymentDeclined"); 17 } 18}
Service using FluentResults:
C#csharp1public async Task<Result<Order>> PlaceOrderAsync(Guid userId, List<CartItem> items) 2{ 3 var user = await _userRepository.GetByIdAsync(userId); 4 if (user is null) 5 return Result.Fail(new UserNotFoundError(userId)); 6 7 var order = Order.Create(userId, items); 8 await _orderRepository.InsertAsync(order); 9 10 return Result.Ok(order); 11}
Controller using FluentResults:
C#csharp1[HttpPost] 2public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request) 3{ 4 var result = await _orderService.PlaceOrderAsync(request.UserId, request.Items); 5 6 if (result.IsFailed) 7 { 8 // Access all errors, not just the first 9 return BadRequest(result.Errors.Select(e => e.Message)); 10 } 11 12 return CreatedAtAction(nameof(GetById), new { id = result.Value.Id }, result.Value); 13}
When NOT to Use the Result Pattern#
The Result Pattern is powerful, but it is not the answer to everything. Here are situations where you should not use it:
1. Truly exceptional situations
If your database is unreachable, throw an exception. That is not a business error you can handle gracefully it is a system failure.
2. Deep internal library code
Inside a library that only your team uses, exceptions are fine for developer errors. The Result Pattern shines at service boundaries between your services, between layers, and at your API surface.
3. Simple CRUD with no business rules
If a method is just fetching a record and returning it, a nullable return type (User?) is perfectly fine. Do not over-engineer.
C#csharp1// This is fine — no need for Result<T> here 2public async Task<User?> GetUserByIdAsync(Guid id) => 3 await _dbContext.Users.FindAsync(id);
Use the Result Pattern when you have explicit business rules that can fail in known, expected ways.
The Complete Pattern Quick Reference#
Here is the full implementation in one place, ready to copy into your project.
Error.cs
C#csharp1public enum ErrorType { Failure, NotFound, Validation, Conflict, Unauthorized } 2 3public sealed record Error(string Code, string Description, ErrorType Type = ErrorType.Failure) 4{ 5 public static readonly Error None = new(string.Empty, string.Empty); 6 public static Error NotFound(string code, string description) => new(code, description, ErrorType.NotFound); 7 public static Error Validation(string code, string description) => new(code, description, ErrorType.Validation); 8 public static Error Conflict(string code, string description) => new(code, description, ErrorType.Conflict); 9 public static Error Unauthorized(string code, string description) => new(code, description, ErrorType.Unauthorized); 10}
Result.cs
C#csharp1public class Result 2{ 3 protected Result(bool isSuccess, Error error) 4 { 5 if (isSuccess && error != Error.None) throw new InvalidOperationException(); 6 if (!isSuccess && error == Error.None) throw new InvalidOperationException(); 7 IsSuccess = isSuccess; 8 Error = error; 9 } 10 public bool IsSuccess { get; } 11 public bool IsFailure => !IsSuccess; 12 public Error Error { get; } 13 public static Result Success() => new(true, Error.None); 14 public static Result Failure(Error error) => new(false, error); 15 public static implicit operator Result(Error error) => Failure(error); 16}
Result<T>.cs
C#csharp1public sealed class Result<T> : Result 2{ 3 private readonly T? _value; 4 private Result(T value) : base(true, Error.None) => _value = value; 5 private Result(Error error) : base(false, error) => _value = default; 6 public T Value => IsSuccess ? _value! : throw new InvalidOperationException("No value on failure."); 7 public static Result<T> Success(T value) => new(value); 8 public static new Result<T> Failure(Error error) => new(error); 9 public static implicit operator Result<T>(T value) => Success(value); 10 public static implicit operator Result<T>(Error error) => Failure(error); 11}
ResultExtensions.cs
C#csharp1public static class ResultExtensions 2{ 3 public static TOut Match<T, TOut>(this Result<T> result, Func<T, TOut> onSuccess, Func<Error, TOut> onFailure) 4 => result.IsSuccess ? onSuccess(result.Value) : onFailure(result.Error); 5 6 public static async Task<TOut> MatchAsync<T, TOut>(this Task<Result<T>> resultTask, Func<T, TOut> onSuccess, Func<Error, TOut> onFailure) 7 { 8 var result = await resultTask; 9 return result.IsSuccess ? onSuccess(result.Value) : onFailure(result.Error); 10 } 11 12 public static IResult ToHttpResult(this Error error) => error.Type switch 13 { 14 ErrorType.NotFound => Results.NotFound(new { error.Code, error.Description }), 15 ErrorType.Validation => Results.BadRequest(new { error.Code, error.Description }), 16 ErrorType.Conflict => Results.Conflict(new { error.Code, error.Description }), 17 ErrorType.Unauthorized => Results.Unauthorized(), 18 _ => Results.Problem(error.Description) 19 }; 20}
The key principles to remember:
- Exceptions are for things you did not expect infrastructure failures, null reference bugs, programming mistakes
- Result Pattern is for things you did expect business rules, validation, not-found scenarios
- Make errors first-class citizens document them in static error classes so they are discoverable
- Use
Matchinstead ofifit forces you to handle both cases - Let the type system do the work implicit conversions keep call sites clean
The beauty of the Result Pattern is not just in cleaner code. It is in the contract it creates. When a method returns Task<Result<Order>>, it is a promise: "I will either give you an Order, or I will give you a specific, documented reason why I could not." That is honest code. That is maintainable code. That is code you will thank yourself for writing six months from now.
Conclusion#
In this article, we have learned about the Result Pattern in .NET from building the Error record and Result<T> class from scratch, to applying it across real-world scenarios like order processing and user registration, to connecting it with ASP.NET Core API endpoints. We have also seen how it dramatically improves testability and explored third-party libraries like ErrorOr and FluentResults.
