The Result Pattern: A Comprehensive Guide to Error Handling in .NET
Introduction
The Result pattern is a functional programming approach to error handling that makes success and failure explicit in the type system. Instead of throwing exceptions and hoping callers remember to catch them, you return an object that represents either a successful result or a failure.
The critical insight: the Result pattern gives value to your exceptions. Not by replacing them — by forcing developers to validate data before moving forward. Consider a CreateInstance() factory method that enforces business rules: if the name is empty, it throws InvalidNameException. If the balance is negative, it throws InsufficientFundsException. These are real domain exceptions with structured context. The Result pattern wraps that factory call and says to the caller: "Before you use this object, prove you've handled the case where creation failed." That's what "giving value" means — every domain exception becomes a mandatory validation checkpoint that the developer must pass through. Exceptions still get thrown inside the factory (that's where business rules live). The Result ensures someone is listening.
This article explores the Result pattern, its implementation in modern .NET applications, and demonstrates how to leverage FrenchExDev.Net.Result to write more robust and maintainable code.
The Problem with Exceptions
Traditional exception-based error handling in C# has several limitations:
1. Hidden Control Flow
public decimal CalculateDiscount(Order order)
{
// Exceptions can be thrown from anywhere, making the flow unclear
return order.Total * 0.1; // What exceptions might this throw?
}Vpublic decimal CalculateDiscount(Order order)
{
// Exceptions can be thrown from anywhere, making the flow unclear
return order.Total * 0.1; // What exceptions might this throw?
}VCallers must remember that methods might throw exceptions, but this isn't always obvious from the method signature.
2. Performance Overhead
Creating exception objects is expensive. In performance-critical code paths, repeatedly throwing and catching exceptions can degrade performance significantly.
3. Unclear Intent
public Order GetOrderById(int id)
{
// Does this throw if not found? Return null? How do I know?
return _repository.FirstOrDefault(o => o.Id == id);
}public Order GetOrderById(int id)
{
// Does this throw if not found? Return null? How do I know?
return _repository.FirstOrDefault(o => o.Id == id);
}4. Exception Handling Complexity
try
{
var user = await _userService.GetUserAsync(userId);
var orders = await _orderService.GetOrdersAsync(user.Id);
var total = CalculateTotal(orders);
}
catch (UserNotFoundException ex)
{
// Handle user not found
}
catch (OrderServiceException ex)
{
// Handle order service error
}
catch (InvalidOperationException ex)
{
// Handle calculation error
}
catch (Exception ex)
{
// Generic error handling
}try
{
var user = await _userService.GetUserAsync(userId);
var orders = await _orderService.GetOrdersAsync(user.Id);
var total = CalculateTotal(orders);
}
catch (UserNotFoundException ex)
{
// Handle user not found
}
catch (OrderServiceException ex)
{
// Handle order service error
}
catch (InvalidOperationException ex)
{
// Handle calculation error
}
catch (Exception ex)
{
// Generic error handling
}Introducing the Result Pattern
The Result pattern encapsulates the outcome of an operation, making it explicit whether the operation succeeded or failed.
Basic Concept
// Marker interface for all results (including generic and non-generic)
public interface IResult
{
bool IsSuccess { get; }
bool IsFailure { get; }
}
// Non-generic result (success / failure)
public sealed record Result : IResult
{
public bool IsSuccess { get; init; }
public bool IsFailure => !IsSuccess;
private Result() { }
public static Result Success() => new() { IsSuccess = true };
public static Result Failure() => new() { IsSuccess = false };
public TMatch Match<TMatch>(Func<TMatch> onSuccess, Func<TMatch> onFailure)
=> IsSuccess ? onSuccess() : onFailure();
public Task<TMatch> MatchAsync<TMatch>(Func<Task<TMatch>> onSuccess, Func<Task<TMatch>> onFailure)
=> IsSuccess ? onSuccess() : onFailure();
// Combine, FromTry and other helpers are available (see below)
}
// Result with a success value and zero or more validation errors
public sealed record Result<T> : IResult where T : notnull
{
public bool IsSuccess { get; init; }
public bool IsFailure => !IsSuccess;
public T? Value { get; init; }
// All validation errors (empty on success)
public IReadOnlyList<ValidationResult> ValidationResults { get; init; } = [];
// Convenience getter for the first error (backward compatible with single-error approaches)
public ValidationResult? ValidationResult => ValidationResults.Count > 0 ? ValidationResults[0] : null;
private Result() { }
public T ValueOrThrow()
{
if (IsFailure)
throw new InvalidOperationException("Cannot get value from a failed result.");
return Value!;
}
public static Result<T> Success(T value) => new() { IsSuccess = true, Value = value };
public static Result<T> Failure(ValidationResult validationResult) =>
new()
{
IsSuccess = false,
ValidationResults = [
new ValidationResult(validationResult.ErrorMessage, validationResult.MemberNames.ToArray())
]
};
internal static Result<T> Failure(IReadOnlyList<ValidationResult> errors) =>
new() { IsSuccess = false, ValidationResults = [..errors] };
public TMatch Match<TMatch>(Func<T, TMatch> onSuccess, Func<IReadOnlyList<ValidationResult>, TMatch> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(ValidationResults);
public Task<TMatch> MatchAsync<TMatch>(Func<T, Task<TMatch>> onSuccess, Func<IReadOnlyList<ValidationResult>, Task<TMatch>> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(ValidationResults);
}
// Result with a typed error — reuse your domain exceptions here
public sealed record Result<TResult, TException> : IResult
where TResult : notnull
where TException : notnull
{
public bool IsSuccess { get; init; }
public bool IsFailure => !IsSuccess;
public TResult? Value { get; init; }
public TException? Error { get; init; }
private Result() { }
public TResult ValueOrThrow()
{
if (IsFailure)
throw new InvalidOperationException($"Cannot get value from a failed result: {Error}");
return Value!;
}
public static Result<TResult, TException> Success(TResult value) => new() { IsSuccess = true, Value = value };
public static Result<TResult, TException> Failure(TException error) => new() { IsSuccess = false, Error = error };
public TMatch Match<TMatch>(Func<TResult, TMatch> onSuccess, Func<TException, TMatch> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(Error!);
public Task<TMatch> MatchAsync<TMatch>(Func<TResult, Task<TMatch>> onSuccess, Func<TException, Task<TMatch>> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}// Marker interface for all results (including generic and non-generic)
public interface IResult
{
bool IsSuccess { get; }
bool IsFailure { get; }
}
// Non-generic result (success / failure)
public sealed record Result : IResult
{
public bool IsSuccess { get; init; }
public bool IsFailure => !IsSuccess;
private Result() { }
public static Result Success() => new() { IsSuccess = true };
public static Result Failure() => new() { IsSuccess = false };
public TMatch Match<TMatch>(Func<TMatch> onSuccess, Func<TMatch> onFailure)
=> IsSuccess ? onSuccess() : onFailure();
public Task<TMatch> MatchAsync<TMatch>(Func<Task<TMatch>> onSuccess, Func<Task<TMatch>> onFailure)
=> IsSuccess ? onSuccess() : onFailure();
// Combine, FromTry and other helpers are available (see below)
}
// Result with a success value and zero or more validation errors
public sealed record Result<T> : IResult where T : notnull
{
public bool IsSuccess { get; init; }
public bool IsFailure => !IsSuccess;
public T? Value { get; init; }
// All validation errors (empty on success)
public IReadOnlyList<ValidationResult> ValidationResults { get; init; } = [];
// Convenience getter for the first error (backward compatible with single-error approaches)
public ValidationResult? ValidationResult => ValidationResults.Count > 0 ? ValidationResults[0] : null;
private Result() { }
public T ValueOrThrow()
{
if (IsFailure)
throw new InvalidOperationException("Cannot get value from a failed result.");
return Value!;
}
public static Result<T> Success(T value) => new() { IsSuccess = true, Value = value };
public static Result<T> Failure(ValidationResult validationResult) =>
new()
{
IsSuccess = false,
ValidationResults = [
new ValidationResult(validationResult.ErrorMessage, validationResult.MemberNames.ToArray())
]
};
internal static Result<T> Failure(IReadOnlyList<ValidationResult> errors) =>
new() { IsSuccess = false, ValidationResults = [..errors] };
public TMatch Match<TMatch>(Func<T, TMatch> onSuccess, Func<IReadOnlyList<ValidationResult>, TMatch> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(ValidationResults);
public Task<TMatch> MatchAsync<TMatch>(Func<T, Task<TMatch>> onSuccess, Func<IReadOnlyList<ValidationResult>, Task<TMatch>> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(ValidationResults);
}
// Result with a typed error — reuse your domain exceptions here
public sealed record Result<TResult, TException> : IResult
where TResult : notnull
where TException : notnull
{
public bool IsSuccess { get; init; }
public bool IsFailure => !IsSuccess;
public TResult? Value { get; init; }
public TException? Error { get; init; }
private Result() { }
public TResult ValueOrThrow()
{
if (IsFailure)
throw new InvalidOperationException($"Cannot get value from a failed result: {Error}");
return Value!;
}
public static Result<TResult, TException> Success(TResult value) => new() { IsSuccess = true, Value = value };
public static Result<TResult, TException> Failure(TException error) => new() { IsSuccess = false, Error = error };
public TMatch Match<TMatch>(Func<TResult, TMatch> onSuccess, Func<TException, TMatch> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(Error!);
public Task<TMatch> MatchAsync<TMatch>(Func<TResult, Task<TMatch>> onSuccess, Func<TException, Task<TMatch>> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}Reuse Your Domain Exceptions — Don't Reinvent Error Types
This is the most important design decision when adopting the Result pattern: TException should be your existing domain exceptions. Don't invent parallel error records. Your exception hierarchy already encodes domain knowledge — use it.
Add a business-specific interface for IDE support
The practical recommendation: define a marker interface on your domain exceptions. When you use that interface as TException, the IDE gives you autocompletion inside onFailure, and you can pattern-match to access specific exception properties:
// Define a business-specific interface on your domain exceptions
public interface IDomainException
{
string Code { get; }
string Message { get; }
}
// Your existing domain exceptions implement it
public class UserNotFoundException : Exception, IDomainException
{
public string Code => "USER_NOT_FOUND";
public int UserId { get; }
public UserNotFoundException(int userId)
: base($"User {userId} not found") => UserId = userId;
}
public class EmailAlreadyRegisteredException : Exception, IDomainException
{
public string Code => "EMAIL_TAKEN";
public string Email { get; }
public EmailAlreadyRegisteredException(string email)
: base($"Email '{email}' is already registered") => Email = email;
}
public class InvalidNameException : Exception, IDomainException
{
public string Code => "INVALID_NAME";
public InvalidNameException(string reason)
: base($"Invalid name: {reason}") { }
}// Define a business-specific interface on your domain exceptions
public interface IDomainException
{
string Code { get; }
string Message { get; }
}
// Your existing domain exceptions implement it
public class UserNotFoundException : Exception, IDomainException
{
public string Code => "USER_NOT_FOUND";
public int UserId { get; }
public UserNotFoundException(int userId)
: base($"User {userId} not found") => UserId = userId;
}
public class EmailAlreadyRegisteredException : Exception, IDomainException
{
public string Code => "EMAIL_TAKEN";
public string Email { get; }
public EmailAlreadyRegisteredException(string email)
: base($"Email '{email}' is already registered") => Email = email;
}
public class InvalidNameException : Exception, IDomainException
{
public string Code => "INVALID_NAME";
public InvalidNameException(string reason)
: base($"Invalid name: {reason}") { }
}Now use IDomainException as TException:
// Result uses the interface — IDE autocompletes Code, Message in onFailure
public Result<User, IDomainException> RegisterUser(RegistrationRequest request)
{
if (_repository.ExistsByEmail(request.Email))
return Result<User, IDomainException>.Failure(
new EmailAlreadyRegisteredException(request.Email));
var user = _repository.FindById(request.UserId);
if (user is null)
return Result<User, IDomainException>.Failure(
new UserNotFoundException(request.UserId));
return Result<User, IDomainException>.Success(user);
}
// Caller: IDE autocompletes .Code, .Message on error
// Pattern matching gives access to specific exception properties
var result = RegisterUser(request);
result.Match(
onSuccess: user => logger.Info($"Registered: {user.Name}"),
onFailure: error =>
{
logger.Warn($"[{error.Code}] {error.Message}"); // IDE autocompletes
switch (error) // Pattern match for structured data
{
case UserNotFoundException notFound:
logger.Warn($"User {notFound.UserId} not found");
break;
case EmailAlreadyRegisteredException emailTaken:
logger.Warn($"Email taken: {emailTaken.Email}");
break;
}
}
);// Result uses the interface — IDE autocompletes Code, Message in onFailure
public Result<User, IDomainException> RegisterUser(RegistrationRequest request)
{
if (_repository.ExistsByEmail(request.Email))
return Result<User, IDomainException>.Failure(
new EmailAlreadyRegisteredException(request.Email));
var user = _repository.FindById(request.UserId);
if (user is null)
return Result<User, IDomainException>.Failure(
new UserNotFoundException(request.UserId));
return Result<User, IDomainException>.Success(user);
}
// Caller: IDE autocompletes .Code, .Message on error
// Pattern matching gives access to specific exception properties
var result = RegisterUser(request);
result.Match(
onSuccess: user => logger.Info($"Registered: {user.Name}"),
onFailure: error =>
{
logger.Warn($"[{error.Code}] {error.Message}"); // IDE autocompletes
switch (error) // Pattern match for structured data
{
case UserNotFoundException notFound:
logger.Warn($"User {notFound.UserId} not found");
break;
case EmailAlreadyRegisteredException emailTaken:
logger.Warn($"Email taken: {emailTaken.Email}");
break;
}
}
);Exceptions at creation boundaries: CreateInstance() and factory methods
"Giving value to exceptions" is most visible at entity creation boundaries — the same DDD factory methods that enforce aggregate invariants. A CreateInstance() factory method enforces business rules — if they fail, it throws a domain exception. The Result wraps the call, forcing the caller to validate the outcome before proceeding:
// Entity with business rules enforced at creation
public class Account
{
public string Name { get; }
public decimal Balance { get; }
private Account(string name, decimal balance)
{
Name = name;
Balance = balance;
}
// Factory method: throws domain exceptions when rules are violated
public static Account CreateInstance(string name, decimal initialBalance)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidNameException("Name cannot be empty");
if (initialBalance < 0)
throw new NegativeBalanceException(initialBalance);
return new Account(name.Trim(), initialBalance);
}
}
// Caller wraps the factory — Result forces handling the failure path
var result = Result.FromTry<Account, IDomainException>(() =>
Account.CreateInstance(request.Name, request.InitialBalance));
result.Match(
onSuccess: account => logger.Info($"Account created: {account.Name}"),
onFailure: error =>
{
// Must handle this before using the account. That's the value.
logger.Warn($"[{error.Code}] {error.Message}");
}
);// Entity with business rules enforced at creation
public class Account
{
public string Name { get; }
public decimal Balance { get; }
private Account(string name, decimal balance)
{
Name = name;
Balance = balance;
}
// Factory method: throws domain exceptions when rules are violated
public static Account CreateInstance(string name, decimal initialBalance)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidNameException("Name cannot be empty");
if (initialBalance < 0)
throw new NegativeBalanceException(initialBalance);
return new Account(name.Trim(), initialBalance);
}
}
// Caller wraps the factory — Result forces handling the failure path
var result = Result.FromTry<Account, IDomainException>(() =>
Account.CreateInstance(request.Name, request.InitialBalance));
result.Match(
onSuccess: account => logger.Info($"Account created: {account.Name}"),
onFailure: error =>
{
// Must handle this before using the account. That's the value.
logger.Warn($"[{error.Code}] {error.Message}");
}
);The exceptions are thrown inside CreateInstance() — that's where business rules belong. The Result catches them and wraps them as values. The caller cannot access the Account without proving they've handled the failure. No forgotten try/catch. No silent null.
Toward source-generated validators
This pattern scales with source generation. Instead of hand-writing validation logic in every CreateInstance(), developers can decorate their entity classes with DSL attributes that specify validation rules and the domain exceptions to throw:
// Developer declares validation rules via attributes (DSL)
[Entity]
public partial class Account
{
[Required(ThrowsException = typeof(InvalidNameException))]
[MaxLength(100, ThrowsException = typeof(InvalidNameException))]
public string Name { get; }
[Range(0, double.MaxValue, ThrowsException = typeof(NegativeBalanceException))]
public decimal Balance { get; }
}
// Source generator emits: Account.CreateInstance(string name, decimal balance)
// with compiled, debuggable validation logic that throws the specified exceptions.
// No runtime reflection. Step through it in the debugger.// Developer declares validation rules via attributes (DSL)
[Entity]
public partial class Account
{
[Required(ThrowsException = typeof(InvalidNameException))]
[MaxLength(100, ThrowsException = typeof(InvalidNameException))]
public string Name { get; }
[Range(0, double.MaxValue, ThrowsException = typeof(NegativeBalanceException))]
public decimal Balance { get; }
}
// Source generator emits: Account.CreateInstance(string name, decimal balance)
// with compiled, debuggable validation logic that throws the specified exceptions.
// No runtime reflection. Step through it in the debugger.The source generator reads the attributes and emits a CreateInstance() method with real if checks and real throw statements — standard C# that compiles, is debuggable, and throws the exact domain exception types the developer specified. The caller wraps it with Result.FromTry<Account, IDomainException>(...) and must handle the failure path.
This is the full picture: DSL attributes define what to validate and which exception to throw → source generator emits compiled validators → Result wraps the call and forces the caller to handle failures → domain exceptions carry structured context through the type system.
When to use Result<T> vs Result<TResult, TException>
| Use case | Type | Why |
|---|---|---|
| Input validation (field errors) | Result<T> |
Multiple ValidationResult errors, not domain exceptions |
| Domain operations (business rules) | Result<T, IDomainException> |
Interface gives IDE support, pattern match for specifics |
Entity creation (CreateInstance()) |
Result<T, IDomainException> |
Wrap factory methods, force callers to handle failures |
| External calls (HTTP, DB, I/O) | Result<T, HttpRequestException> |
Catch at the boundary, wrap as value |
| Single known failure mode | Result<T, SpecificException> |
Maximum type safety |
| Source-generated factories | Result<T, IDomainException> |
Generated validators throw domain exceptions, Result wraps them |
Core Benefits
- Explicit Error Handling: The type system forces you to handle errors
- Clear Intent: Method signatures clearly indicate they may fail
- Composability: Results can be chained using functional operators
- Type Safety: Compile-time guarantees about error handling
- Performance: No exception objects created unless actually needed
Implementation Patterns
Pattern 1: Basic Result with Match
public Result<User> GetUserById(int id)
{
if (id <= 0)
return Result<User>.Failure(new ValidationResult("Invalid user ID"));
var user = _userRepository.FindById(id);
return user == null
? Result<User>.Failure(new ValidationResult($"User with ID {id} not found"))
: Result<User>.Success(user);
}
// Usage
var result = GetUserById(1);
var message = result.Match(
onSuccess: user => $"Found user: {user.Name}",
onFailure: errors => $"Error: {string.Join("; ", errors.Select(e => e.ErrorMessage))}"
);
Console.WriteLine(message);public Result<User> GetUserById(int id)
{
if (id <= 0)
return Result<User>.Failure(new ValidationResult("Invalid user ID"));
var user = _userRepository.FindById(id);
return user == null
? Result<User>.Failure(new ValidationResult($"User with ID {id} not found"))
: Result<User>.Success(user);
}
// Usage
var result = GetUserById(1);
var message = result.Match(
onSuccess: user => $"Found user: {user.Name}",
onFailure: errors => $"Error: {string.Join("; ", errors.Select(e => e.ErrorMessage))}"
);
Console.WriteLine(message);Pattern 2: Accumulate All Errors, Then Combine
Validation methods must go as deep as they can. Check everything, collect all failures, and return them together. Returning on the first failure is an antipattern — the caller gets one error, fixes it, submits again, gets the next error. That's a terrible experience.
public Result<(User User, List<OrderItem> Items)> ValidateOrder(CreateOrderRequest request)
{
// Each validator accumulates its own errors independently
var userResult = ValidateUser(request.UserId);
var itemsResult = ValidateOrderItems(request.Items);
// Combine aggregates ALL errors from ALL validators into one Result
return Result.Combine(userResult, itemsResult);
}
private Result<User> ValidateUser(int userId)
{
var user = _userRepository.FindById(userId);
return user?.IsActive == true
? Result<User>.Success(user)
: Result<User>.Failure(new ValidationResult($"User {userId} is inactive or not found"));
}
private Result<List<OrderItem>> ValidateOrderItems(List<CreateOrderItemRequest> items)
{
// ✗ WRONG: Don't return on first failure
// if (!items.Any())
// return Result<List<OrderItem>>.Failure(new ValidationResult("..."));
// if (items.Sum(...) > 1000)
// return Result<List<OrderItem>>.Failure(new ValidationResult("..."));
// ✓ CORRECT: Accumulate all failures, then decide
var errors = new List<ValidationResult>();
if (!items.Any())
errors.Add(new ValidationResult("Order must contain at least one item"));
if (items.Any(i => i.Quantity <= 0))
errors.Add(new ValidationResult("All items must have a positive quantity"));
if (items.Any(i => i.ProductId <= 0))
errors.Add(new ValidationResult("All items must reference a valid product"));
if (items.Sum(i => i.Quantity) > 1000)
errors.Add(new ValidationResult("Order quantity exceeds maximum limit (1000)"));
if (errors.Count > 0)
return Result<List<OrderItem>>.Failure(errors);
return Result<List<OrderItem>>.Success(
items.Select(i => new OrderItem(i.ProductId, i.Quantity)).ToList());
}public Result<(User User, List<OrderItem> Items)> ValidateOrder(CreateOrderRequest request)
{
// Each validator accumulates its own errors independently
var userResult = ValidateUser(request.UserId);
var itemsResult = ValidateOrderItems(request.Items);
// Combine aggregates ALL errors from ALL validators into one Result
return Result.Combine(userResult, itemsResult);
}
private Result<User> ValidateUser(int userId)
{
var user = _userRepository.FindById(userId);
return user?.IsActive == true
? Result<User>.Success(user)
: Result<User>.Failure(new ValidationResult($"User {userId} is inactive or not found"));
}
private Result<List<OrderItem>> ValidateOrderItems(List<CreateOrderItemRequest> items)
{
// ✗ WRONG: Don't return on first failure
// if (!items.Any())
// return Result<List<OrderItem>>.Failure(new ValidationResult("..."));
// if (items.Sum(...) > 1000)
// return Result<List<OrderItem>>.Failure(new ValidationResult("..."));
// ✓ CORRECT: Accumulate all failures, then decide
var errors = new List<ValidationResult>();
if (!items.Any())
errors.Add(new ValidationResult("Order must contain at least one item"));
if (items.Any(i => i.Quantity <= 0))
errors.Add(new ValidationResult("All items must have a positive quantity"));
if (items.Any(i => i.ProductId <= 0))
errors.Add(new ValidationResult("All items must reference a valid product"));
if (items.Sum(i => i.Quantity) > 1000)
errors.Add(new ValidationResult("Order quantity exceeds maximum limit (1000)"));
if (errors.Count > 0)
return Result<List<OrderItem>>.Failure(errors);
return Result<List<OrderItem>>.Success(
items.Select(i => new OrderItem(i.ProductId, i.Quantity)).ToList());
}The caller sees all problems at once — empty list, invalid quantities, invalid products, limit exceeded — not one at a time. Result.Combine then merges errors from ValidateUser and ValidateOrderItems into a single flat list.
Pattern 3: Async Operations
public async Task<Result<UserProfile>> GetUserProfileAsync(int userId)
{
var errors = new List<ValidationResult>();
if (userId <= 0)
errors.Add(new ValidationResult("Invalid user ID"));
if (errors.Count > 0)
return Result<UserProfile>.Failure(errors);
// Past validation — now fetch data (these are lookups, not validation)
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
return Result<UserProfile>.Failure(
new ValidationResult($"User {userId} not found"));
var posts = await _postRepository.GetUserPostsAsync(userId);
var profile = new UserProfile(user, posts);
return Result<UserProfile>.Success(profile);
}
// Usage
var result = await GetUserProfileAsync(1);
await result.MatchAsync(
onSuccess: async profile =>
{
Console.WriteLine($"User: {profile.User.Name}");
await _cache.SetAsync(profile);
},
onFailure: errors =>
{
// All errors are available at once
_logger.LogError(string.Join("; ", errors.Select(e => e.ErrorMessage)));
return Task.CompletedTask;
}
);public async Task<Result<UserProfile>> GetUserProfileAsync(int userId)
{
var errors = new List<ValidationResult>();
if (userId <= 0)
errors.Add(new ValidationResult("Invalid user ID"));
if (errors.Count > 0)
return Result<UserProfile>.Failure(errors);
// Past validation — now fetch data (these are lookups, not validation)
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
return Result<UserProfile>.Failure(
new ValidationResult($"User {userId} not found"));
var posts = await _postRepository.GetUserPostsAsync(userId);
var profile = new UserProfile(user, posts);
return Result<UserProfile>.Success(profile);
}
// Usage
var result = await GetUserProfileAsync(1);
await result.MatchAsync(
onSuccess: async profile =>
{
Console.WriteLine($"User: {profile.User.Name}");
await _cache.SetAsync(profile);
},
onFailure: errors =>
{
// All errors are available at once
_logger.LogError(string.Join("; ", errors.Select(e => e.ErrorMessage)));
return Task.CompletedTask;
}
);Pattern 4: Helper Extension Methods
The library doesn't include Map/Bind helpers out of the box, but you can add them if they suit your domain:
public static class ResultExtensions
{
public static Result<TOut> Map<TIn, TOut>(
this Result<TIn> result,
Func<TIn, TOut> transform)
where TIn : notnull
where TOut : notnull
{
if (result.IsFailure)
return Result<TOut>.Failure(result.ValidationResults.First());
return Result<TOut>.Success(transform(result.Value!));
}
public static Result<TOut> Bind<TIn, TOut>(
this Result<TIn> result,
Func<TIn, Result<TOut>> transform)
where TIn : notnull
where TOut : notnull
{
if (result.IsFailure)
return Result<TOut>.Failure(result.ValidationResults.First());
return transform(result.Value!);
}
public static T GetOrThrow<T>(this Result<T> result) where T : notnull
=> result.ValueOrThrow();
public static T? GetOrDefault<T>(this Result<T> result, T? defaultValue)
where T : class
=> result.IsSuccess ? result.Value : defaultValue;
}
// Usage
var userResult = GetUserById(1);
var userDto = userResult
.Map(user => new UserDto(user.Id, user.Name))
.GetOrDefault(null);public static class ResultExtensions
{
public static Result<TOut> Map<TIn, TOut>(
this Result<TIn> result,
Func<TIn, TOut> transform)
where TIn : notnull
where TOut : notnull
{
if (result.IsFailure)
return Result<TOut>.Failure(result.ValidationResults.First());
return Result<TOut>.Success(transform(result.Value!));
}
public static Result<TOut> Bind<TIn, TOut>(
this Result<TIn> result,
Func<TIn, Result<TOut>> transform)
where TIn : notnull
where TOut : notnull
{
if (result.IsFailure)
return Result<TOut>.Failure(result.ValidationResults.First());
return transform(result.Value!);
}
public static T GetOrThrow<T>(this Result<T> result) where T : notnull
=> result.ValueOrThrow();
public static T? GetOrDefault<T>(this Result<T> result, T? defaultValue)
where T : class
=> result.IsSuccess ? result.Value : defaultValue;
}
// Usage
var userResult = GetUserById(1);
var userDto = userResult
.Map(user => new UserDto(user.Id, user.Name))
.GetOrDefault(null);API Reference: FrenchExDev.Net.Result
Core types
IResult(marker interface)Result(non-generic success/failure)Result<T>(success value + validation errors list)Result<TResult, TException>(success value + typed domain exception as value)
Key APIs (real implementation)
Result
Result.Success()/Result.Failure()Result.Match(onSuccess, onFailure)Result.MatchAsync(onSuccess, onFailure)Result.Combine(...)(up to 7 values) – combines multipleResult<T>into a single result containing a tuple if all succeed, or aggregates validation errors.Result.FromTry<TResult, TException>(Func<TResult> factory)– catches onlyTExceptionand wraps it as a value inResult<TResult, TException>.Result.FromTryAsync<TResult, TException>(Func<Task<TResult>> factory)– async version ofFromTry.
Result
Result<T>.Success(value)/Result<T>.Failure(ValidationResult)Result<T>.ValueOrThrow()Result<T>.ValidationResults(list ofValidationResult)Result<T>.ValidationResult(first validation error)Result<T>.Match(onSuccess, onFailure)Result<T>.MatchAsync(onSuccess, onFailure)
Result<TResult, TException>
Result<TResult, TException>.Success(value)/Result<TResult, TException>.Failure(error)—TExceptionshould be your existing domain exception (or base class/interface)Result<TResult, TException>.ValueOrThrow()Result<TResult, TException>.Match(onSuccess, onFailure)Result<TResult, TException>.MatchAsync(onSuccess, onFailure)
How FrenchExDev.Net.Result differs from typical Result libraries
- Validation-centric: failures carry a list of
ValidationResultobjects (similar toDataAnnotations) instead of a single error message or exception. - Immutable and thread-safe: all types are immutable (
initproperties) and defensively copy validation lists. - Built-in combinators:
Result.Combine(...)is included for convenience, reducing boilerplate for multi-step validation. - Typed-exception variant:
Result<TResult, TException>wraps your existing domain exceptions as values — no need to invent parallel error hierarchies. - No side-effect helpers: the library avoids built-in
Map/Bindextensions, leaving composition semantics to consumers or higher-level helpers. - Explicit matching:
Match/MatchAsyncmethods are provided on the core types, rather than relying on pattern matching alone.
Best Practices
1. Accumulate All Validation Errors
// ✓ Good: Check everything, accumulate all errors
public Result<Product> ValidateAndGetProduct(int id, string warehouse)
{
var errors = new List<ValidationResult>();
if (id <= 0)
errors.Add(new ValidationResult("Invalid product ID"));
if (string.IsNullOrWhiteSpace(warehouse))
errors.Add(new ValidationResult("Warehouse is required"));
if (errors.Count > 0)
return Result<Product>.Failure(errors);
var product = _productRepository.FindById(id);
return product == null
? Result<Product>.Failure(new ValidationResult($"Product {id} not found in {warehouse}"))
: Result<Product>.Success(product);
}
// ✗ Antipattern: Return on first failure — caller sees one error at a time
public Result<Product> ValidateAndGetProduct(int id, string warehouse)
{
if (id <= 0)
return Result<Product>.Failure(new ValidationResult("Invalid product ID"));
if (string.IsNullOrWhiteSpace(warehouse))
return Result<Product>.Failure(new ValidationResult("Warehouse is required"));
// Caller never sees both errors at once
}// ✓ Good: Check everything, accumulate all errors
public Result<Product> ValidateAndGetProduct(int id, string warehouse)
{
var errors = new List<ValidationResult>();
if (id <= 0)
errors.Add(new ValidationResult("Invalid product ID"));
if (string.IsNullOrWhiteSpace(warehouse))
errors.Add(new ValidationResult("Warehouse is required"));
if (errors.Count > 0)
return Result<Product>.Failure(errors);
var product = _productRepository.FindById(id);
return product == null
? Result<Product>.Failure(new ValidationResult($"Product {id} not found in {warehouse}"))
: Result<Product>.Success(product);
}
// ✗ Antipattern: Return on first failure — caller sees one error at a time
public Result<Product> ValidateAndGetProduct(int id, string warehouse)
{
if (id <= 0)
return Result<Product>.Failure(new ValidationResult("Invalid product ID"));
if (string.IsNullOrWhiteSpace(warehouse))
return Result<Product>.Failure(new ValidationResult("Warehouse is required"));
// Caller never sees both errors at once
}2. Use Meaningful Error Messages
// ✓ Good: Specific, actionable messages
return Result<User>.Failure(new ValidationResult($"User with email '{email}' already exists"));
// ✗ Avoid: Generic messages
return Result<User>.Failure(new ValidationResult("Error"));// ✓ Good: Specific, actionable messages
return Result<User>.Failure(new ValidationResult($"User with email '{email}' already exists"));
// ✗ Avoid: Generic messages
return Result<User>.Failure(new ValidationResult("Error"));3. Wrap Exceptions at System Boundaries
// ✓ Good: Catch the exception type you expect, wrap it as a Result value
var result = await Result.FromTryAsync<Data, HttpRequestException>(async () =>
{
return await _externalApi.FetchDataAsync();
});
// The caller gets the full HttpRequestException — Message, StatusCode, InnerException — all intact
await result.MatchAsync(
onSuccess: data => Task.CompletedTask,
onFailure: ex =>
{
_logger.LogError(ex, "Failed to fetch data: {Message}", ex.Message);
return Task.CompletedTask;
}
);// ✓ Good: Catch the exception type you expect, wrap it as a Result value
var result = await Result.FromTryAsync<Data, HttpRequestException>(async () =>
{
return await _externalApi.FetchDataAsync();
});
// The caller gets the full HttpRequestException — Message, StatusCode, InnerException — all intact
await result.MatchAsync(
onSuccess: data => Task.CompletedTask,
onFailure: ex =>
{
_logger.LogError(ex, "Failed to fetch data: {Message}", ex.Message);
return Task.CompletedTask;
}
);For domain operations, use your own exception types:
// ✓ Good: Domain exceptions carry structured context
var result = Result.FromTry<Account, InsufficientFundsException>(() =>
{
return _accountService.Withdraw(accountId, amount);
});
result.Match(
onSuccess: account => logger.Info($"New balance: {account.Balance}"),
onFailure: ex => logger.Warn(
$"Insufficient funds: requested {ex.RequestedAmount}, available {ex.AvailableBalance}")
);// ✓ Good: Domain exceptions carry structured context
var result = Result.FromTry<Account, InsufficientFundsException>(() =>
{
return _accountService.Withdraw(accountId, amount);
});
result.Match(
onSuccess: account => logger.Info($"New balance: {account.Balance}"),
onFailure: ex => logger.Warn(
$"Insufficient funds: requested {ex.RequestedAmount}, available {ex.AvailableBalance}")
);4. Compose Results for Complex Operations
// ✓ Good: Combine results explicitly
public Result<(User User, List<OrderItem> Items)> CreateOrderContext(CreateOrderRequest request)
{
var userResult = ValidateUser(request.UserId);
var itemsResult = ValidateOrderItems(request.Items);
return Result.Combine(userResult, itemsResult);
}// ✓ Good: Combine results explicitly
public Result<(User User, List<OrderItem> Items)> CreateOrderContext(CreateOrderRequest request)
{
var userResult = ValidateUser(request.UserId);
var itemsResult = ValidateOrderItems(request.Items);
return Result.Combine(userResult, itemsResult);
}5. Handle Success and Failure Explicitly
// ✓ Good: Handle both paths explicitly
var result = ProcessOrder(orderRequest);
result.Match(
onSuccess: order => _logger.LogInformation($"Order created: {order.Id}"),
onFailure: errors => _logger.LogError($"Order creation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}")
);
// ✗ Avoid: Only handling success
if (result.IsSuccess)
{
_logger.LogInformation($"Order: {result.ValueOrThrow().Id}");
}// ✓ Good: Handle both paths explicitly
var result = ProcessOrder(orderRequest);
result.Match(
onSuccess: order => _logger.LogInformation($"Order created: {order.Id}"),
onFailure: errors => _logger.LogError($"Order creation failed: {string.Join("; ", errors.Select(e => e.ErrorMessage))}")
);
// ✗ Avoid: Only handling success
if (result.IsSuccess)
{
_logger.LogInformation($"Order: {result.ValueOrThrow().Id}");
}Real-World Example: User Registration Service
public class UserRegistrationService
{
public async Task<Result<UserRegistrationResponse>> RegisterUserAsync(
UserRegistrationRequest request)
{
// Validate input
var validationResult = ValidateRegistrationRequest(request);
if (validationResult.IsFailure)
return Result<UserRegistrationResponse>.Failure(validationResult.ValidationResult!);
// Check if email exists
var existingUser = await _userRepository.GetByEmailAsync(request.Email);
if (existingUser != null)
return Result<UserRegistrationResponse>.Failure(
new ValidationResult($"Email '{request.Email}' is already registered"));
// Create user
var user = new User
{
Email = request.Email,
Name = request.Name,
PasswordHash = _passwordHasher.Hash(request.Password),
CreatedAt = DateTime.UtcNow
};
var createResult = await Result.FromTryAsync<User, Exception>(async () =>
{
await _userRepository.AddAsync(user);
await _unitOfWork.CommitAsync();
return user;
});
if (createResult.IsFailure)
{
_logger.LogError(createResult.ValidationResult?.ErrorMessage);
return Result<UserRegistrationResponse>.Failure(
new ValidationResult("An error occurred during registration. Please try again later."));
}
// Send verification email (best effort)
var emailResult = await SendVerificationEmailAsync(user);
if (emailResult.IsFailure)
_logger.LogWarning("Failed to send verification email: {Errors}",
string.Join("; ", emailResult.ValidationResults.Select(e => e.ErrorMessage)));
return Result<UserRegistrationResponse>.Success(
new UserRegistrationResponse(user.Id, user.Email, user.Name));
}
private Result<Unit> ValidateRegistrationRequest(UserRegistrationRequest request)
{
var errors = new List<ValidationResult>();
if (string.IsNullOrWhiteSpace(request.Email))
errors.Add(new ValidationResult("Email is required"));
else if (!IsValidEmail(request.Email))
errors.Add(new ValidationResult("Email format is invalid"));
if (string.IsNullOrWhiteSpace(request.Password))
errors.Add(new ValidationResult("Password is required"));
else if (request.Password.Length < 8)
errors.Add(new ValidationResult("Password must be at least 8 characters"));
return errors.Count > 0
? Result<Unit>.Failure(errors)
: Result<Unit>.Success(Unit.Value);
}
private bool IsValidEmail(string email) => /* validation logic */;
private async Task<Result<Unit>> SendVerificationEmailAsync(User user) => /* email logic */;
}public class UserRegistrationService
{
public async Task<Result<UserRegistrationResponse>> RegisterUserAsync(
UserRegistrationRequest request)
{
// Validate input
var validationResult = ValidateRegistrationRequest(request);
if (validationResult.IsFailure)
return Result<UserRegistrationResponse>.Failure(validationResult.ValidationResult!);
// Check if email exists
var existingUser = await _userRepository.GetByEmailAsync(request.Email);
if (existingUser != null)
return Result<UserRegistrationResponse>.Failure(
new ValidationResult($"Email '{request.Email}' is already registered"));
// Create user
var user = new User
{
Email = request.Email,
Name = request.Name,
PasswordHash = _passwordHasher.Hash(request.Password),
CreatedAt = DateTime.UtcNow
};
var createResult = await Result.FromTryAsync<User, Exception>(async () =>
{
await _userRepository.AddAsync(user);
await _unitOfWork.CommitAsync();
return user;
});
if (createResult.IsFailure)
{
_logger.LogError(createResult.ValidationResult?.ErrorMessage);
return Result<UserRegistrationResponse>.Failure(
new ValidationResult("An error occurred during registration. Please try again later."));
}
// Send verification email (best effort)
var emailResult = await SendVerificationEmailAsync(user);
if (emailResult.IsFailure)
_logger.LogWarning("Failed to send verification email: {Errors}",
string.Join("; ", emailResult.ValidationResults.Select(e => e.ErrorMessage)));
return Result<UserRegistrationResponse>.Success(
new UserRegistrationResponse(user.Id, user.Email, user.Name));
}
private Result<Unit> ValidateRegistrationRequest(UserRegistrationRequest request)
{
var errors = new List<ValidationResult>();
if (string.IsNullOrWhiteSpace(request.Email))
errors.Add(new ValidationResult("Email is required"));
else if (!IsValidEmail(request.Email))
errors.Add(new ValidationResult("Email format is invalid"));
if (string.IsNullOrWhiteSpace(request.Password))
errors.Add(new ValidationResult("Password is required"));
else if (request.Password.Length < 8)
errors.Add(new ValidationResult("Password must be at least 8 characters"));
return errors.Count > 0
? Result<Unit>.Failure(errors)
: Result<Unit>.Success(Unit.Value);
}
private bool IsValidEmail(string email) => /* validation logic */;
private async Task<Result<Unit>> SendVerificationEmailAsync(User user) => /* email logic */;
}Integration with ASP.NET Core
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
var result = await _orderService.CreateOrderAsync(request);
return await result.MatchAsync(
onSuccess: order => Task.FromResult<IActionResult>(
CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order)),
onFailure: errors => Task.FromResult<IActionResult>(
BadRequest(new ErrorResponse(string.Join("; ", errors.Select(e => e.ErrorMessage))))));
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)
{
var result = await _orderService.GetOrderAsync(id);
return await result.MatchAsync(
onSuccess: order => Task.FromResult<IActionResult>(Ok(order)),
onFailure: errors => Task.FromResult<IActionResult>(
NotFound(new ErrorResponse(string.Join("; ", errors.Select(e => e.ErrorMessage))))));
}
}[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
var result = await _orderService.CreateOrderAsync(request);
return await result.MatchAsync(
onSuccess: order => Task.FromResult<IActionResult>(
CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order)),
onFailure: errors => Task.FromResult<IActionResult>(
BadRequest(new ErrorResponse(string.Join("; ", errors.Select(e => e.ErrorMessage))))));
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)
{
var result = await _orderService.GetOrderAsync(id);
return await result.MatchAsync(
onSuccess: order => Task.FromResult<IActionResult>(Ok(order)),
onFailure: errors => Task.FromResult<IActionResult>(
NotFound(new ErrorResponse(string.Join("; ", errors.Select(e => e.ErrorMessage))))));
}
}Testing with Results
[TestFixture]
public class OrderServiceTests
{
[Test]
public async Task CreateOrder_WithValidRequest_ReturnsSuccess()
{
// Arrange
var request = new CreateOrderRequest { UserId = 1, Items = /* ... */ };
var service = CreateOrderService();
// Act
var result = await service.CreateOrderAsync(request);
// Assert
Assert.True(result.IsSuccess);
Assert.That(result.ValueOrThrow().Id, Is.GreaterThan(0));
}
[Test]
public async Task CreateOrder_WithInvalidUserId_ReturnsFailure()
{
// Arrange
var request = new CreateOrderRequest { UserId = -1, Items = /* ... */ };
var service = CreateOrderService();
// Act
var result = await service.CreateOrderAsync(request);
// Assert
Assert.False(result.IsSuccess);
Assert.That(result.ValidationResult?.ErrorMessage, Contains.Substring("Invalid user ID"));
}
}[TestFixture]
public class OrderServiceTests
{
[Test]
public async Task CreateOrder_WithValidRequest_ReturnsSuccess()
{
// Arrange
var request = new CreateOrderRequest { UserId = 1, Items = /* ... */ };
var service = CreateOrderService();
// Act
var result = await service.CreateOrderAsync(request);
// Assert
Assert.True(result.IsSuccess);
Assert.That(result.ValueOrThrow().Id, Is.GreaterThan(0));
}
[Test]
public async Task CreateOrder_WithInvalidUserId_ReturnsFailure()
{
// Arrange
var request = new CreateOrderRequest { UserId = -1, Items = /* ... */ };
var service = CreateOrderService();
// Act
var result = await service.CreateOrderAsync(request);
// Assert
Assert.False(result.IsSuccess);
Assert.That(result.ValidationResult?.ErrorMessage, Contains.Substring("Invalid user ID"));
}
}Comparison: Result vs. Exceptions vs. Try-Catch
| Aspect | Exceptions | Try-Catch | Result Pattern |
|---|---|---|---|
| Syntax | Implicit throws | Verbose | Explicit |
| Type Safety | No | No | Yes |
| Performance | High overhead | Moderate | Low overhead |
| Readability | Can be obscured | Verbose | Clear |
| Composability | Limited | Limited | Excellent |
| Learning Curve | Easy | Moderate | Moderate |
| Error Context | Variable | Captured | Explicit |
Conclusion
The Result pattern provides a powerful, type-safe approach to error handling in .NET applications. Three takeaways:
Add a business-specific interface (
IDomainException) to your domain exceptions. Use it asTExceptioninResult<T, IDomainException>for IDE autocompletion inonFailure, and pattern-match for specific exception types when you need their structured data.Give value to your exceptions by enforcing them at creation boundaries. Factory methods like
CreateInstance()throw domain exceptions when business rules fail.Result.FromTrywraps the call and forces the caller to handle the failure before using the object. That's the validation checkpoint.Scale with source generation. Instead of hand-writing every
CreateInstance(), decorate entity properties with DSL attributes that specify the validation rules and which domain exceptions to throw. The source generator emits compiled, debuggable validator code. The caller wraps it with Result. The entire chain — from attribute to exception to Result toMatch— is type-safe, IDE-friendly, and visible in the debugger.
When implemented consistently with FrenchExDev.Net.Result, it leads to:
- More maintainable code through explicit error handling
- Better performance by avoiding stack unwinding overhead
- Improved composability enabling functional programming patterns
- Enhanced testability with clear success/failure paths
- Type-safe error handling enforced at compile time
- Zero wasted domain modeling — your existing exceptions work as-is
- Source generation ready — DSL attributes define rules, generators emit validators, Result wraps the call
The pattern is particularly valuable in:
- Domain-Driven Design with rich entity validation
- Source-generated factory methods and validators
- Microservices and distributed systems
- Performance-critical applications
- APIs where error information is essential
By adopting the Result pattern, teams can build more resilient, maintainable, and predictable .NET applications.