C# Exception Handling Patterns
Exception handling is a critical aspect of writing robust C# applications. While the basic try-catch-finally blocks form the foundation of exception handling, there are several patterns and best practices that can help you write more effective, maintainable, and elegant error-handling code.
Introduction to Exception Handling Patterns
Exception handling patterns are established solutions to common error-handling scenarios. They help you:
- Write more maintainable code
- Improve application robustness
- Prevent resource leaks
- Provide better diagnostics
- Improve user experience during error conditions
Let's explore several key patterns that every C# developer should know.
Basic Try-Catch Pattern
The fundamental pattern is the try-catch block:
try
{
// Code that might throw an exception
int result = 10 / 0; // Will throw DivideByZeroException
}
catch (Exception ex)
{
// Handle the exception
Console.WriteLine($"An error occurred: {ex.Message}");
}
Output:
An error occurred: Attempted to divide by zero.
This is the starting point for all exception handling patterns.
Specific to General Exception Handling Pattern
When catching multiple exception types, always order them from most specific to most general:
try
{
string text = File.ReadAllText("nonexistent.txt");
int number = int.Parse(text);
}
catch (FileNotFoundException ex)
{
// Handle missing file specifically
Console.WriteLine($"The file was not found: {ex.Message}");
}
catch (FormatException ex)
{
// Handle parsing errors specifically
Console.WriteLine($"The file content couldn't be parsed as a number: {ex.Message}");
}
catch (Exception ex)
{
// Handle any other exceptions generally
Console.WriteLine($"An unexpected error occurred: {ex.Message}");
}
If you put the general Exception
catch block first, it would catch all exceptions, and the more specific handlers would never execute.
Try-Catch-Finally Pattern
Use finally
to ensure cleanup code always runs:
FileStream file = null;
try
{
file = new FileStream("data.txt", FileMode.Open);
// Work with the file...
}
catch (IOException ex)
{
Console.WriteLine($"Error working with the file: {ex.Message}");
}
finally
{
// This code always runs, whether an exception occurred or not
if (file != null)
{
file.Dispose();
}
}
Using Statement Pattern
The using
statement is a cleaner way to ensure proper resource disposal:
try
{
using (var file = new FileStream("data.txt", FileMode.Open))
{
// Work with the file...
} // file.Dispose() automatically called here
}
catch (IOException ex)
{
Console.WriteLine($"Error accessing the file: {ex.Message}");
}
In C# 8.0 and later, you can use the simplified using declaration:
try
{
using var file = new FileStream("data.txt", FileMode.Open);
// Work with the file...
} // file.Dispose() automatically called when leaving scope
catch (IOException ex)
{
Console.WriteLine($"Error accessing the file: {ex.Message}");
}
Exception Filtering Pattern
C# 6.0 introduced exception filters, allowing you to add conditions to your catch blocks:
try
{
ProcessData("sample.txt");
}
catch (IOException ex) when (ex.Message.Contains("access denied"))
{
Console.WriteLine("You don't have permission to access this file.");
}
catch (IOException ex) when (ex.HResult == -2147024894) // File not found
{
Console.WriteLine("The specified file could not be found.");
}
catch (IOException ex)
{
Console.WriteLine($"A different IO error occurred: {ex.Message}");
}
This pattern keeps your code cleaner than using nested if statements within catch blocks.
Retry Pattern
For transient errors (like network issues), the retry pattern can be useful:
public void OperationWithRetry(Action operation)
{
int maxRetries = 3;
int retryCount = 0;
int delayBetweenRetriesMs = 1000; // 1 second
while (true)
{
try
{
operation();
break; // Success! Exit the retry loop
}
catch (Exception ex) when (IsTransientError(ex) && retryCount < maxRetries)
{
retryCount++;
Console.WriteLine($"Operation failed, retry {retryCount} of {maxRetries}");
Thread.Sleep(delayBetweenRetriesMs);
}
catch (Exception ex)
{
// Non-transient error or max retries reached
Console.WriteLine($"Operation failed permanently: {ex.Message}");
throw; // Re-throw the exception
}
}
}
private bool IsTransientError(Exception ex)
{
// Logic to determine if an error is transient
// For example, check if it's a network timeout
return ex is TimeoutException ||
(ex is HttpRequestException && ex.Message.Contains("timeout"));
}
Usage example:
try
{
OperationWithRetry(() => {
// Some operation that might have transient failures
WebClient client = new WebClient();
string data = client.DownloadString("https://api.example.com/data");
Console.WriteLine("Data downloaded successfully!");
});
}
catch (Exception ex)
{
Console.WriteLine("All retries failed!");
}
Fail Fast Pattern
For unrecoverable errors, fail fast to avoid further damage:
public void ProcessCriticalData(string data)
{
if (string.IsNullOrEmpty(data))
{
throw new ArgumentNullException(nameof(data), "Critical data cannot be null or empty!");
}
try
{
// Process data
var result = JsonConvert.DeserializeObject<CriticalData>(data);
if (result == null || !result.IsValid())
{
throw new InvalidDataException("Critical data is not valid!");
}
// Continue with valid data
ProcessValidData(result);
}
catch (JsonException ex)
{
// Log detailed info but don't try to recover
Logger.LogError($"Critical data parsing failed: {ex}");
throw; // Re-throw to stop execution
}
}
Exception Wrapping/Translation Pattern
This pattern preserves the original exception while providing more context:
public Customer GetCustomerById(int customerId)
{
try
{
// Database access code
return _repository.FindCustomer(customerId);
}
catch (DbException ex)
{
// Wrap low-level DB exception in a more meaningful exception
throw new CustomerNotFoundException(
$"Customer with ID {customerId} could not be found", ex);
}
}
Global Exception Handler Pattern
For application-wide exception handling (especially in web apps):
// Program.cs in an ASP.NET Core application
var builder = WebApplication.CreateBuilder(args);
// Add services...
var app = builder.Build();
// Global exception handling middleware
app.UseExceptionHandler(errorApp => {
errorApp.Run(async context => {
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var exceptionHandlerPathFeature =
context.Features.Get<IExceptionHandlerPathFeature>();
var exception = exceptionHandlerPathFeature?.Error;
// Log the exception
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exception, "Unhandled exception");
// Return a generic error message to the client
await context.Response.WriteAsync(
JsonSerializer.Serialize(new {
error = "An unexpected error occurred."
}));
});
});
// Other middleware and app configuration...
app.Run();
Custom Exception Class Pattern
Creating custom exceptions improves code clarity:
public class OrderProcessingException : Exception
{
public string OrderId { get; }
public OrderProcessingException(string orderId, string message)
: base(message)
{
OrderId = orderId;
}
public OrderProcessingException(string orderId, string message, Exception innerException)
: base(message, innerException)
{
OrderId = orderId;
}
}
// Usage
public void ProcessOrder(Order order)
{
try
{
// Process the order
if (!ValidateOrder(order))
{
throw new OrderProcessingException(
order.Id,
"Order validation failed: Missing required fields.");
}
// Continue processing...
}
catch (DatabaseException ex)
{
throw new OrderProcessingException(
order.Id,
"Database error while processing order",
ex);
}
}
Exception Logging Pattern
Proper exception logging is crucial for debugging production issues:
try
{
// Code that might throw
ProcessComplexOperation();
}
catch (Exception ex)
{
// Log all important details
Logger.LogError(
ex,
"Error occurred during {Operation} at {Time}. User: {UserId}",
"ComplexOperation",
DateTime.UtcNow,
currentUser.Id);
// Optional: rethrow or return error to user
throw;
}
Real-World Example: Full E-Commerce Order Processing
Let's see how multiple patterns can be combined in a realistic scenario:
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IPaymentGateway _paymentGateway;
private readonly ILogger<OrderService> _logger;
public OrderService(
IOrderRepository repository,
IPaymentGateway paymentGateway,
ILogger<OrderService> logger)
{
_repository = repository;
_paymentGateway = paymentGateway;
_logger = logger;
}
public async Task<OrderResult> PlaceOrderAsync(Order order)
{
// Fail fast pattern
if (order == null || !order.IsValid())
{
throw new ArgumentException("Invalid order data", nameof(order));
}
try
{
// Save the order
await _repository.SaveOrderAsync(order);
// Retry pattern for payment processing
await ProcessPaymentWithRetryAsync(order);
// Complete the order
order.Status = OrderStatus.Completed;
await _repository.UpdateOrderAsync(order);
return new OrderResult { Success = true, OrderId = order.Id };
}
catch (PaymentDeclinedException ex)
{
// Handle specific payment failure
_logger.LogWarning(ex, "Payment declined for order {OrderId}", order.Id);
// Update order status
order.Status = OrderStatus.PaymentFailed;
await _repository.UpdateOrderAsync(order);
return new OrderResult
{
Success = false,
OrderId = order.Id,
ErrorMessage = "Your payment was declined. Please try another payment method."
};
}
catch (Exception ex)
{
// Exception wrapping pattern
_logger.LogError(ex, "Failed to process order {OrderId}", order.Id);
// Try to mark the order as failed
try
{
order.Status = OrderStatus.Error;
await _repository.UpdateOrderAsync(order);
}
catch (Exception updateEx)
{
_logger.LogError(updateEx, "Failed to update failed order status");
}
throw new OrderProcessingException(
order.Id,
"An unexpected error occurred while processing your order",
ex);
}
}
private async Task ProcessPaymentWithRetryAsync(Order order)
{
int maxRetries = 3;
int retryCount = 0;
TimeSpan delay = TimeSpan.FromSeconds(2);
while (true)
{
try
{
// Attempt payment
await _paymentGateway.ProcessPaymentAsync(order.Payment);
break; // Success
}
catch (Exception ex) when (IsTransientError(ex) && retryCount < maxRetries)
{
retryCount++;
_logger.LogWarning(
ex,
"Transient error during payment processing (attempt {RetryCount}/{MaxRetries})",
retryCount, maxRetries);
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff
}
}
}
private bool IsTransientError(Exception ex)
{
return ex is TimeoutException ||
ex is HttpRequestException ||
(ex is PaymentGatewayException pge && pge.IsTransient);
}
}
Summary
Mastering these exception handling patterns will significantly improve the quality of your C# code. Remember these key points:
- Always catch specific exceptions before general ones
- Use finally blocks or using statements for resource cleanup
- Apply exception filters for cleaner code
- Implement retry logic for transient errors
- Create custom exceptions for better semantics
- Log exceptions with sufficient context
- Consider global exception handling for application-wide concerns
By combining these patterns appropriately, you'll write more robust, maintainable C# applications.
Additional Resources
- Microsoft Docs: Exception Handling
- Best Practices for Exception Handling in .NET
- Polly - A .NET resilience and transient-fault-handling library
Exercises
- Implement a file processing application that demonstrates at least three exception handling patterns.
- Create a web service client with retry logic for handling network failures.
- Design custom exceptions for a banking application that properly encapsulate domain-specific error information.
- Implement global exception handling for a web API that returns appropriate HTTP status codes.
- Write a utility class that safely executes arbitrary code with proper exception handling and logging.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)