Table of Contents

Error Handling

This guide covers error types, discriminated matching, and transformation patterns in Railway Oriented Programming.

using FunctionalDdd;
using System.Collections.Immutable;

Table of Contents

Error Types

Built-in error types map to HTTP status codes and common business scenarios:

Error Type HTTP Status Use Case Example
ValidationError 400 Bad Request Input validation failures Invalid email format, required field missing
BadRequestError 400 Bad Request General request errors Malformed request
UnauthorizedError 401 Unauthorized Authentication required Missing token, invalid credentials
ForbiddenError 403 Forbidden Insufficient permissions User cannot access resource
NotFoundError 404 Not Found Resource doesn't exist User not found, order not found
ConflictError 409 Conflict Resource state conflict Duplicate email, concurrent update
DomainError 422 Unprocessable Entity Business rule violation Cannot withdraw more than balance
RateLimitError 429 Too Many Requests Rate limit exceeded Too many login attempts
UnexpectedError 500 Internal Server Error System errors Database connection failed
ServiceUnavailableError 503 Service Unavailable Service temporarily down Service under maintenance
AggregateError Varies Multiple errors combined Multiple validation failures or mixed error types

Error Structure

All errors share a common structure:

public class Error
{
    public string Code { get; }        // Machine-readable error code
    public string Detail { get; }      // Human-readable error description
    public string? Instance { get; }   // Optional resource identifier
}

// Factory methods create specific error types:
Error.Validation(...)     // ValidationError
Error.NotFound(...)       // NotFoundError
Error.Conflict(...)       // ConflictError
Error.BadRequest(...)     // BadRequestError
Error.Unauthorized(...)   // UnauthorizedError
Error.Forbidden(...)      // ForbiddenError
Error.Unexpected(...)     // UnexpectedError
Error.Domain(...)         // DomainError
Error.RateLimit(...)      // RateLimitError
Error.ServiceUnavailable(...) // ServiceUnavailableError

Creating Errors

Validation Errors

// Simple validation error for a single field
var error = Error.Validation("Email is required", "email");

// Results in:
// Code: "validation.error"
// Detail: "Email is required"
// FieldErrors: [{ FieldName: "email", Details: ["Email is required"] }]

// Multiple validation errors using fluent API (see ValidationError Fluent API section)
var error = ValidationError.For("email", "Email is required")
    .And("password", "Password must be at least 8 characters")
    .And("age", "Must be 18 or older");

// Multiple errors for the same field
var error = Error.Validation("Invalid email format", "email");
var error2 = Error.Validation("Email domain not allowed", "email");
var combined = error.Combine(error2);
// Results in single ValidationError with multiple details for "email" field

Not Found Errors

// Simple not found
var error = Error.NotFound($"User {userId} not found");
// Code: "not.found.error"
// Detail: "User {userId} not found"
// Instance: null

// With instance identifier
var error = Error.NotFound(
    $"User with ID {userId} does not exist",
    userId.ToString()
);
// Code: "not.found.error"
// Detail: "User with ID {userId} does not exist"
// Instance: "{userId}"

Authorization Errors

// Unauthorized (authentication required - user not logged in)
var error = Error.Unauthorized("Authentication token missing");
// Code: "unauthorized.error"
// Maps to HTTP 401

// Forbidden (insufficient permissions - user logged in but lacks access)
var error = Error.Forbidden("User does not have permission to delete orders");
// Code: "forbidden.error"
// Maps to HTTP 403

Conflict Errors

// Resource conflict
var error = Error.Conflict($"Email {email} is already registered");

// Concurrent update conflict
var error = Error.Conflict("Resource was modified by another user");

Domain Errors

// Business rule violations
var error = Error.Domain("Cannot withdraw more than account balance");

// With instance identifier
var error = Error.Domain(
    "Order quantity exceeds available inventory",
    orderId.ToString()
);

Rate Limit Errors

// Rate limit exceeded
var error = Error.RateLimit("Too many login attempts. Try again in 60 seconds");

// With retry information
var error = Error.RateLimit("API rate limit exceeded. Retry after 60 seconds");

Service Unavailable Errors

// Temporary service unavailability
var error = Error.ServiceUnavailable("Payment service is temporarily unavailable");

// With maintenance window
var error = Error.ServiceUnavailable("System under maintenance until 2:00 AM UTC");

Unexpected Errors

// System errors
var error = Error.Unexpected("Database connection failed");

// From exception
try
{
    // risky operation
}
catch (Exception ex)
{
    return Result.Failure<Data>(
        Error.Unexpected($"Unexpected error: {ex.Message}")
    );
}

// Or use Result.Try to automatically convert exceptions
var result = Result.Try(() => RiskyOperation());

Discriminated Error Matching

The MatchError method allows you to handle different error types with specific logic:

Basic Error Matching

var httpResult = ProcessOrder(order)
    .MatchError(
        onValidation: validationErr => 
            Results.BadRequest(new { 
                errors = validationErr.FieldErrors
                    .ToDictionary(f => f.FieldName, f => f.Details.ToArray())
            }),
        onNotFound: notFoundErr => 
            Results.NotFound(new { message = notFoundErr.Detail }),
        onConflict: conflictErr => 
            Results.Conflict(new { message = conflictErr.Detail }),
        onSuccess: order => 
            Results.Ok(order)
    );

Complete Error Matching

Handle all error types explicitly:

return await ProcessTransactionAsync(transaction)
    .MatchError(
        onValidation: err => 
            Results.BadRequest(new { 
                message = err.Detail,
                errors = err.FieldErrors
                    .ToDictionary(f => f.FieldName, f => f.Details.ToArray())
            }),
        onBadRequest: err =>
            Results.BadRequest(new { message = err.Detail }),
        onNotFound: err => 
            Results.NotFound(new { message = err.Detail }),
        onUnauthorized: err => 
            Results.Unauthorized(),
        onForbidden: err => 
            Results.StatusCode(403),
        onConflict: err => 
            Results.Conflict(new { message = err.Detail }),
        onDomain: err =>
            Results.UnprocessableEntity(new { message = err.Detail }),
        onRateLimit: err =>
            Results.StatusCode(429),
        onServiceUnavailable: err =>
            Results.StatusCode(503),
        onUnexpected: err => 
            Results.StatusCode(500),
        onSuccess: transaction => 
            Results.Ok(new { transactionId = transaction.Id })
    );

Partial Error Matching

You don't need to handle every error type - provide an onError fallback for unhandled types:

var result = CreateUser(userData)
    .MatchError(
        onValidation: err => Results.BadRequest(err.FieldErrors),
        onConflict: err => Results.Conflict(err.Detail),
        onError: err => Results.StatusCode(500), // Fallback for all other error types
        onSuccess: user => Results.Created($"/users/{user.Id}", user)
    );

Note: If you don't provide handlers for some error types and no onError fallback, MatchError will throw InvalidOperationException when it encounters an unhandled error type.

Switch Error Matching (Side Effects Only)

Use SwitchError when you only need side effects without returning a value:

ProcessOrder(order)
    .SwitchError(
        onValidation: err => _logger.LogWarning("Validation failed: {Errors}", err.FieldErrors),
        onNotFound: err => _logger.LogWarning("Order not found: {Detail}", err.Detail),
        onConflict: err => _logger.LogWarning("Order conflict: {Detail}", err.Detail),
        onSuccess: order => _logger.LogInformation("Order processed: {OrderId}", order.Id)
    );

Error Side Effects

TapError - Execute Side Effects on Failure

Use TapError to perform side effects (like logging) when an error occurs without changing the result:

var result = ProcessOrder(order)
    .TapError(error => _logger.LogError("Order processing failed: {Error}", error.Detail))
    .TapError(error => _metrics.RecordFailure(error.Code))
    .TapError(error => _notificationService.NotifyAdmin(error));

// TapError only executes on failure
// On success, TapError is skipped

Combined Tap and TapError

var result = ProcessPayment(order)
    .Tap(payment => _logger.LogInformation("Payment succeeded: {Id}", payment.Id))
    .TapError(error => _logger.LogError("Payment failed: {Error}", error.Detail))
    .TapError(error => SendFailureNotification(error))
    .Tap(payment => SendSuccessEmail(payment));

Async TapError

var result = await ProcessOrderAsync(order)
    .TapErrorAsync(async error => 
        await _auditLog.LogFailureAsync(error, cancellationToken))
    .TapErrorAsync(async error => 
        await _notificationService.NotifyAsync(error, cancellationToken),
        cancellationToken);

Error Transformation

Transform errors as they flow through your pipeline:

MapError - Transform Error Types

var result = GetUserFromExternalApi(userId)
    .MapError(error => error switch
    {
        NotFoundError => Error.NotFound(
            "User not found in our system",
            userId
        ),
        UnexpectedError => Error.ServiceUnavailable(
            "External service is temporarily unavailable"
        ),
        _ => error
    });

Add Context to Errors

var result = ProcessPayment(order)
    .MapError(error => Error.Unexpected(
        $"Payment processing failed for order {order.Id}: {error.Detail}",
        $"order-{order.Id}"
    ));

Compensate - Error Recovery

var result = GetUserFromCache(userId)
    .Compensate(cacheError => 
        GetUserFromDatabase(userId)
            .MapError(dbError => Error.NotFound(
                $"User {userId} not found. Cache: {cacheError.Detail}, DB: {dbError.Detail}",
                userId
            ))
    );

Aggregate Errors

When combining multiple Results, errors are intelligently aggregated based on their types:

Validation Error Merging

When combining multiple ValidationError instances, they are merged into a single ValidationError with all field errors:

var emailError = Error.Validation("Email is required", "email");
var passwordError = Error.Validation("Password is required", "password");
var ageError = Error.Validation("Must be 18 or older", "age");

var result = emailError.Combine(passwordError).Combine(ageError);

// Result: Single ValidationError with 3 field errors
// FieldErrors:
//   - email: ["Email is required"]
//   - password: ["Password is required"]
//   - age: ["Must be 18 or older"]

Mixed Error Types Create AggregateError

When combining ValidationError with other error types (or combining different non-validation error types), an AggregateError is created:

var validationError = Error.Validation("Invalid email", "email");
var notFoundError = Error.NotFound("User not found");
var conflictError = Error.Conflict("Email already exists");

var result = validationError.Combine(notFoundError).Combine(conflictError);

// Result: AggregateError containing 3 separate errors
// Errors:
//   - ValidationError: Invalid email (email field)
//   - NotFoundError: User not found
//   - ConflictError: Email already exists

Automatic Aggregation with Combine

var result = EmailAddress.TryCreate(email)
    .Combine(FirstName.TryCreate(firstName))
    .Combine(LastName.TryCreate(lastName));

// If all succeed: Result<(EmailAddress, FirstName, LastName)>
// If any fail with ValidationError: Single merged ValidationError
// If failures include non-validation errors: AggregateError

// Handling aggregated errors
if (result.IsFailure)
{
    if (result.Error is ValidationError validation)
    {
        // All errors were validation errors - merged into one
        foreach (var fieldError in validation.FieldErrors)
        {
            Console.WriteLine($"{fieldError.FieldName}: {string.Join(", ", fieldError.Details)}");
        }
    }
    else if (result.Error is AggregateError aggregate)
    {
        // Mixed error types or multiple non-validation errors
        foreach (var error in aggregate.Errors)
        {
            Console.WriteLine($"{error.GetType().Name}: {error.Detail}");
        }
    }
    else
    {
        // Single error type
        Console.WriteLine($"{result.Error.Detail}");
    }
}

Manual Error Aggregation

var errors = new List<Error>();

if (string.IsNullOrEmpty(email))
    errors.Add(Error.Validation("Email is required", "email"));

if (age < 18)
    errors.Add(Error.Validation("Must be 18 or older", "age"));

if (errors.Any())
{
    // Combine all errors into one
    var combinedError = errors.Aggregate((acc, err) => acc.Combine(err));
    return Result.Failure<User>(combinedError);
}

return Result.Success(new User(email, age));

ValidationError Fluent API

ValidationError provides a fluent API for building multi-field validation errors:

Building Multi-Field Validation Errors

// Start with one field, then chain with And()
var error = ValidationError.For("email", "Email is required")
    .And("password", "Password must be at least 8 characters")
    .And("password", "Password must contain a number")  // Same field, multiple errors
    .And("age", "Must be 18 or older");

// Results in single ValidationError with field errors:
// - email: ["Email is required"]
// - password: ["Password must be at least 8 characters", "Password must contain a number"]
// - age: ["Must be 18 or older"]

Adding Multiple Messages to One Field

// Add multiple validation messages for a single field at once
var error = ValidationError.For("email", "Email is required")
    .And("password", 
        "Must be at least 8 characters",
        "Must contain a number",
        "Must contain a special character");

// Results in:
// - email: ["Email is required"]
// - password: ["Must be at least 8 characters", "Must contain a number", "Must contain a special character"]

Merging Validation Errors

var emailValidation = ValidationError.For("email", "Invalid format");
var passwordValidation = ValidationError.For("password", "Too short")
    .And("password", "Not complex enough");

var merged = emailValidation.Merge(passwordValidation);

// Results in single ValidationError:
// - email: ["Invalid format"]
// - password: ["Too short", "Not complex enough"]

Using Combine for Automatic Merging

// Combine automatically merges ValidationErrors
var error1 = Error.Validation("Email required", "email");
var error2 = Error.Validation("Password required", "password");
var error3 = Error.Validation("Password too short", "password");

var combined = error1.Combine(error2).Combine(error3);

// Results in single ValidationError:
// - email: ["Email required"]
// - password: ["Password required", "Password too short"]

Async Error Handling

Handle errors in async workflows with full cancellation support:

Async MatchError

return await ProcessOrderAsync(orderId, cancellationToken)
    .MatchErrorAsync(
        onValidation: async (err, ct) =>
        {
            await LogValidationFailureAsync(err, ct);
            return Results.BadRequest(err.FieldErrors);
        },
        onNotFound: async (err, ct) =>
        {
            await NotifyNotFoundAsync(err, ct);
            return Results.NotFound(err.Detail);
        },
        onSuccess: async (order, ct) =>
        {
            await SendConfirmationAsync(order, ct);
            return Results.Ok(order);
        },
        cancellationToken: cancellationToken
    );

Async SwitchError

await ProcessPaymentAsync(payment, cancellationToken)
    .SwitchErrorAsync(
        onValidation: async (err, ct) => 
            await LogErrorAsync("Validation failed", err, ct),
        onUnexpected: async (err, ct) =>
            await NotifyAdminAsync("Payment system error", err, ct),
        onSuccess: async (result, ct) =>
            await AuditSuccessAsync(result, ct),
        cancellationToken: cancellationToken
    );

Async TapError with CancellationToken

var result = await GetUserAsync(userId, cancellationToken)
    .TapErrorAsync(
        async (error, ct) => await LogErrorAsync(error, ct),
        cancellationToken
    )
    .TapErrorAsync(
        async (error, ct) => await NotifyAdminAsync(error, ct),
        cancellationToken
    );

Async MapError

var result = await FetchDataAsync(id, cancellationToken)
    .MapErrorAsync(
        async (error, ct) =>
        {
            await LogErrorDetailsAsync(error, ct);
            return Error.ServiceUnavailable("External service unavailable");
        },
        cancellationToken
    );

Custom Error Types

While the built-in error types cover most scenarios, you can extend the system:

Custom Error Factory Methods

public static class CustomErrors
{
    public static RateLimitError RateLimitExceeded(int retryAfterSeconds)
    {
        return Error.RateLimit(
            $"Too many requests. Please try again after {retryAfterSeconds} seconds."
        );
    }

    public static DomainError PaymentDeclined(string reason)
    {
        return Error.Domain(
            $"Payment declined: {reason}"
        );
    }
    
    public static ValidationError InvalidCreditCard(string fieldName)
    {
        return Error.Validation(
            "Credit card number is invalid",
            fieldName
        );
    }
}

// Usage
if (requestCount > limit)
    return Result.Failure<Response>(
        CustomErrors.RateLimitExceeded(retryAfterSeconds: 60)
    );

Domain-Specific Errors

public static class OrderErrors
{
    public static Error InsufficientInventory(ProductId productId, int requested, int available)
    {
        return Error.Conflict(
            $"Product {productId} has insufficient inventory. Requested: {requested}, Available: {available}",
            productId.Value
        );
    }

    public static Error OrderAlreadyShipped(OrderId orderId)
    {
        return Error.Conflict(
            $"Order {orderId} has already been shipped and cannot be modified",
            orderId.Value
        );
    }
    
    public static Error PaymentAmountMismatch(decimal expected, decimal actual)
    {
        return Error.Domain(
            $"Payment amount mismatch. Expected: {expected:C}, Received: {actual:C}"
        );
    }
}

// Usage
if (inventory.Available < order.Quantity)
{
    return Result.Failure<Order>(
        OrderErrors.InsufficientInventory(
            order.ProductId, 
            order.Quantity, 
            inventory.Available
        )
    );
}

Best Practices

  1. Use Specific Error Types: Choose the most specific error type (NotFound vs Validation vs Domain)
  2. Include Context in Instance: Use the instance parameter for resource identifiers
  3. Consistent Error Codes: Use consistent, meaningful error codes across your app
  4. Handle Errors at Boundaries: Use MatchError at API boundaries to convert to HTTP responses
  5. Don't Swallow Errors: Always propagate or handle errors explicitly
  6. Use Aggregate for Multiple Errors: Return all validation errors at once, not just the first one
  7. Use TapError for Logging: Add TapError calls to log failures without breaking the chain
  8. Leverage Fluent API: Use ValidationError.For().And() for building multi-field validations
  9. Add Tracing IDs: Include correlation IDs in error instance for distributed tracing
  10. Use MapError Sparingly: Only transform errors when you need to add context or change error types

Next Steps