Skip to main content

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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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):

csharp
// 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:

csharp
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:

csharp
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:

csharp
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

Exercises

  1. Implement a file processing application that demonstrates at least three exception handling patterns.
  2. Create a web service client with retry logic for handling network failures.
  3. Design custom exceptions for a banking application that properly encapsulate domain-specific error information.
  4. Implement global exception handling for a web API that returns appropriate HTTP status codes.
  5. 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! :)