You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Error Handling Patterns

Compare exceptions with result-based error modeling approaches

Error Handling Patterns in C#

Master robust error handling with free flashcards and spaced repetition practice. This lesson covers exception strategies, the Result pattern, Railway-Oriented Programming, and functional error handling techniquesβ€”essential concepts for building resilient C# applications that gracefully handle failures.

Welcome πŸ’»

Error handling is one of the most critical aspects of professional software development. Poor error handling leads to crashes, data corruption, security vulnerabilities, and frustrated users. In C#, we have multiple paradigms for handling errors: traditional try-catch exceptions, functional Result types, Railway-Oriented Programming, and modern pattern matching.

This lesson will transform how you think about errorsβ€”from "exceptional circumstances" to "expected outcomes" that should be explicitly modeled in your code. You'll learn when to use exceptions versus Result types, how to compose error-handling logic functionally, and how to create robust, maintainable error handling strategies.

Core Concepts 🎯

Exception-Based Error Handling

Exceptions are the traditional C# mechanism for error handling. When something goes wrong, you throw an exception, which unwinds the call stack until it's caught by a catch block.

public class Customer
{
    public void UpdateEmail(string newEmail)
    {
        if (string.IsNullOrWhiteSpace(newEmail))
            throw new ArgumentException("Email cannot be empty", nameof(newEmail));
        
        if (!IsValidEmail(newEmail))
            throw new ArgumentException("Invalid email format", nameof(newEmail));
        
        Email = newEmail;
    }
}

When to use exceptions:

  • βœ… True exceptional circumstances (disk full, network failure, hardware error)
  • βœ… Violations of invariants or contracts
  • βœ… Situations where the caller cannot reasonably be expected to check beforehand
  • ❌ NOT for control flow (e.g., parsing user input)
  • ❌ NOT for expected failure scenarios

πŸ’‘ Tip: Exceptions are expensive! They involve stack unwinding, building stack traces, and disrupting normal control flow. Use them judiciously.

The Problem with Exceptions

Exceptions have several drawbacks:

  1. Hidden control flow: Methods don't declare what exceptions they might throw (unlike Java's checked exceptions)
  2. Performance cost: Creating and throwing exceptions is slow
  3. Invisible in signatures: public User GetUser(int id) doesn't tell you it might throw NotFoundException
  4. Composition difficulties: Hard to chain operations that might fail
EXCEPTION CONTROL FLOW

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Method A    β”‚
β”‚  calls B    │──┐
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚ Method B    β”‚  β”‚
β”‚  calls C    │───
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚ Method C    β”‚  β”‚
β”‚  throws!    β”‚β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       ↓
    ⚠️ UNWIND!
       ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Catch in A  β”‚
β”‚ or Program  β”‚
β”‚ crashes     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Result Pattern 🎁

The Result pattern makes errors explicit in method signatures. Instead of throwing exceptions, methods return a Result<T> type that can be either success with a value or failure with an error.

public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public string Error { get; }
    
    private Result(bool isSuccess, T value, string error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }
    
    public static Result<T> Success(T value) => 
        new Result<T>(true, value, null);
    
    public static Result<T> Failure(string error) => 
        new Result<T>(false, default(T), error);
}

Advantages of Result pattern:

  • βœ… Errors are visible in method signatures
  • βœ… No performance penalty
  • βœ… Forces callers to handle errors
  • βœ… Composable with LINQ and functional patterns
  • βœ… Predictable control flow

Usage example:

public Result<int> ParseAge(string input)
{
    if (string.IsNullOrWhiteSpace(input))
        return Result<int>.Failure("Age cannot be empty");
    
    if (!int.TryParse(input, out int age))
        return Result<int>.Failure("Age must be a valid number");
    
    if (age < 0 || age > 150)
        return Result<int>.Failure("Age must be between 0 and 150");
    
    return Result<int>.Success(age);
}

// Usage
var result = ParseAge(userInput);
if (result.IsSuccess)
{
    Console.WriteLine($"Age: {result.Value}");
}
else
{
    Console.WriteLine($"Error: {result.Error}");
}

Enhanced Result with Error Types πŸ”

For more sophisticated error handling, use typed errors instead of strings:

public abstract class Error
{
    public string Message { get; }
    protected Error(string message) => Message = message;
}

public class ValidationError : Error
{
    public ValidationError(string message) : base(message) { }
}

public class NotFoundError : Error
{
    public NotFoundError(string message) : base(message) { }
}

public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public Error Error { get; }
    
    public static Result<T> Success(T value) => 
        new Result<T> { IsSuccess = true, Value = value };
    
    public static Result<T> Failure(Error error) => 
        new Result<T> { IsSuccess = false, Error = error };
}

This allows pattern matching on error types:

var result = GetUser(userId);
var message = result.Error switch
{
    ValidationError e => $"Invalid input: {e.Message}",
    NotFoundError e => $"User not found: {e.Message}",
    _ => "An unknown error occurred"
};

Railway-Oriented Programming πŸš‚

Railway-Oriented Programming (ROP) is a functional pattern that treats your program like a railroad with two tracks: the success track and the failure track. Once you're on the failure track, you stay there.

RAILWAY-ORIENTED PROGRAMMING

SUCCESS TRACK:  ──[Op1]──[Op2]──[Op3]──[Op4]── βœ… Result
                     β”‚      β”‚      β”‚      β”‚
                     ↓      ↓      ↓      ↓
FAILURE TRACK:  ────────────────────────────── ❌ Error

Once you switch to failure track, you stay there!

Implement this with extension methods:

public static class ResultExtensions
{
    // Bind: chains operations that return Result
    public static Result<TOut> Bind<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, Result<TOut>> func)
    {
        return result.IsSuccess 
            ? func(result.Value) 
            : Result<TOut>.Failure(result.Error);
    }
    
    // Map: transforms the value inside a successful Result
    public static Result<TOut> Map<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, TOut> func)
    {
        return result.IsSuccess 
            ? Result<TOut>.Success(func(result.Value)) 
            : Result<TOut>.Failure(result.Error);
    }
    
    // Match: handles both success and failure cases
    public static TOut Match<TIn, TOut>(
        this Result<TIn> result,
        Func<TIn, TOut> onSuccess,
        Func<Error, TOut> onFailure)
    {
        return result.IsSuccess 
            ? onSuccess(result.Value) 
            : onFailure(result.Error);
    }
}

Pipeline example:

public Result<Order> ProcessOrder(OrderRequest request)
{
    return ValidateRequest(request)
        .Bind(CheckInventory)
        .Bind(CalculatePrice)
        .Bind(ChargeCustomer)
        .Map(CreateOrderRecord);
}

// Each step returns Result<T>
// If any step fails, the rest are skipped
// Error propagates to the end automatically

This is declarative and composableβ€”you describe what should happen, and the railway infrastructure handles the error flow.

Either Type for Dual Outcomes βš–οΈ

The Either type is a generalization of Result that can hold one of two values: Left (typically error) or Right (typically success).

public class Either<TLeft, TRight>
{
    private readonly TLeft _left;
    private readonly TRight _right;
    private readonly bool _isRight;
    
    private Either(TLeft left)
    {
        _left = left;
        _isRight = false;
    }
    
    private Either(TRight right)
    {
        _right = right;
        _isRight = true;
    }
    
    public static Either<TLeft, TRight> Left(TLeft value) => 
        new Either<TLeft, TRight>(value);
    
    public static Either<TLeft, TRight> Right(TRight value) => 
        new Either<TLeft, TRight>(value);
    
    public TOut Match<TOut>(
        Func<TLeft, TOut> leftFunc,
        Func<TRight, TOut> rightFunc)
    {
        return _isRight ? rightFunc(_right) : leftFunc(_left);
    }
}

// Usage with validation
public Either<ValidationError, User> CreateUser(string email, int age)
{
    if (age < 18)
        return Either<ValidationError, User>.Left(
            new ValidationError("Must be 18 or older"));
    
    var user = new User { Email = email, Age = age };
    return Either<ValidationError, User>.Right(user);
}

Option Type for Missing Values 🎁

The Option type (also called Maybe) represents a value that might be absent, avoiding null reference errors:

public class Option<T>
{
    private readonly T _value;
    public bool HasValue { get; }
    
    private Option(T value, bool hasValue)
    {
        _value = value;
        HasValue = hasValue;
    }
    
    public static Option<T> Some(T value) => 
        new Option<T>(value, true);
    
    public static Option<T> None() => 
        new Option<T>(default(T), false);
    
    public TOut Match<TOut>(
        Func<T, TOut> some,
        Func<TOut> none)
    {
        return HasValue ? some(_value) : none();
    }
}

public Option<User> FindUserById(int id)
{
    var user = _database.Users.FirstOrDefault(u => u.Id == id);
    return user != null 
        ? Option<User>.Some(user) 
        : Option<User>.None();
}

// Usage
var result = FindUserById(42).Match(
    some: user => $"Found: {user.Name}",
    none: () => "User not found"
);

Option vs null:

  • Option makes absence explicit in the type system
  • Compiler forces you to handle the None case
  • No null reference exceptions

Practical Examples πŸ”§

Example 1: Parsing and Validation Pipeline

Let's build a complete user registration system using Result types:

public class UserRegistration
{
    public Result<Email> ValidateEmail(string input)
    {
        if (string.IsNullOrWhiteSpace(input))
            return Result<Email>.Failure("Email is required");
        
        if (!input.Contains("@"))
            return Result<Email>.Failure("Invalid email format");
        
        return Result<Email>.Success(new Email(input));
    }
    
    public Result<Age> ValidateAge(string input)
    {
        if (!int.TryParse(input, out int age))
            return Result<Age>.Failure("Age must be a number");
        
        if (age < 18 || age > 120)
            return Result<Age>.Failure("Age must be between 18 and 120");
        
        return Result<Age>.Success(new Age(age));
    }
    
    public Result<User> CreateUser(Email email, Age age)
    {
        // Check if email already exists
        if (_userRepository.EmailExists(email))
            return Result<User>.Failure("Email already registered");
        
        var user = new User 
        { 
            Email = email.Value, 
            Age = age.Value,
            CreatedAt = DateTime.UtcNow
        };
        
        return Result<User>.Success(user);
    }
    
    public Result<User> RegisterUser(string emailInput, string ageInput)
    {
        // Railway-oriented pipeline
        var emailResult = ValidateEmail(emailInput);
        var ageResult = ValidateAge(ageInput);
        
        // Combine two results
        if (!emailResult.IsSuccess)
            return Result<User>.Failure(emailResult.Error);
        
        if (!ageResult.IsSuccess)
            return Result<User>.Failure(ageResult.Error);
        
        return CreateUser(emailResult.Value, ageResult.Value);
    }
}

// Usage
var result = registration.RegisterUser(
    emailInput: "john@example.com",
    ageInput: "25"
);

var message = result.Match(
    onSuccess: user => $"Welcome, {user.Email}!",
    onFailure: error => $"Registration failed: {error}"
);

Key points:

  • Each validation step returns a Result
  • Errors are descriptive and specific
  • No exceptions thrown for expected validation failures
  • Easy to test each component independently

Example 2: Database Operations with Try-Catch vs Result

Compare traditional exception handling with Result pattern:

Traditional exception approach:

public class UserService
{
    public User GetUser(int id)
    {
        try
        {
            var user = _database.Users.Find(id);
            if (user == null)
                throw new NotFoundException($"User {id} not found");
            
            return user;
        }
        catch (SqlException ex)
        {
            _logger.LogError(ex, "Database error");
            throw new DatabaseException("Failed to retrieve user", ex);
        }
    }
    
    // Caller must remember to catch exceptions
    public void DisplayUser(int id)
    {
        try
        {
            var user = GetUser(id);
            Console.WriteLine(user.Name);
        }
        catch (NotFoundException)
        {
            Console.WriteLine("User not found");
        }
        catch (DatabaseException ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

Result pattern approach:

public class UserService
{
    public Result<User> GetUser(int id)
    {
        try
        {
            var user = _database.Users.Find(id);
            
            return user != null
                ? Result<User>.Success(user)
                : Result<User>.Failure(new NotFoundError($"User {id} not found"));
        }
        catch (SqlException ex)
        {
            _logger.LogError(ex, "Database error");
            return Result<User>.Failure(
                new DatabaseError("Failed to retrieve user"));
        }
    }
    
    // Signature forces caller to handle Result
    public void DisplayUser(int id)
    {
        var result = GetUser(id);
        
        var message = result.Error switch
        {
            NotFoundError => "User not found",
            DatabaseError e => $"Database error: {e.Message}",
            _ => result.IsSuccess 
                ? result.Value.Name 
                : "Unknown error"
        };
        
        Console.WriteLine(message);
    }
}

Benefits of Result approach:

  • Type signature declares possible failure
  • No hidden control flow
  • Pattern matching for different error types
  • Compiler ensures error handling

Example 3: Async Operations with Result

Combining async/await with Result pattern:

public class ApiClient
{
    private readonly HttpClient _httpClient;
    
    public async Task<Result<string>> FetchDataAsync(string url)
    {
        try
        {
            var response = await _httpClient.GetAsync(url);
            
            if (!response.IsSuccessStatusCode)
            {
                return Result<string>.Failure(
                    new HttpError($"HTTP {response.StatusCode}"));
            }
            
            var content = await response.Content.ReadAsStringAsync();
            return Result<string>.Success(content);
        }
        catch (HttpRequestException ex)
        {
            return Result<string>.Failure(
                new NetworkError("Network error occurred"));
        }
        catch (TaskCanceledException)
        {
            return Result<string>.Failure(
                new TimeoutError("Request timed out"));
        }
    }
    
    public async Task<Result<Weather>> GetWeatherAsync(string city)
    {
        var url = $"https://api.weather.com/v1/{city}";
        var result = await FetchDataAsync(url);
        
        return result.IsSuccess
            ? ParseWeather(result.Value)
            : Result<Weather>.Failure(result.Error);
    }
    
    private Result<Weather> ParseWeather(string json)
    {
        try
        {
            var weather = JsonSerializer.Deserialize<Weather>(json);
            return Result<Weather>.Success(weather);
        }
        catch (JsonException)
        {
            return Result<Weather>.Failure(
                new ParseError("Invalid weather data format"));
        }
    }
}

// Usage with async
var result = await apiClient.GetWeatherAsync("London");

await result.Match(
    onSuccess: async weather => 
    {
        Console.WriteLine($"Temperature: {weather.Temp}Β°C");
        await SaveToCache(weather);
    },
    onFailure: error => 
    {
        Console.WriteLine($"Failed: {error.Message}");
        return Task.CompletedTask;
    }
);

Async Result benefits:

  • Clear error handling in async context
  • No swallowed exceptions
  • Composable async operations
  • Type-safe error propagation

Example 4: Combining Multiple Results

When you need to combine multiple operations that might fail:

public static class ResultExtensions
{
    // Combine two Results - both must succeed
    public static Result<(T1, T2)> Combine<T1, T2>(
        Result<T1> result1,
        Result<T2> result2)
    {
        if (!result1.IsSuccess)
            return Result<(T1, T2)>.Failure(result1.Error);
        
        if (!result2.IsSuccess)
            return Result<(T1, T2)>.Failure(result2.Error);
        
        return Result<(T1, T2)>.Success((result1.Value, result2.Value));
    }
    
    // Collect multiple Results - all must succeed
    public static Result<IEnumerable<T>> Sequence<T>(
        this IEnumerable<Result<T>> results)
    {
        var values = new List<T>();
        
        foreach (var result in results)
        {
            if (!result.IsSuccess)
                return Result<IEnumerable<T>>.Failure(result.Error);
            
            values.Add(result.Value);
        }
        
        return Result<IEnumerable<T>>.Success(values);
    }
}

// Example: Validating multiple fields
public Result<Registration> ValidateRegistration(
    string email,
    string password,
    string confirmPassword)
{
    var emailResult = ValidateEmail(email);
    var passwordResult = ValidatePassword(password);
    var matchResult = ValidatePasswordsMatch(password, confirmPassword);
    
    // Collect all validation results
    var allResults = new[] { emailResult, passwordResult, matchResult };
    var sequenceResult = allResults.Sequence();
    
    return sequenceResult.Map(results => new Registration
    {
        Email = email,
        PasswordHash = HashPassword(password)
    });
}

Common Mistakes ⚠️

Mistake 1: Mixing Exceptions and Results

❌ Wrong:

public Result<User> GetUser(int id)
{
    if (id <= 0)
        throw new ArgumentException("Invalid ID"); // Exception!
    
    var user = _database.Find(id);
    return user != null 
        ? Result<User>.Success(user)
        : Result<User>.Failure("Not found"); // Result!
}

βœ… Right:

public Result<User> GetUser(int id)
{
    if (id <= 0)
        return Result<User>.Failure("Invalid ID"); // Consistent!
    
    var user = _database.Find(id);
    return user != null 
        ? Result<User>.Success(user)
        : Result<User>.Failure("Not found");
}

Lesson: Choose one strategy per method. Don't mix exceptions and Resultsβ€”it creates confusion about how errors are communicated.

Mistake 2: Accessing Value Without Checking IsSuccess

❌ Wrong:

var result = ParseAge(input);
Console.WriteLine(result.Value); // Might be default(T)!

βœ… Right:

var result = ParseAge(input);
if (result.IsSuccess)
{
    Console.WriteLine(result.Value);
}
else
{
    Console.WriteLine(result.Error);
}

// Or use Match
var output = result.Match(
    onSuccess: age => $"Age: {age}",
    onFailure: error => $"Error: {error}"
);

Lesson: Always check IsSuccess before accessing Value. Better yet, use the Match method to force handling both cases.

Mistake 3: Using Empty Catch Blocks

❌ Wrong:

try
{
    ProcessData();
}
catch
{
    // Silently swallow exception
}

βœ… Right:

try
{
    ProcessData();
}
catch (Exception ex)
{
    _logger.LogError(ex, "Failed to process data");
    // Re-throw or return error Result
    throw;
}

Lesson: Never silently swallow exceptions. At minimum, log them. Consider whether the exception should propagate or be converted to a Result.

Mistake 4: Creating Vague Error Messages

❌ Wrong:

return Result<User>.Failure("Error");
return Result<User>.Failure("Invalid");
return Result<User>.Failure("Failed");

βœ… Right:

return Result<User>.Failure("Email format is invalid: missing @ symbol");
return Result<User>.Failure("Age must be between 18 and 120");
return Result<User>.Failure("Database connection timeout after 30 seconds");

Lesson: Error messages should be specific, actionable, and helpful for debugging. Include context like invalid values, constraints, and what went wrong.

Mistake 5: Not Using Typed Errors

❌ Wrong:

public Result<T> { string Error { get; } }

// Can't distinguish error types
if (result.Error.Contains("not found"))
    // String matching is fragile!

βœ… Right:

public abstract class Error { }
public class NotFoundError : Error { }
public class ValidationError : Error { }

public Result<T> { Error Error { get; } }

// Pattern matching on type
var action = result.Error switch
{
    NotFoundError => HandleNotFound(),
    ValidationError => HandleValidation(),
    _ => HandleUnknown()
};

Lesson: Use typed errors instead of strings when you need to handle different error types differently. This enables pattern matching and type safety.

Mistake 6: Over-Using Exceptions for Flow Control

❌ Wrong:

public int ParseOrDefault(string input)
{
    try
    {
        return int.Parse(input); // Throws on invalid input
    }
    catch
    {
        return 0; // Using exceptions for expected failure
    }
}

βœ… Right:

public int ParseOrDefault(string input)
{
    return int.TryParse(input, out int result) ? result : 0;
}

// Or with Result
public Result<int> ParseInt(string input)
{
    return int.TryParse(input, out int result)
        ? Result<int>.Success(result)
        : Result<int>.Failure("Invalid number format");
}

Lesson: Exceptions are for exceptional circumstances. Use TryParse, Result types, or bool return values for expected failure scenarios.

Key Takeaways πŸŽ“

  1. Choose the right tool: Use exceptions for truly exceptional situations, Result types for expected failures

  2. Make errors explicit: Result makes potential failures visible in method signatures

  3. Railway-Oriented Programming: Chain operations with Bind/Map to handle errors elegantly

  4. Type your errors: Use error classes instead of strings for better pattern matching and type safety

  5. Option for absence: Use Option instead of null to avoid null reference exceptions

  6. Compose operations: Result types are composableβ€”use Bind, Map, and Sequence to build pipelines

  7. Be consistent: Don't mix exceptions and Results in the same method or API layer

  8. Provide context: Error messages should be specific and actionable

  9. Test error paths: Error handling is code tooβ€”write tests for failure scenarios

  10. Performance matters: Exceptions are slow; Result types have minimal overhead

πŸ“‹ Quick Reference: Error Handling Decision Tree

Should I throw an exception?
         β”‚
         ↓
    Is it truly exceptional?
    (hardware failure, programming error)
         β”‚
    β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
    ↓         ↓
   YES       NO
    β”‚         β”‚
    ↓         ↓
  THROW    Is it expected?
 EXCEPTION (parsing, validation)
            β”‚
       β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
       ↓         ↓
      YES       NO
       β”‚         β”‚
       ↓         ↓
   USE RESULT  USE OPTION
   PATTERN     (for absence)
Pattern Use When Example
Exception Truly exceptional, can't continue OutOfMemoryException, disk full
Result<T> Expected failure, multiple error types Validation, parsing, business rules
Option<T> Value might be absent Database lookup, dictionary access
Either<L,R> Two distinct outcomes Success/Failure with different types
bool return Simple success/failure, no details needed TryParse, file exists check

πŸ“š Further Study


πŸ’‘ Remember: Error handling isn't just about catching errorsβ€”it's about designing your code to make invalid states unrepresentable and guiding callers toward correct usage. Master these patterns, and your code will be more robust, maintainable, and pleasant to work with!