[TOC]
When developing modern applications, handling operation outcomes effectively presents several challenges:
- Inconsistent Error Handling: Different parts of the application may handle errors in different ways, leading to inconsistent error reporting and handling.
- Context Loss: Important error context and details can be lost when exceptions are caught and rethrown up the call stack.
- Mixed Concerns: Business logic errors often get mixed with technical exceptions, making it harder to handle each appropriately.
- Pagination Complexity: Managing paginated data with associated metadata adds complexity to result handling.
- Type Safety: Maintaining type safety while handling both successful and failed operations can be challenging.
- Error Propagation: Propagating errors through multiple layers of the application while preserving context.
The Result pattern implementation provides a comprehensive solution by:
- Providing a standardized way to handle operation outcomes
- Encapsulating success/failure status, messages, and errors in a single object
- Supporting generic result types for operations that return values
- Offering specialized support for paginated results
- Enabling strongly-typed error handling
- Maintaining immutability with a fluent interface design
A type-safe Result pattern implementation for explicit success/failure handling with optional functional extensions.
The Result pattern consists of three primary classes in hierarchy: Result
provides base
success/failure tracking with message and error collections, ResultT
adds generic type support for
strongly-typed value handling, and ResultPagedT
extends this for collection scenarios with
pagination metadata. Each maintains a fluent interface with factory methods (Success()
,
Failure()
). Error handling is supported through IResultError
, enabling custom error types across
the hierarchy.
The pattern can be enhanced with optional functional extensions like Map/Bind
for transformations,
Tap
for side effects, and Filter/Unless
for conditionals and more.
See Appendix B: Functional Extensions for an overview
of the available functional operations.
classDiagram
class IResultError {
<<interface>>
+string Message
}
class ResultErrorBase {
<<abstract>>
+string Message
+ResultErrorBase(string message)
}
class IResult {
<<interface>>
+IReadOnlyList Messages
+IReadOnlyList Errors
+bool IsSuccess
+bool IsFailure
+bool HasError()
}
class IResultT {
<<interface>>
+T Value
}
class Result {
-List messages
-List errors
-bool success
+bool IsSuccess
+bool IsFailure
+Result WithMessage(string)
+Result WithMessages(IEnumerable)
+Result WithError(IResultError)
+Result WithErrors(IEnumerable)
+Result For()
+static Result Success()
+static Result Failure()
}
class ResultT {
-List messages
-List errors
-bool success
+T Value
+ResultT WithMessage(string)
+ResultT WithMessages(IEnumerable)
+ResultT WithError(IResultError)
+ResultT WithErrors(IEnumerable)
+Result For()
+static ResultT Success(T value)
+static ResultT Failure()
}
class ResultPagedT {
+int CurrentPage
+int TotalPages
+long TotalCount
+int PageSize
+bool HasPreviousPage
+bool HasNextPage
+static ResultPagedT Success(IEnumerable, long, int, int)
+static ResultPagedT Failure()
}
IResultError <|.. ResultErrorBase
IResult <|-- IResultT
IResult <|.. Result
IResultT <|.. ResultT
ResultT <|-- ResultPagedT
Result ..> IResultError : uses
ResultT ..> IResultError : uses
ResultPagedT ..> IResultError : uses
IResult ..> IResultError : contains
The Result pattern is particularly useful in the following scenarios:
- Service Layer Operations
- Handling business rule validations
- Processing complex operations with multiple potential failure points
- Returning domain-specific errors
- Data Access Operations
- Managing database operations
- Handling entity not found scenarios
- Dealing with validation errors
- API Endpoints
- Returning paginated data
- Handling complex operation outcomes
- Providing detailed error information
- Complex Workflows
- Managing multi-step processes
- Handling conditional operations
- Aggregating errors from multiple sources
// Creating success results
var success = Result.Success();
var successWithMessage = Result.Success("Operation completed successfully");
// Creating failure results
var failure = Result.Failure("Operation failed")
.WithError<ValidationError>();
// Checking result status
if (success.IsSuccess)
{
// Handle success
}
if (failure.IsFailure)
{
// Handle failure
}
// Error handling
if (failure.HasError<ValidationError>())
{
// Handle specific error type
}
// Paged result
var resultPaged = ResultPagedT<Item>.Success(
items, totalCount: 100, page: 1, pageSize: 10
);
public class UserService
{
private readonly IUserRepository repository;
public UserService(IUserRepository repository)
{
this.repository = repository;
}
public Result<User> GetUserById(int id)
{
var user = this.repository.FindById(id);
if (user == null)
{
return Result<User>.Failure()
.WithError<NotFoundError>()
.WithMessage($"User {id} not found");
}
return Result<User>.Success(user);
}
}
Imperative programming expresses logic as a sequence of explicit steps and checks. Declarative programming describes the desired outcome. In the Result pattern, imperative style requires explicit success checks and error handling, while declarative style creates a clean pipeline of operations using the functional extensions.
// IMPERATIVE STYLE (traditional)
public Result<UserDto> ProcessRegistration(UserRequest request)
{
var validationResult = ValidateUser(request); // Validate user
if (validationResult.IsFailure)
return Result<UserDto>.Failure(validationResult.Errors);
var user = new User(request); // Create user
var saveResult = SaveUser(user);
if (saveResult.IsFailure)
return Result<UserDto>.Failure(saveResult.Errors);
var emailResult = SendWelcomeEmail(user); // Send email
if (emailResult.IsFailure)
return Result<UserDto>.Failure(emailResult.Errors);
return Result<UserDto>.Success(user.ToDto());
}
// DECLARATIVE STYLE (with functional extensions)
public Result<UserDto> ProcessRegistration(UserRequest request) =>
Result<UserRequest>.Success(request)
.Bind(ValidateUser)
.Map(req => new User(req))
.Bind(SaveUser)
.Tap(SendWelcomeEmail)
.Map(user => user.ToDto());
public class ValidationError : ResultErrorBase
{
public ValidationError(string message) : base(message)
{
}
}
public class ValidationService
{
public Result ValidateData(DataModel model)
{
var result = Result.Success();
if (!this.IsValid(model))
{
result = Result.Failure()
.WithError(new ValidationError("Invalid input"))
.WithMessage("Validation failed");
}
// Check for specific errors
if (result.HasError<ValidationError>())
{
// Handle validation error
}
if (result.HasError<ValidationError>(out var validationErrors))
{
foreach (var error in validationErrors)
{
Console.WriteLine(error.Message);
}
}
return result;
}
private bool IsValid(DataModel model)
{
// Validation logic
return true;
}
}
public class WorkflowService
{
public Result ProcessWorkflow()
{
var result = Result.Success()
.WithMessage("Step 1 completed")
.WithMessage("Step 2 completed");
var messages = new[] { "Process started", "Process completed" };
result = Result.Success()
.WithMessages(messages);
foreach (var message in result.Messages)
{
Console.WriteLine(message);
}
return result;
}
}
public class ProductService
{
private readonly IProductRepository repository;
private readonly ILogger<ProductService> logger;
public ProductService(
IProductRepository repository,
ILogger<ProductService> logger)
{
this.repository = repository;
this.logger = logger;
}
public async Task<ResultPaged<Product>> GetProductsAsync(int page = 1, int pageSize = 10)
{
try
{
var totalCount = await this.repository.CountAsync();
var products = await this.repository.GetPageAsync(page, pageSize);
return ResultPaged<Product>.Success(
products,
totalCount,
page,
pageSize);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to get products");
return ResultPaged<Product>.Failure()
.WithError(new ExceptionError(ex));
}
}
}
public class ValidationResultError : ResultErrorBase
{
public ValidationResultError(string field, string message)
: base($"Validation failed for {field}: {message}")
{
}
}
public class UnauthorizedResultError : ResultErrorBase
{
public UnauthorizedResultError()
: base("User is not authorized to perform this action")
{
}
}
public class OrderService
{
private readonly IAuthService authService;
private readonly IOrderRepository orderRepository;
public OrderService(
IAuthService authService,
IOrderRepository orderRepository)
{
this.authService = authService;
this.orderRepository = orderRepository;
}
public Result<Order> CreateOrder(OrderRequest request)
{
if (!this.authService.CanCreateOrders())
{
return Result<Order>.Failure<UnauthorizedResultError>();
}
if (request.Quantity <= 0)
{
return Result<Order>.Failure()
.WithError(new ValidationResultError("Quantity", "Must be greater than zero"));
}
var order = this.orderRepository.Create(request);
return Result<Order>.Success(order, "Order created successfully");
}
}
public class DataService
{
private readonly IDataRepository repository;
private readonly ILogger<DataService> logger;
public DataService(
IDataRepository repository,
ILogger<DataService> logger)
{
this.repository = repository;
this.logger = logger;
}
public Result<Data> GetData()
{
try
{
var data = this.repository.FetchData();
return Result<Data>.Success(data);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Failed to fetch data");
return Result<Data>.Failure()
.WithError(new ExceptionError(ex))
.WithMessage("Failed to fetch data");
}
}
}
- Early Returns: Return failures as soon as possible to avoid unnecessary processing.
public class OrderProcessor
{
public Result<Order> ProcessOrder(OrderRequest request)
{
if (request == null)
{
return Result<Order>.Failure("Request cannot be null");
}
if (!request.IsValid)
{
return Result<Order>.Failure("Invalid request");
}
// Continue processing
return Result<Order>.Success(new Order(request));
}
}
- Meaningful Messages: Include context in error messages.
public Result ProcessOrderById(int orderId, string status)
{
return Result.Failure($"Failed to process order {orderId}: Invalid status {status}");
}
- Type-Safe Errors: Use strongly-typed errors for better error handling.
public class OrderNotFoundError : ResultErrorBase
{
public OrderNotFoundError(int orderId)
: base($"Order {orderId} not found")
{
}
}
public class UserRepository
{
private readonly DbContext dbContext;
public UserRepository(DbContext dbContext)
{
this.dbContext = dbContext;
}
public Result<User> GetById(int id)
{
try
{
var user = this.dbContext.Users.FindById(id);
if (user == null)
{
return Result<User>.Failure<NotFoundResultError>();
}
return Result<User>.Success(user);
}
catch (Exception ex)
{
return Result<User>.Failure()
.WithError(new ExceptionError(ex))
.WithMessage($"Failed to retrieve user {id}");
}
}
}
public class UserService
{
private readonly IUserRepository repository;
private readonly IValidator<User> validator;
private readonly IMapper mapper;
public UserService(
IUserRepository repository,
IValidator<User> validator,
IMapper mapper)
{
this.repository = repository;
this.validator = validator;
this.mapper = mapper;
}
public Result<UserDto> UpdateUser(int id, UpdateUserRequest request)
{
var getUserResult = this.repository.GetById(id);
if (getUserResult.IsFailure)
{
return Result<UserDto>.Failure()
.WithErrors(getUserResult.Errors)
.WithMessages(getUserResult.Messages);
}
var user = getUserResult.Value;
user.Update(request);
var validationResult = this.validator.Validate(user);
if (!validationResult.IsValid)
{
return Result<UserDto>.Failure()
.WithError(new ValidationResultError("User", validationResult.Error));
}
var savedUser = this.repository.Save(user);
return Result<UserDto>.Success(this.mapper.ToDto(savedUser));
}
}
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService productService;
public ProductsController(IProductService productService)
{
this.productService = productService;
}
[HttpGet]
public async Task<IActionResult> GetProducts([FromQuery] int page = 1, [FromQuery] int pageSize = 10)
{
var result = await this.productService.GetProductsAsync(page, pageSize);
if (result.IsFailure)
{
return this.BadRequest(new
{
Errors = result.Errors.Select(e => e.Message),
Messages = result.Messages
});
}
return this.Ok(new
{
Data = result.Value,
Pagination = new
{
result.CurrentPage,
result.TotalPages,
result.TotalCount,
result.HasNextPage,
result.HasPreviousPage
}
});
}
}
public class OrderProcessor
{
private readonly IOrderRepository orderRepository;
private readonly IInventoryService inventoryService;
private readonly IPaymentService paymentService;
private readonly INotificationService notificationService;
private readonly IValidator<OrderRequest> validator;
private readonly ILogger<OrderProcessor> logger;
public OrderProcessor(
IOrderRepository orderRepository,
IInventoryService inventoryService,
IPaymentService paymentService,
INotificationService notificationService,
IValidator<OrderRequest> validator,
ILogger<OrderProcessor> logger)
{
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
this.paymentService = paymentService;
this.notificationService = notificationService;
this.validator = validator;
this.logger = logger;
}
public async Task<Result<Order>> ProcessOrderAsync(OrderRequest request)
{
// Validate request
var validationResult = await this.ValidateOrderRequestAsync(request);
if (validationResult.IsFailure)
{
return Result<Order>.Failure()
.WithErrors(validationResult.Errors)
.WithMessage("Order validation failed");
}
// Reserve inventory
var inventoryResult = await this.ReserveInventoryAsync(request.Items);
if (inventoryResult.IsFailure)
{
return Result<Order>.Failure()
.WithErrors(inventoryResult.Errors)
.WithMessage("Inventory reservation failed");
}
try
{
// Process payment
var paymentResult = await this.ProcessPaymentAsync(request.Payment);
if (paymentResult.IsFailure)
{
// Rollback inventory reservation
await this.ReleaseInventoryAsync(request.Items);
return Result<Order>.Failure()
.WithErrors(paymentResult.Errors)
.WithMessage("Payment processing failed");
}
// Create order
var order = await this.CreateOrderAsync(request, paymentResult.Value);
if (order.IsFailure)
{
// Rollback payment and inventory
await this.ReversePaymentAsync(paymentResult.Value);
await this.ReleaseInventoryAsync(request.Items);
return Result<Order>.Failure()
.WithErrors(order.Errors)
.WithMessage("Order creation failed");
}
// Send notifications
await this.notificationService.SendOrderConfirmationAsync(order.Value);
return Result<Order>.Success(order.Value)
.WithMessage("Order processed successfully");
}
catch (Exception ex)
{
this.logger.LogError(ex, "Unexpected error during order processing");
return Result<Order>.Failure()
.WithError(new ExceptionError(ex))
.WithMessage("An unexpected error occurred while processing the order");
}
}
private async Task<Result> ValidateOrderRequestAsync(OrderRequest request)
{
try
{
var validationResult = await this.validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return Result.Failure()
.WithError(new ValidationResultError("Order", validationResult.Message));
}
return Result.Success();
}
catch (Exception ex)
{
this.logger.LogError(ex, "Validation error");
return Result.Failure()
.WithError(new ExceptionError(ex));
}
}
private async Task<Result<InventoryReservation>> ReserveInventoryAsync(IEnumerable<OrderItem> items)
{
try
{
return await this.inventoryService.ReserveItemsAsync(items);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Inventory reservation error");
return Result<InventoryReservation>.Failure()
.WithError(new ExceptionError(ex));
}
}
private async Task<Result<PaymentTransaction>> ProcessPaymentAsync(PaymentDetails payment)
{
try
{
var transaction = await this.paymentService.ProcessAsync(payment);
if (transaction.IsFailure)
{
this.logger.LogWarning("Payment failed: {Message}", transaction.Messages.FirstOrDefault());
}
return transaction;
}
catch (Exception ex)
{
this.logger.LogError(ex, "Payment processing error");
return Result<PaymentTransaction>.Failure()
.WithError(new ExceptionError(ex));
}
}
private async Task<Result<Order>> CreateOrderAsync(OrderRequest request, PaymentTransaction transaction)
{
try
{
var order = new Order(request, transaction);
return await this.orderRepository.InsertResultAsync(order);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Order creation error");
return Result<Order>.Failure()
.WithError(new ExceptionError(ex));
}
}
private async Task ReleaseInventoryAsync(IEnumerable<OrderItem> items)
{
try
{
await this.inventoryService.ReleaseReservationAsync(items);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Error releasing inventory");
}
}
private async Task ReversePaymentAsync(PaymentTransaction transaction)
{
try
{
await this.paymentService.ReverseTransactionAsync(transaction);
}
catch (Exception ex)
{
this.logger.LogError(ex, "Error reversing payment");
}
}
}
This example demonstrates:
- Proper Error Handling
- Each operation returns a
Result
orResult<T>
- Errors are properly propagated and transformed
- Rollback operations are performed when needed
- Clean Code Practices
- Follows the .editorconfig standards
- Proper use of dependency injection
- Clear separation of concerns
- Consistent error handling
- Workflow Management
- Sequential processing with proper validation
- Rollback mechanisms for failed operations
- Proper logging at each step
- Result Pattern Usage
- Consistent use of Result objects
- Proper error aggregation
- Clear success/failure paths
- Resource Management
- Proper cleanup in case of failures
- Structured error handling
- Comprehensive logging
The DevKit provides extension methods for repositories that don't natively support the Result pattern. These extensions wrap standard repository operations in Result objects, providing consistent error handling and operation results across your application.
- Read-Only Repository Extensions (
GenericReadOnlyRepositoryResultExtensions
):
- Count operations
- Find operations
- Paged query operations
- Repository Extensions (
GenericRepositoryResultExtensions
):
- Insert operations
- Update operations
- Upsert operations
- Delete operations
public class UserService
{
private readonly IGenericRepository<User> _repository;
// Insert with Result
public async Task<Result<User>> CreateUserAsync(User user)
{
return await _repository.InsertResultAsync(user);
}
// Update with Result
public async Task<Result<User>> UpdateUserAsync(User user)
{
return await _repository.UpdateResultAsync(user);
}
// Delete with Result
public async Task<Result<RepositoryActionResult>> DeleteUserAsync(int id)
{
return await _repository.DeleteByIdResultAsync(id);
}
}
public class ProductService
{
private readonly IGenericReadOnlyRepository<Product> _repository;
// Count with Result
public async Task<Result<long>> GetProductCountAsync()
{
return await _repository.CountResultAsync();
}
// Find One with Result
public async Task<Result<Product>> GetProductByIdAsync(int id)
{
return await _repository.FindOneResultAsync(id);
}
// Find All Paged with Result
public async Task<ResultPaged<Product>> GetProductsPagedAsync(
int page = 1,
int pageSize = 10)
{
return await _repository.FindAllResultPagedAsync(
ordering: "Name ascending",
page: page,
pageSize: pageSize);
}
}
public class OrderService
{
private readonly IGenericReadOnlyRepository<Order> _repository;
// Complex query with specifications
public async Task<ResultPaged<Order>> GetOrdersAsync(
FilterModel filterModel,
IEnumerable<ISpecification<Order>> additionalSpecs = null)
{
return await _repository.FindAllResultPagedAsync(
filterModel,
additionalSpecs);
}
// Query with includes
public async Task<Result<Order>> GetOrderWithDetailsAsync(int id)
{
return await _repository.FindOneResultAsync(
id,
options: new FindOptions<Order>
{
Include = new IncludeOption<Order>(o => o.OrderItems)
});
}
}
The extensions automatically handle exceptions and wrap them in Result objects:
public class InventoryService
{
private readonly IGenericRepository<Inventory> _repository;
public async Task<Result<Inventory>> UpdateInventoryAsync(Inventory inventory)
{
var result = await _repository.UpdateResultAsync(inventory);
if (result.IsFailure)
{
// Check for specific errors
if (result.HasError<ExceptionError>())
{
// Handle database exception
_logger.LogError(result.Errors.First().Message);
}
}
return result;
}
}
- Consistent Usage: Use these extensions throughout the application for consistent error handling:
// Instead of:
try {
var product = await _repository.FindOneAsync(id);
return product;
}
catch (Exception ex) {
// Handle error
}
// Use:
var result = await _repository.FindOneResultAsync(id);
return result;
- Combining Operations: Chain repository operations while maintaining error handling:
public async Task<Result<Order>> ProcessOrderAsync(Order order)
{
// Check inventory
var inventoryResult = await _inventoryRepository
.FindOneResultAsync(order.ProductId);
if (inventoryResult.IsFailure)
return inventoryResult.For<Order>(); // convert to Order result
// Insert order
var orderResult = await _orderRepository
.InsertResultAsync(order);
return orderResult;
}
- Paged Queries with Specifications:
public async Task<ResultPaged<Product>> SearchProductsAsync(
string searchTerm,
int page = 1,
int pageSize = 10)
{
var specification = new Specification<Product>(p => p.Name.Contains(searchTerm));
return await _repository.FindAllResultPagedAsync(specification);
}
- Filtering with model:
public async Task<ResultPaged<Order>> GetOrdersAsync(FilterModel filterModel)
{
var specifications = new List<ISpecification<Order>>
{
new Specification<Order>(o => o.Status == OrderStatus.Active)
};
return await _repository.FindAllResultPagedAsync(filterModel, specifications);
}
These extensions provide a seamless way to integrate the Result pattern with existing repository implementations, ensuring consistent error handling and operation results across your application.
Composable, type-safe operations for elegant Result error handling and flow control.
The functional programming extensions transform the Result pattern from a simple success/failure
container into a powerful composition tool. By providing fluent, chainable operations like Map
,
Bind
, and Match
, complex workflows can be expressed as a series of small, focused
transformations. This approach eliminates nested error handling, reduces complexity, and makes the
code's intent clearer.
Each operation maintains the Result context, automatically handling error propagation and ensuring type safety throughout the chain. The pattern enables both synchronous and asynchronous operations, side effects, and validations while keeping the core business logic clean and maintainable. This functional style particularly shines in handling complex flows where multiple operations must be composed together, each potentially failing, with proper error context preserved throughout the chain.
Essential value transformations and validations ensuring Result integrity.
- Map: Transform success value into different type
await result.MapAsync(async (user, ct) => await LoadUserPreferencesAsync(user));
- Bind: Chain Results together, preserving errors
await result.BindAsync(async (user, ct) => await ValidateAndTransformUserAsync(user));
- Ensure: Verify condition on success value
await result.EnsureAsync(
async (user, ct) => await CheckUserStatusAsync(user),
new Error("Invalid status"));
- Try: Wrap async operation in Result handling exceptions
await Result<User>.TryAsync(async ct => await repository.GetUserAsync(userId, ct));
- Validate: Validate collection of values using FluentValidation
await result.ValidateAsync(new UserValidator(),
strategy => strategy.IncludeRuleSets("Create"));
Execute operations without changing Result value.
- Tap: Execute side effect on success value
await result.TapAsync(async (user, ct) => await _cache.StoreUserAsync(user));
- TeeMap: Transform and execute side effect
await result.TeeMapAsync(
user => user.ToDto(),
async (dto, ct) => await _cache.StoreDtoAsync(dto));
- Do: Execute action regardless of Result state
await result.DoAsync(async ct => await InitializeSystemAsync(ct));
- AndThen: Chain operations preserving original value
await result.AndThenAsync(async (user, ct) => await ValidateUserAsync(user));
Conditional logic and alternative paths.
- Filter: Convert to failure if predicate fails
await result.FilterAsync(
async (user, ct) => await HasValidSubscriptionAsync(user),
new SubscriptionError("Invalid subscription"));
- Unless: Convert to failure if predicate succeeds
await result.UnlessAsync(
async (user, ct) => await IsBlacklistedAsync(user),
new BlacklistError("Blacklisted"));
- OrElse: Provide fallback value on failure
await result.OrElseAsync(
async ct => await LoadDefaultUserAsync());
- Switch: Execute conditional side effect
await result.SwitchAsync(
user => user.IsAdmin,
async (user, ct) => await NotifyAdminLoginAsync(user));
Value mappings and collection handling.
- BiMap: Transform both success and failure cases
await result.BiMap(
user => new UserDto(user),
errors => errors.Select(e => new PublicError(e)));
- Choose: Filter optional values
await result.ChooseAsync(async (user, ct) =>
user.IsActive ? Option<UserDto>.Some(new UserDto(user)) : Option<UserDto>.None());
- Collect: Transform collection preserving all errors
await result.CollectAsync(async (user, ct) => await ValidateUserAsync(user));
Success/failure case handling.
- Match: Handle success/failure with async functions
await result.MatchAsync(
async (user, ct) => await ProcessUserAsync(user),
async (errors, ct) => await HandleErrorsAsync(errors));
- MatchAsync (mixed): Handle with mix of sync/async functions
await result.MatchAsync(
async (user, ct) => await ProcessUserAsync(user),
errors => "Processing failed");
- Match (values): Return different values for success/failure
await result.Match(
success: "Valid",
failure: "Invalid");
Clean validation, external integration and transformation flow with automatic error propagation.
The chain processes a list of persons through a series of validations and transformations.
Starting with input validation (age checks, location requirements), it transforms the data (email
normalization), interacts with external services (email notifications), and performs final
validations (database checks). Each operation in the chain either transforms the data or validates
it, with errors propagating automatically through the chain (ResultT
).
The functional style ensures that if any step fails, subsequent operations are skipped and the error context is preserved. The chain concludes by matching the result to either a success or failure message.
flowchart TB
Input[Result List<Person>] --> A
subgraph "Initial Validation"
A[Do - Log Start] --> B[Validate persons]
B --> C[Ensure age >= 18]
C --> E[Filter locations]
end
subgraph "Data Transformation"
E --> F[Map emails lowercase]
end
subgraph "External Operations"
F --> H[AndThenAsync send emails]
end
subgraph "Final Validation"
H --> K[EnsureAsync DB check]
end
K --> Final[Match result to string]
%% Side Effects
subgraph "Side Effects System"
direction LR
Email[Email Service]
DB[Database]
end
H -.-> Email
K -.-> DB
var people = new List<Person>{ personA, personB };
var result = await Result<List>.Success(people)
.Do(() => logger.LogInformation("Starting person processing"))
.Validate(validator)
.Ensure(
persons => persons.All(p => p.Age >= 18),
new Error("All persons must be adults"))
.Filter(
persons => persons.All(p => p.Locations.Any()),
new Error("All persons must have at least one location"))
.Map(persons => persons.Select(p => {
var email = EmailAddress.Create(p.Email.Value.ToLowerInvariant());
return new Person(p.FirstName, p.LastName, email, p.Age, p.Locations);
}).ToList())
.AndThenAsync(async (persons, ct) =>
await emailService.SendWelcomeEmailAsync(persons),
CancellationToken.None)
.EnsureAsync(async (persons, ct) =>
await database.PersonsExistAsync(persons),
new Error("Not all persons were saved correctly"))
.Match(
list => $"Successfully processed {list.Count} persons",
errors => $"Processing failed: {string.Join(", ", errors)}"
);
Guidance to creating and initializing Result instances.
// Basic success with value
var result1 = Result<int>.Success(42);
// Success with message
var result2 = Result<User>.Success(user, "User created successfully");
// Success with multiple messages
var result3 = Result<Order>.Success(
order,
new[] { "Order validated", "Payment processed" });
// Empty success (default value)
var result4 = Result<List<string>>.Success();
// Basic failure (default value)
var result1 = Result<User>.Failure();
// Failure with specific error type
var result2 = Result<Order>.Failure<ValidationError>();
// Failure with value
var result3 = Result<int>.Failure(42);
// Failure with message
var result4 = Result<User>.Failure("User creation failed");
// Failure with value and message
var result5 = Result<User>.Failure(user, "Validation failed");
// Failure with error instance
var result6 = Result<Order>.Failure()
.WithError(new ValidationError("Invalid order"));
// Failure with messages and errors
var result7 = Result<Product>.Failure(
new[] { "Validation failed", "Invalid price" },
new IResultError[]
{
new ValidationError("price", "Must be positive"),
new DomainError("Invalid product state")
});
// Success if condition is met
var result1 = Result<User>.SuccessIf(
user.Age >= 18,
user,
new ValidationError("Must be 18 or older"));
// Success if predicate is satisfied
var result2 = Result<Order>.SuccessIf(
order => order.Total > 0,
order,
new ValidationError("Order total must be positive"));
// Failure if condition is met
var result3 = Result<Product>.FailureIf(
product.Stock == 0,
product,
new OutOfStockError());
// Failure if predicate is satisfied
var result4 = Result<User>.FailureIf(
user => user.IsBlacklisted,
user,
new ValidationError("User is blacklisted"));
// Wrap synchronous operation
var result1 = Result<User>.For(() =>
userRepository.GetById(userId));
// Wrap async operation
var result2 = await Result<Order>.ForAsync(async () =>
await orderRepository.GetByIdAsync(orderId));
// Wrap operation with error handling
var result3 = Result<decimal>.For(() =>
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive");
return CalculateDiscount(amount);
});
// Wrap async operation with cancellation
var result4 = await Result<List<Product>>.ForAsync(async ct =>
await productRepository.GetAllAsync(ct),
cancellationToken);
// Convert to non-generic Result
Result baseResult = Result<int>.Success(42);
// Convert to different Result<T> type
var result1 = userResult.For<UserDto>();
// Convert with new value
var result2 = orderResult.For(orderDto);
// Implicit conversion to bool
bool isSuccess = Result<User>.Success(user);
// Implicit conversion from value
Result<int> result3 = 42; // Creates successful result
// Implicit conversion from IResult<T>
IResult<User> interfaceResult = GetUser();
Result<User> result4 = interfaceResult;
The Either type represents a value that can be one of two different types. Unlike Result, which specifically handles success and failure states, Either provides a more general mechanism for working with two distinct types. This makes it particularly valuable in scenarios where an operation might produce different but equally valid outcomes.
The type brings several advantages to your codebase. It enforces type safety by eliminating the need for type casting and null checks. It follows functional programming principles, offering immutable value semantics and composable operations. Furthermore, it integrates seamlessly with the Result type when you need to transition between different error handling approaches.
Either shines in scenarios where an operation can produce two different but valid result types. For instance, when parsing data that could be either numeric or textual, or when an API might return different response types based on certain conditions. It's particularly useful when you need type-safe handling of alternatives and want to avoid the pitfalls of null checking or type casting.
However, Either isn't always the right choice. When you're primarily concerned with success and failure scenarios, the Result type is more appropriate. Similarly, if one of your types represents an error state, Result provides better semantics for that use case. Either also isn't suitable when you need to handle more than two types or when a simple null check would suffice.
The Either type provides multiple ways to create and handle values:
// Create Either from first type
Either<int, string> numericCase = 42;
Either<int, string> firstCase = Either<int, string>.FromFirst(42);
// Create Either from second type
Either<int, string> textCase = "Hello";
Either<int, string> secondCase = Either<int, string>.FromSecond("Hello");
// Check which type is contained
if (result.IsFirst) { }
if (result.IsSecond) { }
// Safe access to values
int number = result.FirstValue; // Throws if not first type
string text = result.SecondValue; // Throws if not second type
The Either type provides comprehensive pattern matching capabilities through Match and Switch operations. These methods ensure type-safe handling of both possible values:
// Basic matching
string result = either.Match(
firstValue => $"Number: {firstValue}",
secondValue => $"Text: {secondValue}");
// Async matching
await either.MatchAsync(
async (number, ct) => await ProcessNumberAsync(number),
async (text, ct) => await ProcessTextAsync(text));
// Action matching with Switch
either.Switch(
number => Console.WriteLine($"Got number: {number}"),
text => Console.WriteLine($"Got text: {text}"));
// Match with transformations
var numericEither = Either<int, string>.FromFirst(42);
var result = numericEither.Match(
num => num * 2,
text => int.Parse(text));
// Switch for side effects
var executed = false;
numericEither.Switch(
num => executed = true,
_ => throw new Exception("Should not execute"));
// Async match operations
await either.MatchAsync(
async (number, ct) => await ProcessNumberAsync(number),
async (text, ct) => await ProcessTextAsync(text));
// Async switch with cancellation
await either.SwitchAsync(
async (num, ct) => {
await Task.Delay(100, ct);
return ProcessNumber(num);
},
async (text, ct) => {
await Task.Delay(100, ct);
return ProcessText(text);
});
Either provides built-in support for handling operations that might fail:
// Basic try operation
var parsed = Either<int, Exception>.Try(() => int.Parse("42"));
// Async try with potential failure
var result = await Either<Data, Exception>.TryAsync(
async () => await FetchDataAsync());
// Filter operations
var filtered = either.Filter(
num => num > 0,
"Number must be positive");
Either integrates naturally with the Result type, allowing you to transition between the two approaches when needed:
// Converting Either to Result
Result<int> result = either.ToResult(
firstMatch: num => num,
secondMatch: text => int.Parse(text),
error => new ValidationError(error));
// Using Try operations
Either<int, Exception> parsed = Either<int, Exception>
.Try(() => int.Parse("42"));
// Async operations
var result = await Either<int, Exception>
.TryAsync(async () => await GetValueAsync());
// Convert to Result with custom error
var resultWithError = either.ToResult(
firstMatch: num => num.ToString(),
secondMatch: err => "0",
errorFactory: err => new CustomError(err));
// Convert with simple error message
var simpleResult = either.ToResult(
firstMatch: num => num,
errorMessage: "Conversion failed");
Here's how Either can be used in practical scenarios:
The Either type provides elegant handling of different API response types:
public class ApiExample
{
public Either<SuccessResponse, ErrorDetails> CallApi()
{
try
{
var response = api.Call();
return response.IsSuccess
? new SuccessResponse(response)
: new ErrorDetails(response.Error);
}
catch (Exception ex)
{
return new ErrorDetails(ex);
}
}
public async Task ProcessApiCall()
{
var result = await CallApi()
.MatchAsync(
async success => await ProcessSuccess(success),
async error => await HandleError(error));
}
}
Either can elegantly handle different outcomes in data processing scenarios:
public class DataProcessor
{
public Either<ParsedData, ValidationErrors> ProcessInput(string input)
{
return ValidateInput(input)
.Match(
valid => ParsedData.Create(valid),
errors => new ValidationErrors(errors));
}
public void HandleData(string input)
{
ProcessInput(input)
.Switch(
parsed => SaveToDatabase(parsed),
errors => LogValidationErrors(errors));
}
}
When working with Either, focus on using it for truly bifurcated scenarios where both types represent valid outcomes. Always handle both cases through pattern matching to ensure type-safe operations. Consider async operations when dealing with I/O or time-consuming processes.
Avoid using Either for simple boolean conditions or null checks, as these scenarios are better served by simpler constructs. Don't use Either when you need to handle more than two types, and avoid throwing exceptions in Either handlers as this defeats its purpose of type-safe handling.
Either is implemented as a value type, providing thread-safety and immutability by design. It's memory-efficient and supports both synchronous and asynchronous operations. The implementation ensures that you can't accidentally access the wrong type without explicitly handling both cases, providing robust type safety at compile time.