πŸ”·

Exception Handling

Language Fundamentals Beginner 4 min read 600 words

C# Exception Handling

Introduction

Exception handling is a mechanism for responding to runtime errors in a structured way. Proper exception handling makes applications more robust, maintainable, and user-friendly.


Table of Contents


Exception Basics

What is an Exception?

An exception is an object that represents an error or unexpected condition during program execution.

// Exception hierarchy
System.Object
└── System.Exception
    β”œβ”€β”€ System.SystemException
    β”‚   β”œβ”€β”€ System.ArgumentException
    β”‚   β”‚   β”œβ”€β”€ System.ArgumentNullException
    β”‚   β”‚   └── System.ArgumentOutOfRangeException
    β”‚   β”œβ”€β”€ System.NullReferenceException
    β”‚   β”œβ”€β”€ System.InvalidOperationException
    β”‚   β”œβ”€β”€ System.IndexOutOfRangeException
    β”‚   β”œβ”€β”€ System.DivideByZeroException
    β”‚   └── System.IO.IOException
    └── System.ApplicationException (legacy, don't use)

Exception Properties

try
{
    throw new InvalidOperationException("Operation failed");
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);        // "Operation failed"
    Console.WriteLine(ex.GetType().Name); // "InvalidOperationException"
    Console.WriteLine(ex.StackTrace);     // Stack trace
    Console.WriteLine(ex.Source);         // Assembly name
    Console.WriteLine(ex.TargetSite);     // Method that threw
    Console.WriteLine(ex.InnerException); // Wrapped exception
    Console.WriteLine(ex.Data);           // Additional data dictionary
    Console.WriteLine(ex.HResult);        // HRESULT code
}

Try-Catch-Finally

Basic Structure

try
{
    // Code that might throw
    int result = int.Parse("not a number");
}
catch (FormatException ex)
{
    // Handle specific exception
    Console.WriteLine($"Invalid format: {ex.Message}");
}
catch (Exception ex)
{
    // Handle any other exception
    Console.WriteLine($"Error: {ex.Message}");
}
finally
{
    // Always executes (cleanup)
    Console.WriteLine("Cleanup complete");
}

Multiple Catch Blocks

try
{
    ProcessFile(path);
}
catch (FileNotFoundException ex)
{
    // Most specific first
    Log($"File not found: {ex.FileName}");
    throw; // Re-throw for caller to handle
}
catch (IOException ex)
{
    // More general
    Log($"IO Error: {ex.Message}");
}
catch (UnauthorizedAccessException ex)
{
    Log($"Access denied: {ex.Message}");
}
catch (Exception ex)
{
    // Catch-all (should be last)
    Log($"Unexpected error: {ex.Message}");
    throw; // Don't swallow unexpected exceptions
}

Try-Finally Without Catch

// Ensures cleanup even if exception propagates
FileStream stream = null;
try
{
    stream = File.OpenRead(path);
    ProcessStream(stream);
}
finally
{
    stream?.Dispose();  // Always runs
}

// Preferred: using statement (equivalent to try-finally)
using (var stream = File.OpenRead(path))
{
    ProcessStream(stream);
}

// C# 8+ simplified using declaration
using var stream = File.OpenRead(path);
ProcessStream(stream);

Nested Try-Catch

try
{
    try
    {
        // Inner operation
        RiskyOperation();
    }
    catch (SpecificException ex)
    {
        // Handle specific cases locally
        HandleSpecific(ex);
    }
    // Other operations continue
    AnotherOperation();
}
catch (Exception ex)
{
    // Outer handler catches unhandled exceptions
    LogAndReport(ex);
}

Exception Types

Common System Exceptions

Exception Cause Prevention
NullReferenceException Accessing member on null Null checks, null-conditional operators
ArgumentNullException Null passed to method Parameter validation
ArgumentException Invalid argument value Input validation
ArgumentOutOfRangeException Value outside valid range Range validation
InvalidOperationException Operation invalid for current state State checks
IndexOutOfRangeException Array index out of bounds Bounds checking
KeyNotFoundException Dictionary key not found TryGetValue
FormatException Invalid string format TryParse
InvalidCastException Invalid type cast as operator, is check
ObjectDisposedException Using disposed object Proper disposal patterns
NotImplementedException Method not implemented Development placeholder
NotSupportedException Operation not supported Design consideration

IO Exceptions

try
{
    File.ReadAllText(path);
}
catch (FileNotFoundException)
{
    // File doesn't exist
}
catch (DirectoryNotFoundException)
{
    // Directory doesn't exist
}
catch (UnauthorizedAccessException)
{
    // No permission
}
catch (PathTooLongException)
{
    // Path exceeds system limit
}
catch (IOException)
{
    // General IO error (file in use, etc.)
}

Throwing Exceptions

Basic Throw

public void ProcessOrder(Order order)
{
    if (order == null)
        throw new ArgumentNullException(nameof(order));

    if (order.Items.Count == 0)
        throw new ArgumentException("Order must have items", nameof(order));

    if (order.Total < 0)
        throw new ArgumentOutOfRangeException(nameof(order.Total),
            order.Total, "Total cannot be negative");

    // Process order...
}

Throw vs Throw ex

try
{
    DoSomething();
}
catch (Exception ex)
{
    // ❌ BAD: Resets stack trace
    throw ex;
}

try
{
    DoSomething();
}
catch (Exception ex)
{
    // βœ… GOOD: Preserves stack trace
    throw;
}

try
{
    DoSomething();
}
catch (Exception ex)
{
    // βœ… GOOD: Wrap with inner exception
    throw new ServiceException("Operation failed", ex);
}

Conditional Throw Helpers

// C# 10+ ArgumentNullException helpers
public void Process(string input)
{
    ArgumentNullException.ThrowIfNull(input);
    ArgumentException.ThrowIfNullOrEmpty(input);
    ArgumentException.ThrowIfNullOrWhiteSpace(input);

    // Continue processing...
}

// Custom throw helper (reduces IL size in hot paths)
public static class ThrowHelper
{
    [DoesNotReturn]
    public static void ThrowArgumentNull(string paramName)
    {
        throw new ArgumentNullException(paramName);
    }

    [DoesNotReturn]
    public static void ThrowInvalidOperation(string message)
    {
        throw new InvalidOperationException(message);
    }
}

Custom Exceptions

Creating Custom Exceptions

// Full implementation following best practices
[Serializable]
public class OrderProcessingException : Exception
{
    public string OrderId { get; }
    public OrderErrorCode ErrorCode { get; }

    public OrderProcessingException()
    {
    }

    public OrderProcessingException(string message)
        : base(message)
    {
    }

    public OrderProcessingException(string message, Exception innerException)
        : base(message, innerException)
    {
    }

    public OrderProcessingException(string message, string orderId, OrderErrorCode errorCode)
        : base(message)
    {
        OrderId = orderId;
        ErrorCode = errorCode;
    }

    public OrderProcessingException(string message, string orderId,
        OrderErrorCode errorCode, Exception innerException)
        : base(message, innerException)
    {
        OrderId = orderId;
        ErrorCode = errorCode;
    }

    // For serialization (if needed)
    protected OrderProcessingException(SerializationInfo info, StreamingContext context)
        : base(info, context)
    {
        OrderId = info.GetString(nameof(OrderId));
        ErrorCode = (OrderErrorCode)info.GetInt32(nameof(ErrorCode));
    }

    public override void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        base.GetObjectData(info, context);
        info.AddValue(nameof(OrderId), OrderId);
        info.AddValue(nameof(ErrorCode), (int)ErrorCode);
    }
}

public enum OrderErrorCode
{
    Unknown,
    InvalidItems,
    PaymentFailed,
    OutOfStock,
    ShippingUnavailable
}

Using Custom Exceptions

public class OrderService
{
    public void ProcessOrder(Order order)
    {
        try
        {
            ValidateOrder(order);
            ProcessPayment(order);
            FulfillOrder(order);
        }
        catch (PaymentException ex)
        {
            throw new OrderProcessingException(
                "Payment processing failed",
                order.Id,
                OrderErrorCode.PaymentFailed,
                ex);
        }
        catch (InventoryException ex)
        {
            throw new OrderProcessingException(
                "Insufficient inventory",
                order.Id,
                OrderErrorCode.OutOfStock,
                ex);
        }
    }
}

// Caller handles domain exception
try
{
    orderService.ProcessOrder(order);
}
catch (OrderProcessingException ex)
{
    switch (ex.ErrorCode)
    {
        case OrderErrorCode.PaymentFailed:
            ShowPaymentRetryUI();
            break;
        case OrderErrorCode.OutOfStock:
            ShowBackorderOption();
            break;
        default:
            ShowGenericError(ex.Message);
            break;
    }
}

Exception Filters

When Clause (C# 6+)

try
{
    await httpClient.GetAsync(url);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    // Handle 404 specifically
    return null;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
    // Handle 401 specifically
    throw new AuthenticationException("Session expired", ex);
}
catch (HttpRequestException ex)
{
    // Handle other HTTP errors
    throw new ServiceException($"HTTP error: {ex.StatusCode}", ex);
}

Logging Without Catching

try
{
    ProcessData();
}
catch (Exception ex) when (LogException(ex))
{
    // Never executes because LogException returns false
}

static bool LogException(Exception ex)
{
    Logger.Error(ex, "Exception occurred");
    return false;  // Don't catch, let it propagate
}

Conditional Handling

var retryCount = 0;
const int maxRetries = 3;

while (true)
{
    try
    {
        return await CallExternalService();
    }
    catch (TimeoutException) when (retryCount < maxRetries)
    {
        retryCount++;
        await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retryCount)));
        // Retry
    }
    // On maxRetries exceeded, exception propagates
}

Best Practices

DO

// βœ… Catch specific exceptions
catch (FileNotFoundException ex)

// βœ… Use throw; to re-throw
throw;

// βœ… Wrap exceptions with context
throw new ServiceException("User lookup failed", ex);

// βœ… Validate arguments early
if (input == null) throw new ArgumentNullException(nameof(input));

// βœ… Use finally or using for cleanup
using var stream = File.OpenRead(path);

// βœ… Include relevant information in exceptions
throw new OrderException($"Order {orderId} failed") { OrderId = orderId };

// βœ… Log exceptions at appropriate level
catch (Exception ex)
{
    _logger.LogError(ex, "Failed to process order {OrderId}", orderId);
    throw;
}

DON’T

// ❌ Empty catch block (swallows exceptions)
catch (Exception) { }

// ❌ Catch Exception when you expect specific type
catch (Exception ex) { /* handle any error */ }

// ❌ Use throw ex; (loses stack trace)
throw ex;

// ❌ Use exceptions for flow control
try
{
    var value = dictionary[key];
}
catch (KeyNotFoundException)
{
    value = default;  // Use TryGetValue instead!
}

// ❌ Throw Exception base class
throw new Exception("Something failed");

// ❌ Catch and ignore without logging
catch (Exception) { return null; }

// ❌ Include sensitive information in exception messages
throw new Exception($"Login failed for password: {password}");

Exception Handling Patterns

Result Pattern (Alternative to Exceptions)

public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public string Error { get; }

    private Result(T value)
    {
        IsSuccess = true;
        Value = value;
    }

    private Result(string error)
    {
        IsSuccess = false;
        Error = error;
    }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Failure(string error) => new(error);
}

// Usage
public Result<User> GetUser(int id)
{
    var user = _repository.Find(id);
    if (user == null)
        return Result<User>.Failure($"User {id} not found");

    return Result<User>.Success(user);
}

var result = GetUser(123);
if (result.IsSuccess)
    ProcessUser(result.Value);
else
    ShowError(result.Error);

Global Exception Handler

// ASP.NET Core middleware
public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionMiddleware> _logger;

    public GlobalExceptionMiddleware(RequestDelegate next,
        ILogger<GlobalExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            _logger.LogWarning(ex, "Validation error");
            context.Response.StatusCode = 400;
            await WriteErrorResponse(context, ex.Message);
        }
        catch (NotFoundException ex)
        {
            _logger.LogWarning(ex, "Resource not found");
            context.Response.StatusCode = 404;
            await WriteErrorResponse(context, ex.Message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
            context.Response.StatusCode = 500;
            await WriteErrorResponse(context, "An error occurred");
        }
    }
}

Retry Pattern with Polly

using Polly;
using Polly.Retry;

// Define retry policy
AsyncRetryPolicy retryPolicy = Policy
    .Handle<HttpRequestException>()
    .Or<TimeoutException>()
    .WaitAndRetryAsync(
        retryCount: 3,
        sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
        onRetry: (exception, timeSpan, attempt, context) =>
        {
            _logger.LogWarning(exception,
                "Retry {Attempt} after {Delay}s", attempt, timeSpan.TotalSeconds);
        });

// Use policy
var result = await retryPolicy.ExecuteAsync(async () =>
{
    return await httpClient.GetStringAsync(url);
});

Circuit Breaker Pattern

using Polly.CircuitBreaker;

AsyncCircuitBreakerPolicy circuitBreaker = Policy
    .Handle<HttpRequestException>()
    .CircuitBreakerAsync(
        exceptionsAllowedBeforeBreaking: 3,
        durationOfBreak: TimeSpan.FromSeconds(30),
        onBreak: (ex, duration) =>
        {
            _logger.LogWarning("Circuit breaker opened for {Duration}s", duration.TotalSeconds);
        },
        onReset: () =>
        {
            _logger.LogInformation("Circuit breaker reset");
        });

try
{
    await circuitBreaker.ExecuteAsync(async () =>
    {
        return await httpClient.GetStringAsync(url);
    });
}
catch (BrokenCircuitException)
{
    // Service unavailable, use fallback
    return GetCachedData();
}

Performance Considerations

Exception Cost

// Exceptions are expensive when thrown!
// Creating exception object: ~100 CPU cycles
// Throwing and catching: ~10,000-50,000 CPU cycles

// ❌ BAD: Using exceptions for expected cases
public int ParseOrDefault(string input, int defaultValue)
{
    try
    {
        return int.Parse(input);  // Throws on invalid input
    }
    catch (FormatException)
    {
        return defaultValue;
    }
}

// βœ… GOOD: Avoid exceptions for expected cases
public int ParseOrDefault(string input, int defaultValue)
{
    return int.TryParse(input, out int result) ? result : defaultValue;
}

Try Pattern Methods

// Framework types often provide Try methods
bool success = int.TryParse("123", out int number);
bool found = dictionary.TryGetValue(key, out var value);
bool valid = Uri.TryCreate(url, UriKind.Absolute, out var uri);

// Implement Try pattern in your own code
public class UserService
{
    public bool TryGetUser(int id, out User user)
    {
        user = _repository.Find(id);
        return user != null;
    }

    // Or with nullable reference types (C# 8+)
    public User? TryGetUser(int id)
    {
        return _repository.Find(id);
    }
}

Benchmark: Exception vs TryParse

// Results (typical):
// TryParse: ~15 nanoseconds
// Parse with catch: ~50,000 nanoseconds (when exception thrown)

[Benchmark]
public int ParseWithTry()
{
    return int.TryParse("invalid", out int result) ? result : 0;
}

[Benchmark]
public int ParseWithCatch()
{
    try
    {
        return int.Parse("invalid");
    }
    catch
    {
        return 0;
    }
}

Interview Questions

1. What’s the difference between throw and throw ex?

Answer:

  • throw; re-throws the current exception preserving the original stack trace.
  • throw ex; throws the exception but resets the stack trace to the current location, losing information about where the exception originated.

Always use throw; when re-throwing to preserve debugging information.


2. When should you create custom exceptions?

Answer: Create custom exceptions when:

  • Built-in exceptions don’t adequately describe the error
  • You need to include domain-specific information (OrderId, ErrorCode, etc.)
  • Callers need to handle your errors differently from system errors
  • You want to abstract implementation details from callers

Don’t create custom exceptions for every error - use built-in exceptions when they fit.


3. Should you catch Exception in your code?

Answer: Generally avoid catching Exception directly. Catch specific exceptions you can handle meaningfully. However, there are valid cases:

  • Global exception handlers for logging/reporting
  • Fire-and-forget background tasks
  • Top-level event handlers

If you catch Exception, either re-throw with throw; or log and fail gracefully. Never silently swallow all exceptions.


4. What is the purpose of the finally block?

Answer: The finally block executes whether or not an exception occurs, making it ideal for cleanup code (closing connections, releasing resources). It runs:

  • After try block if no exception
  • After catch block if exception was caught
  • Before exception propagates if not caught

Modern C# prefers using statements which are syntactic sugar for try-finally.


5. How do exception filters (when clause) work?

Answer: Exception filters (C# 6+) allow conditional catching based on exception properties or external state. The filter runs before entering the catch block, and if it returns false, the exception continues propagating.

Benefits:

  • Catch specific conditions without catching/re-throwing
  • Stack not unwound when filter runs (better debugging)
  • Can inspect exception without catching it (logging pattern)
catch (HttpException ex) when (ex.StatusCode == 404)
{
    // Only catches 404 errors
}

6. Why are exceptions expensive?

Answer: Exceptions are expensive because:

  1. Runtime must capture stack trace (walks entire call stack)
  2. Searches for catch handlers up the call stack
  3. Executes finally blocks
  4. May trigger first-chance exception handlers
  5. Creates exception object with metadata

This makes exceptions unsuitable for control flow. Use Try* patterns for expected failures and reserve exceptions for truly exceptional conditions.


Sources

πŸ“š Related Articles