C# Exception Handling — throw, custom exceptions, and exception filters

Basic try/catch/finally handles most exceptions, but production code needs more: meaningful custom exception types, correct rethrowing, and exception filters to avoid catching what you can't handle.

throw vs rethrow — Preserving the Stack Trace

Rethrowing an exception with throw ex resets the stack trace, losing the original call site. Use bare throw to preserve it.

C# Example Code
// BAD — stack trace is reset to this catch block
try { DoWork(); }
catch (Exception ex) { throw ex; }   // ❌ loses original stack trace

// GOOD — original stack trace preserved
try { DoWork(); }
catch (Exception ex) { throw; }      // ✅

// ALSO GOOD — wrap with context, pass original as inner exception
try { DoWork(); }
catch (Exception ex)
{
    throw new InvalidOperationException("DoWork failed during startup.", ex); // ✅
}

Custom Exception Classes

Create custom exceptions when callers need to handle your specific failure mode separately from general errors.

C# Example Code
// Minimal custom exception
public class OrderNotFoundException : Exception
{
    public int OrderId { get; }

    public OrderNotFoundException(int orderId)
        : base($"Order {orderId} was not found.")
    {
        OrderId = orderId;
    }

    // Always implement this constructor for serialization support
    public OrderNotFoundException(int orderId, Exception inner)
        : base($"Order {orderId} was not found.", inner)
    {
        OrderId = orderId;
    }
}

Throw and catch the custom exception:

C# Example Code
Order GetOrder(int id)
{
    var order = _db.Orders.Find(id);
    if (order is null)
        throw new OrderNotFoundException(id);
    return order;
}

try
{
    var order = GetOrder(42);
}
catch (OrderNotFoundException ex)
{
    Console.WriteLine($"Caught: {ex.Message}");   // Caught: Order 42 was not found.
    Console.WriteLine($"Order ID: {ex.OrderId}"); // Order ID: 42
}

Exception Filters — when Clause

Exception filters let you catch only when a condition is true, without catching and rethrowing. The stack trace stays intact and other handlers get a chance to run.

C# Example Code
try
{
    ProcessRequest(request);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    // Only catches 429 — other HttpRequestExceptions fall through
    await Task.Delay(TimeSpan.FromSeconds(5));
    await ProcessRequest(request); // retry
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
    await RefreshTokenAsync();
}
catch (HttpRequestException)
{
    // Catches any remaining HttpRequestException
    throw;
}

Exception filters also work with logging side-effects — the when clause runs even if the exception is not caught:

C# Example Code
try { DoWork(); }
catch (Exception ex) when (LogException(ex))
{
    // LogException should return false so this block never executes
    // but the logging side-effect always runs
}

bool LogException(Exception ex)
{
    _logger.LogError(ex, "Unhandled exception in DoWork");
    return false;   // let the exception propagate
}

Exception Hierarchy — Only Catch What You Can Handle

C# Example Code
try
{
    string text = File.ReadAllText(path);
    int value   = int.Parse(text.Trim());
    Process(value);
}
catch (FileNotFoundException ex)
{
    Console.Error.WriteLine($"Config file missing: {ex.FileName}");
    // Recoverable — use default config
    Process(defaultValue);
}
catch (FormatException)
{
    Console.Error.WriteLine("Config file contains invalid number.");
    throw;  // Not recoverable here — let it bubble up
}
// Don't catch IOException or Exception unless you can handle every subtype

AggregateException — async and Task.WhenAll

When using Task.WhenAll, exceptions from multiple tasks are wrapped in AggregateException.

C# Example Code
try
{
    await Task.WhenAll(taskA, taskB, taskC);
}
catch (Exception ex) when (ex is not AggregateException)
{
    // await unwraps the first inner exception automatically
    Console.WriteLine($"First failure: {ex.Message}");
}

// Inspect all failures
if (taskA.IsFaulted || taskB.IsFaulted || taskC.IsFaulted)
{
    foreach (var t in new[] { taskA, taskB, taskC }.Where(t => t.IsFaulted))
    foreach (var inner in t.Exception!.InnerExceptions)
        Console.WriteLine(inner.Message);
}

Real-World Example: Validation Exception with Error Details

C# Example Code
public class ValidationException : Exception
{
    public IReadOnlyList<string> Errors { get; }

    public ValidationException(IEnumerable<string> errors)
        : base("One or more validation errors occurred.")
    {
        Errors = errors.ToList().AsReadOnly();
    }
}

void ValidateOrder(Order order)
{
    var errors = new List<string>();

    if (order.Quantity <= 0)
        errors.Add("Quantity must be greater than zero.");
    if (string.IsNullOrWhiteSpace(order.CustomerEmail))
        errors.Add("Customer email is required.");
    if (order.TotalAmount < 0)
        errors.Add("Total amount cannot be negative.");

    if (errors.Count > 0)
        throw new ValidationException(errors);
}

try
{
    ValidateOrder(new Order { Quantity = -1, TotalAmount = -5 });
}
catch (ValidationException ex)
{
    Console.WriteLine(ex.Message);
    foreach (var error in ex.Errors)
        Console.WriteLine($"  - {error}");
}
// One or more validation errors occurred.
//   - Quantity must be greater than zero.
//   - Customer email is required.
//   - Total amount cannot be negative.

Quick Reference

PatternWhen to use
catch (MyEx ex) { throw; }Log then rethrow without losing stack trace
throw new WrapEx("msg", ex)Add context, preserve inner exception
catch (Ex ex) when (condition)Catch only specific cases without resetting stack
Custom exception classCallers need to distinguish your error from others
AggregateException.InnerExceptionsInspect all failures after Task.WhenAll
Catch the most specific type firstPrevents swallowing exceptions you can't handle