How to Use Structured Exception Handling in .NET

Introduction

Imagine your application receives a file upload, starts processing the data, and suddenly encounters malformed content. Without proper exception handling, your entire process crashes, leaving resources locked and users confused. You need a way to detect errors, clean up resources, and provide meaningful feedback.

Structured exception handling in .NET provides try-catch-finally blocks that separate normal code from error-handling code. This makes your code cleaner and ensures resources get cleaned up regardless of whether errors occur. Exceptions flow up the call stack until something catches and handles them.

You'll learn how try-catch-finally blocks work, how to create custom exceptions, when to catch versus rethrow exceptions, how exception filters add precision to error handling, and patterns that make your code robust without overcomplicating it.

Understanding Try-Catch-Finally

The try block contains code that might throw exceptions. Catch blocks handle specific exception types. The finally block contains cleanup code that runs whether an exception occurred or not. This structure separates normal logic from error handling.

When an exception occurs in the try block, the runtime searches for a matching catch block. If found, it executes that block and then the finally block. If no catch handles the exception, the finally block still runs before the exception propagates up the call stack.

BasicExceptionHandling.cs
using System;

public class FileProcessor
{
    public void ProcessFile(string path)
    {
        FileStream? file = null;
        try
        {
            Console.WriteLine($"Opening file: {path}");
            file = File.OpenRead(path);

            Console.WriteLine("Processing data...");
            // Process file contents
            byte[] buffer = new byte[1024];
            int bytesRead = file.Read(buffer, 0, buffer.Length);
            Console.WriteLine($"Read {bytesRead} bytes");
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"Error: File not found - {ex.Message}");
        }
        catch (UnauthorizedAccessException ex)
        {
            Console.WriteLine($"Error: Access denied - {ex.Message}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Unexpected error: {ex.Message}");
        }
        finally
        {
            if (file != null)
            {
                Console.WriteLine("Closing file");
                file.Close();
            }
        }
    }
}

Multiple catch blocks handle different exception types. Order them from most specific to most general. The FileNotFoundException and UnauthorizedAccessException blocks handle specific scenarios, while the general Exception block catches anything else. The finally block ensures the file closes regardless of success or failure.

Exception Propagation and Rethrowing

Sometimes you need to catch an exception, perform some action like logging, and then let the exception continue up the stack. Use throw without specifying the exception to preserve the original stack trace.

Throwing the same exception variable with throw ex resets the stack trace, losing information about where the error originated. This makes debugging harder because you can't see the original error location.

ExceptionPropagation.cs
using System;

public class DataService
{
    public void SaveData(string data)
    {
        try
        {
            ValidateData(data);
            WriteToDatabase(data);
        }
        catch (Exception ex)
        {
            LogError(ex);
            throw; // Preserves original stack trace
        }
    }

    private void ValidateData(string data)
    {
        if (string.IsNullOrEmpty(data))
        {
            throw new ArgumentException("Data cannot be empty", nameof(data));
        }
    }

    private void WriteToDatabase(string data)
    {
        // Simulate database write
        throw new InvalidOperationException("Database connection failed");
    }

    private void LogError(Exception ex)
    {
        Console.WriteLine($"[ERROR] {DateTime.Now}: {ex.GetType().Name}");
        Console.WriteLine($"Message: {ex.Message}");
        Console.WriteLine($"Stack Trace: {ex.StackTrace}");
    }
}

// Usage
try
{
    var service = new DataService();
    service.SaveData("test data");
}
catch (Exception ex)
{
    Console.WriteLine($"\nCaught in main: {ex.Message}");
}

The SaveData method catches exceptions, logs them, and rethrows using throw without the exception variable. This pattern allows intermediate code to observe exceptions without preventing higher-level code from handling them appropriately.

Creating Custom Exceptions

Custom exceptions communicate domain-specific errors more clearly than generic exceptions. They let callers distinguish between different failure scenarios and handle them appropriately. Always inherit from Exception and follow the standard exception naming convention.

Include multiple constructors to support different initialization patterns. Provide a meaningful message and support inner exceptions to preserve the original error when wrapping exceptions.

CustomExceptions.cs
using System;

public class InsufficientFundsException : Exception
{
    public decimal AccountBalance { get; }
    public decimal RequestedAmount { get; }

    public InsufficientFundsException()
    {
    }

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

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

    public InsufficientFundsException(decimal balance, decimal requested)
        : base($"Insufficient funds. Balance: {balance:C}, Requested: {requested:C}")
    {
        AccountBalance = balance;
        RequestedAmount = requested;
    }
}

public class BankAccount
{
    public decimal Balance { get; private set; }

    public BankAccount(decimal initialBalance)
    {
        Balance = initialBalance;
    }

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

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

        Balance -= amount;
        Console.WriteLine($"Withdrew {amount:C}. New balance: {Balance:C}");
    }
}

// Usage
try
{
    var account = new BankAccount(100m);
    account.Withdraw(150m);
}
catch (InsufficientFundsException ex)
{
    Console.WriteLine($"Cannot withdraw: {ex.Message}");
    Console.WriteLine($"You need {ex.RequestedAmount - ex.AccountBalance:C} more");
}

The InsufficientFundsException includes properties for balance and requested amount, giving callers detailed information about the error. This lets calling code provide specific feedback to users or implement retry logic based on the error details.

Using Exception Filters

Exception filters with when clauses let you catch exceptions conditionally based on properties or other runtime conditions. This is more efficient than catching and rethrowing because the filter evaluates before unwinding the stack.

Filters are useful when you want to handle specific scenarios of a broad exception type, such as HTTP errors with particular status codes or validation errors for specific fields.

ExceptionFilters.cs
using System;
using System.Net;
using System.Net.Http;

public class ApiClient
{
    public async Task<string> FetchDataAsync(string url)
    {
        try
        {
            using var client = new HttpClient();
            var response = await client.GetStringAsync(url);
            return response;
        }
        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
        {
            Console.WriteLine("Resource not found, returning default");
            return string.Empty;
        }
        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
        {
            Console.WriteLine("Unauthorized access, please authenticate");
            throw new InvalidOperationException("Authentication required", ex);
        }
        catch (HttpRequestException ex)
        {
            Console.WriteLine($"HTTP error: {ex.Message}");
            throw;
        }
    }

    public void ProcessValue(int value)
    {
        try
        {
            if (value < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(value), value,
                    "Value must be non-negative");
            }
            Console.WriteLine($"Processing {value}");
        }
        catch (ArgumentOutOfRangeException ex) when (ex.ParamName == nameof(value))
        {
            Console.WriteLine($"Invalid value parameter: {ex.ActualValue}");
        }
    }
}

The when clause filters HttpRequestException by status code, handling 404 and 401 differently without catching all HTTP errors. This makes the code more precise and easier to maintain than checking exception properties inside catch blocks.

Try It Yourself

Here's a complete example demonstrating exception handling in a file processing application with proper cleanup and custom exceptions.

Program.cs
using System;
using System.IO;

public class ConfigurationException : Exception
{
    public ConfigurationException(string message) : base(message) { }
    public ConfigurationException(string message, Exception inner)
        : base(message, inner) { }
}

public class ConfigReader
{
    public string ReadConfig(string path)
    {
        try
        {
            Console.WriteLine($"Reading config from: {path}");
            string content = File.ReadAllText(path);

            if (string.IsNullOrWhiteSpace(content))
            {
                throw new ConfigurationException("Configuration file is empty");
            }

            return content;
        }
        catch (FileNotFoundException ex)
        {
            throw new ConfigurationException(
                $"Config file not found: {path}", ex);
        }
        catch (UnauthorizedAccessException ex)
        {
            throw new ConfigurationException(
                "Cannot read config file: permission denied", ex);
        }
    }
}

// Test the exception handling
var reader = new ConfigReader();

try
{
    string config = reader.ReadConfig("config.txt");
    Console.WriteLine($"Config loaded: {config.Length} characters");
}
catch (ConfigurationException ex)
{
    Console.WriteLine($"Configuration error: {ex.Message}");
    if (ex.InnerException != null)
    {
        Console.WriteLine($"Caused by: {ex.InnerException.GetType().Name}");
    }
}

Console.WriteLine("\nTrying non-existent file:");
try
{
    reader.ReadConfig("missing.txt");
}
catch (ConfigurationException ex)
{
    Console.WriteLine($"Error: {ex.Message}");
}
Project.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output:

Reading config from: config.txt
Configuration error: Config file not found: config.txt
Caused by: FileNotFoundException

Trying non-existent file:
Reading config from: missing.txt
Error: Config file not found: missing.txt

Run with dotnet run. The custom ConfigurationException wraps lower-level exceptions with domain-specific context, making errors clearer to calling code. The inner exception preserves the original error details for debugging.

Exception Handling Best Practices

Only catch what you can handle: Don't catch exceptions just to log and rethrow. Let exceptions bubble up to code that has context to handle them meaningfully. Use global exception handlers at application boundaries for unhandled exceptions.

Be specific with exception types: Catch specific exceptions rather than the base Exception class whenever possible. This prevents accidentally catching and mishandling unexpected errors. Order catch blocks from most specific to most general.

Preserve stack traces when rethrowing: Use throw without the exception variable to maintain the original stack trace. This preserves crucial debugging information about where the error originated.

Use using statements for resource cleanup: Modern C# provides using declarations that automatically dispose resources. This is cleaner than try-finally blocks and ensures disposal even when exceptions occur. The compiler generates the finally block automatically.

Don't use exceptions for control flow: Exceptions are expensive because they involve stack unwinding and memory allocation. Use return values, nullable types, or result objects for expected error conditions. Reserve exceptions for truly exceptional situations.

Provide meaningful exception messages: Include context about what operation failed and why. Good messages help with debugging and let users understand what went wrong without seeing stack traces.

Frequently Asked Questions (FAQ)

What's the difference between throw and throw ex?

Using throw preserves the original stack trace, showing where the exception actually occurred. Using throw ex resets the stack trace to the current location, losing information about the original error source. Always use throw without the exception variable to preserve the full stack trace for debugging.

When should I create custom exception classes?

Create custom exceptions for domain-specific errors that need special handling. If your business logic has unique failure modes that callers should handle differently, custom exceptions make this explicit. Always inherit from Exception and follow naming conventions by ending class names with Exception.

Should I catch exceptions in every method?

No, only catch exceptions where you can handle them meaningfully. Let exceptions bubble up to code that has context to handle them properly. Catching and logging without handling adds noise and hides the actual problem. Use global exception handlers at application boundaries to catch unhandled exceptions.

What's the purpose of the finally block?

The finally block executes regardless of whether an exception occurred, providing a guaranteed place to clean up resources like files, database connections, or locks. Modern C# often uses using statements instead, which automatically generate finally blocks for IDisposable objects.

How do exception filters work with when clauses?

Exception filters with when clauses let you conditionally catch exceptions based on properties or conditions. The filter expression evaluates before unwinding the stack, allowing you to catch only specific error scenarios while letting others propagate. This is more efficient than catching and rethrowing.

Are there performance costs to exception handling?

Throwing and catching exceptions is relatively expensive because it involves stack unwinding, memory allocation, and building stack traces. Don't use exceptions for normal control flow. Reserve them for actual exceptional conditions. For expected error cases, use return values or result types instead.

Back to Articles