Skip to main content

C# Error Handling Strategies

Error handling is a critical aspect of writing robust C# applications. When your program encounters unexpected situations, proper error handling ensures that your application can respond gracefully rather than crash. This guide will explore various strategies for handling errors in C#, providing you with the tools to write more resilient code.

Introduction to Error Handling

In C#, errors are primarily handled through a mechanism called exception handling. An exception is an object that represents an error or unexpected condition that occurs during program execution. Instead of letting your program crash when an error occurs, you can "catch" these exceptions and handle them appropriately.

Effective error handling offers several benefits:

  • Prevents application crashes
  • Provides meaningful feedback to users
  • Makes debugging easier
  • Increases code reliability
  • Improves maintainability

Basic Exception Handling

The fundamental construct for exception handling in C# is the try-catch-finally block.

csharp
try
{
// Code that might cause an exception
}
catch (Exception ex)
{
// Code that handles the exception
}
finally
{
// Code that always runs, whether an exception occurred or not
}

Let's see a simple example:

csharp
using System;

public class DivisionExample
{
public static void Main()
{
int numerator = 10;
int denominator = 0;

try
{
// This will throw a DivideByZeroException
int result = numerator / denominator;
Console.WriteLine($"Result: {result}"); // This won't execute
}
catch (DivideByZeroException ex)
{
Console.WriteLine("Error: Cannot divide by zero");
Console.WriteLine($"Exception message: {ex.Message}");
}
finally
{
Console.WriteLine("This block always executes");
}
}
}

Output:

Error: Cannot divide by zero
Exception message: Attempted to divide by zero.
This block always executes

Key Components of Exception Handling

  1. Try Block: Contains code that might throw an exception.
  2. Catch Block: Catches and handles specific exceptions.
  3. Finally Block: Contains code that always executes, whether an exception occurred or not.

Catching Specific Exceptions

It's generally better to catch specific exception types rather than the base Exception class. This allows you to handle different types of errors differently.

csharp
using System;
using System.IO;

public class FileReadExample
{
public static void Main()
{
try
{
string content = File.ReadAllText("nonexistent.txt");
Console.WriteLine(content);
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"File not found: {ex.FileName}");
}
catch (IOException ex)
{
Console.WriteLine($"IO error occurred: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"An unexpected error occurred: {ex.Message}");
}
}
}

Output:

File not found: nonexistent.txt

When multiple catch blocks are present, they are evaluated from most specific to least specific. Once an exception is caught, no other catch blocks are executed.

The when Clause

C# 6.0 introduced exception filters with the when clause, which allows you to specify additional conditions for catching an exception:

csharp
using System;
using System.IO;

public class ExceptionFilterExample
{
public static void Main()
{
try
{
ProcessFile("data.txt");
}
catch (IOException ex) when (ex.Message.Contains("disk"))
{
Console.WriteLine("A disk-related IO error occurred");
}
catch (IOException ex)
{
Console.WriteLine("Some other IO error occurred");
}
}

static void ProcessFile(string filename)
{
// Simulating an IO error
throw new IOException("The disk is full");
}
}

Output:

A disk-related IO error occurred

Throwing Exceptions

Sometimes you need to throw exceptions yourself, especially when you detect invalid conditions in your code:

csharp
using System;

public class BankAccount
{
private decimal balance;

public BankAccount(decimal initialBalance)
{
if (initialBalance < 0)
{
throw new ArgumentException("Initial balance cannot be negative", nameof(initialBalance));
}

balance = initialBalance;
}

public void Withdraw(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentException("Withdrawal amount must be positive", nameof(amount));
}

if (amount > balance)
{
throw new InvalidOperationException("Insufficient funds");
}

balance -= amount;
}

public decimal GetBalance() => balance;
}

public class Program
{
public static void Main()
{
try
{
BankAccount account = new BankAccount(1000);
Console.WriteLine($"Initial balance: ${account.GetBalance()}");

account.Withdraw(500);
Console.WriteLine($"Balance after withdrawal: ${account.GetBalance()}");

// This will throw an exception
account.Withdraw(2000);
}
catch (ArgumentException ex)
{
Console.WriteLine($"Invalid argument: {ex.Message}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Invalid operation: {ex.Message}");
}
}
}

Output:

Initial balance: $1000
Balance after withdrawal: $500
Invalid operation: Insufficient funds

Custom Exceptions

For more specific error handling, you can create custom exception classes:

csharp
using System;

// Custom exception class
public class InsufficientFundsException : Exception
{
public decimal AttemptedAmount { get; }
public decimal CurrentBalance { get; }

public InsufficientFundsException(decimal attemptedAmount, decimal currentBalance)
: base($"Insufficient funds for withdrawal of ${attemptedAmount}. Current balance: ${currentBalance}")
{
AttemptedAmount = attemptedAmount;
CurrentBalance = currentBalance;
}
}

public class ImprovedBankAccount
{
private decimal balance;

public ImprovedBankAccount(decimal initialBalance)
{
if (initialBalance < 0)
{
throw new ArgumentException("Initial balance cannot be negative", nameof(initialBalance));
}

balance = initialBalance;
}

public void Withdraw(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentException("Withdrawal amount must be positive", nameof(amount));
}

if (amount > balance)
{
throw new InsufficientFundsException(amount, balance);
}

balance -= amount;
}

public decimal GetBalance() => balance;
}

public class Program
{
public static void Main()
{
try
{
ImprovedBankAccount account = new ImprovedBankAccount(500);
account.Withdraw(700);
}
catch (InsufficientFundsException ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine($"Attempted to withdraw: ${ex.AttemptedAmount}");
Console.WriteLine($"Available balance: ${ex.CurrentBalance}");
Console.WriteLine($"Shortfall: ${ex.AttemptedAmount - ex.CurrentBalance}");
}
}
}

Output:

Insufficient funds for withdrawal of $700. Current balance: $500
Attempted to withdraw: $700
Available balance: $500
Shortfall: $200

Custom exceptions should:

  • Inherit from Exception (or a more specific exception type)
  • Include "Exception" in their name
  • Be serializable (if they need to cross application domain boundaries)
  • Include constructors that allow setting the error message

Exception Best Practices

1. Only Catch Exceptions You Can Handle

Don't catch exceptions unless you have a specific recovery strategy:

csharp
// Bad practice
try
{
// Some code
}
catch (Exception)
{
// Empty catch block - swallows the exception
}

// Good practice
try
{
// Some code
}
catch (SpecificException ex)
{
// Handle specifically this type of exception
LogError(ex);
DisplayUserFriendlyMessage();
}

2. Use Finally for Cleanup

The finally block is perfect for cleanup operations that should occur whether an exception happens or not:

csharp
SqlConnection connection = null;
try
{
connection = new SqlConnection(connectionString);
connection.Open();
// Work with database
}
catch (SqlException ex)
{
// Handle database-specific exceptions
}
finally
{
// This ensures the connection is closed even if an exception occurs
connection?.Close();
}

3. Prefer Using Statements for Disposable Resources

For resources implementing IDisposable, use the using statement instead of try-finally blocks:

csharp
// This automatically calls Dispose() when execution leaves the block
using (SqlConnection connection = new SqlConnection(connectionString))
{
connection.Open();
// Work with database
}

In C# 8.0 and later, you can use simplified using statements:

csharp
// Simplified using statement (C# 8.0+)
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Work with database
// Dispose() is called automatically at the end of the containing scope

4. Include Important Details When Re-throwing

When re-throwing exceptions, preserve the original stack trace:

csharp
try
{
// Some code that might throw
}
catch (Exception ex)
{
// Log the error
LogError(ex);

// Bad - loses original stack trace
// throw new ApplicationException("An error occurred", ex.Message);

// Good - preserves the original exception
throw;

// Also good - wraps original exception
// throw new ApplicationException("An error occurred", ex);
}

5. Avoid Throwing Exceptions in Performance-Critical Code

Exception handling has a performance cost. For performance-critical code paths, consider using return values to indicate errors:

csharp
// Using exceptions
public decimal Divide(decimal a, decimal b)
{
if (b == 0)
{
throw new DivideByZeroException();
}
return a / b;
}

// Using return values and out parameter
public bool TryDivide(decimal a, decimal b, out decimal result)
{
if (b == 0)
{
result = 0;
return false;
}

result = a / b;
return true;
}

// Usage:
if (TryDivide(10, 0, out decimal quotient))
{
Console.WriteLine($"Result: {quotient}");
}
else
{
Console.WriteLine("Cannot divide by zero");
}

Real-World Example: File Processing Application

Let's create a more comprehensive example that demonstrates error handling in a file processing application:

csharp
using System;
using System.IO;
using System.Text;

public class FileProcessor
{
public void ProcessFile(string inputPath, string outputPath)
{
// Validate arguments
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be empty", nameof(inputPath));

if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be empty", nameof(outputPath));

try
{
// Check if input file exists
if (!File.Exists(inputPath))
{
throw new FileNotFoundException($"Input file not found: {inputPath}", inputPath);
}

// Read all content
string content;
try
{
content = File.ReadAllText(inputPath);
}
catch (IOException ex)
{
throw new IOException($"Error reading input file: {inputPath}", ex);
}

// Process the content
string processedContent = ProcessContent(content);

// Write to output file
try
{
// Ensure directory exists
string outputDir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}

File.WriteAllText(outputPath, processedContent);
}
catch (IOException ex)
{
throw new IOException($"Error writing to output file: {outputPath}", ex);
}
}
catch (Exception ex) when (ex is not ArgumentException && ex is not FileNotFoundException)
{
// Wrap any other exceptions
throw new ApplicationException($"Error processing file from {inputPath} to {outputPath}", ex);
}
}

private string ProcessContent(string content)
{
// In a real app, this would do actual processing
// For demonstration, we'll just convert to uppercase
try
{
return content.ToUpper();
}
catch (Exception ex)
{
throw new InvalidOperationException("Error processing content", ex);
}
}
}

public class Program
{
public static void Main()
{
FileProcessor processor = new FileProcessor();

try
{
processor.ProcessFile("input.txt", "output/result.txt");
Console.WriteLine("File processed successfully!");
}
catch (ArgumentException ex)
{
Console.WriteLine($"Invalid argument: {ex.Message}");
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"File not found: {ex.FileName}");
Console.WriteLine("Please make sure the input file exists.");
}
catch (IOException ex)
{
Console.WriteLine($"IO error: {ex.Message}");
Console.WriteLine("There was a problem reading from or writing to a file.");
}
catch (Exception ex)
{
Console.WriteLine($"An unexpected error occurred: {ex.Message}");
if (ex.InnerException != null)
{
Console.WriteLine($"Inner exception: {ex.InnerException.Message}");
}
}
}
}

This example demonstrates several error handling best practices:

  1. Validating arguments early
  2. Using specific exception types
  3. Proper exception hierarchy
  4. Wrapping and re-throwing exceptions with context
  5. Using the when clause for exception filtering
  6. Providing user-friendly error messages

Async/Await Error Handling

When working with asynchronous code, error handling works similarly:

csharp
using System;
using System.IO;
using System.Threading.Tasks;

public class AsyncErrorHandlingDemo
{
public static async Task Main()
{
try
{
await ProcessFilesAsync("input.txt", "output.txt");
Console.WriteLine("Processing completed successfully");
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"File not found: {ex.FileName}");
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}

public static async Task ProcessFilesAsync(string inputPath, string outputPath)
{
try
{
string content = await File.ReadAllTextAsync(inputPath);
string processed = content.ToUpper();
await File.WriteAllTextAsync(outputPath, processed);
}
catch (Exception ex)
{
// Add context to the exception
throw new Exception($"Error processing files: {inputPath} to {outputPath}", ex);
}
}
}

Summary

Effective error handling is crucial for building robust and reliable C# applications. Here are the key takeaways:

  1. Use try-catch-finally blocks to handle exceptions.
  2. Catch specific exception types rather than generic ones.
  3. Create custom exception classes for domain-specific errors.
  4. Use the using statement for automatic resource cleanup.
  5. Implement the appropriate error handling strategy based on your application's needs.
  6. Add meaningful context when re-throwing exceptions.
  7. Provide user-friendly error messages.
  8. Use exception filters with the when clause for more precise exception handling.

By implementing these strategies, you'll create more resilient applications that can handle unexpected situations gracefully.

Additional Resources

Exercises

  1. Create a console application that reads a list of numbers from a file and calculates their average. Implement error handling for file access, parsing errors, and division by zero.

  2. Extend the BankAccount class from the examples to include deposit functionality and appropriate error handling. Create a custom exception for negative deposit attempts.

  3. Write a method that validates an email address and throws custom exceptions for various invalid formats with meaningful error messages.

  4. Create an async method that downloads content from a URL and implements proper error handling for network-related issues.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)