Exceptions represent runtime errors. C# uses structured exception handling with try, catch, finally, and throw.

Basic try-catch

  try
{
    int result = Divide(10, 0);
    Console.WriteLine(result);
}
catch (DivideByZeroException ex)
{
    Console.WriteLine($"Math error: {ex.Message}");
}
catch (Exception ex)
{
    Console.WriteLine($"Unexpected: {ex.Message}");
}
finally
{
    Console.WriteLine("Cleanup always runs");
}

static int Divide(int a, int b) => a / b;
  

Throwing Exceptions

  void ValidateAge(int age)
{
    if (age < 0)
        throw new ArgumentOutOfRangeException(nameof(age), "Age cannot be negative");
    if (age > 150)
        throw new ArgumentException("Age seems unrealistic", nameof(age));
}

try
{
    ValidateAge(-5);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.ParamName);  // age
}
  

Custom Exceptions

  class InsufficientFundsException : Exception
{
    public decimal Balance { get; }

    public InsufficientFundsException(decimal balance)
        : base($"Insufficient funds. Balance: {balance:C}")
    {
        Balance = balance;
    }
}

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

Exception Filters

  try
{
    ProcessData(null!);
}
catch (ArgumentNullException ex) when (ex.ParamName == "data")
{
    Console.WriteLine("Data was null");
}
  

Best Practices

  • Catch specific exceptions, not bare Exception, when possible.
  • Do not use exceptions for normal control flow.
  • Include meaningful messages and preserve inner exceptions.
  • Use throw; to re-throw without resetting the stack trace.
  catch (IOException ex)
{
    Log(ex);
    throw;  // preserves stack trace
}
  

For expected failures (file not found, invalid input), consider returning Result<T> types or nullable values instead of throwing.

AggregateException in Parallel Code

  try
{
    Parallel.ForEach(items, item => Process(item));
}
catch (AggregateException ae)
{
    foreach (var ex in ae.InnerExceptions)
        Console.WriteLine(ex.Message);
}
  

try-finally Without catch

  FileStream? stream = null;
try
{
    stream = File.OpenRead("data.bin");
    // process stream
}
finally
{
    stream?.Dispose();
}
  

Prefer using or await using over manual finally blocks.

Exception Properties

  catch (SqlException ex)
{
    Console.WriteLine(ex.Message);     // human-readable
    Console.WriteLine(ex.StackTrace);  // call stack
    Console.WriteLine(ex.InnerException?.Message);  // wrapped cause
}
  

Log all three in production error handlers.

Fail Fast vs Graceful Degradation

  // Fail fast — configuration errors at startup
var connString = config.GetConnectionString("Default")
    ?? throw new InvalidOperationException("Connection string missing");

// Graceful — optional feature unavailable
try { await SendNotificationAsync(user); }
catch (NotificationException ex)
{
    logger.LogWarning(ex, "Notification failed — continuing");
}
  

Testing Exceptions with xUnit

  [Fact]
public void ValidateAge_ThrowsForNegative()
{
    var ex = Assert.Throws<ArgumentOutOfRangeException>(
        () => ValidateAge(-1));
    Assert.Equal("age", ex.ParamName);
}