Exception Handling Best Practices in .NET

Master exception handling patterns, custom exceptions, exception filters, and global error handling in .NET and ASP.NET Core applications

Exception Handling
Error Management
ASP.NET Core
Best Practices
20 min read

Overview

Understanding exception handling deeply is critical for building production-ready applications. This includes knowing when to catch, when to throw, how to create custom exceptions, and how to implement global error handling in ASP.NET Core.

Core Concepts

Try-Catch-Finally Patterns

// Basic pattern
public void ProcessFile(string path)
{
    FileStream file = null;
    try
    {
        file = File.OpenRead(path);
        // Process file
    }
    catch (FileNotFoundException ex)
    {
        _logger.LogWarning(ex, "File not found: {Path}", path);
        // Handle missing file
    }
    catch (IOException ex)
    {
        _logger.LogError(ex, "IO error reading file: {Path}", path);
        throw;  // Can't handle, let caller deal with it
    }
    finally
    {
        // Always runs - cleanup
        file?.Dispose();
    }
}

// Modern using statement (preferred)
public void ProcessFileModern(string path)
{
    try
    {
        using var file = File.OpenRead(path);  // Auto-disposes
        // Process file
    }
    catch (FileNotFoundException ex)
    {
        _logger.LogWarning(ex, "File not found: {Path}", path);
    }
}

Custom Exceptions

// Domain-specific exception
public class InsufficientFundsException : Exception
{
    public decimal CurrentBalance { get; }
    public decimal RequestedAmount { get; }

    public InsufficientFundsException(decimal currentBalance, decimal requestedAmount)
        : base($"Insufficient funds. Balance: {currentBalance:C}, Requested: {requestedAmount:C}")
    {
        CurrentBalance = currentBalance;
        RequestedAmount = requestedAmount;
    }

    // Required for serialization
    protected InsufficientFundsException(
        System.Runtime.Serialization.SerializationInfo info,
        System.Runtime.Serialization.StreamingContext context)
        : base(info, context)
    {
    }
}

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

    public void Withdraw(decimal amount)
    {
        if (amount > Balance)
            throw new InsufficientFundsException(Balance, amount);

        Balance -= amount;
    }
}

// Caller
try
{
    account.Withdraw(1000m);
}
catch (InsufficientFundsException ex)
{
    _logger.LogWarning(
        "Withdrawal failed. Balance: {Balance}, Requested: {Amount}",
        ex.CurrentBalance,
        ex.RequestedAmount);

    // Show user-friendly message
    return BadRequest($"Insufficient funds. Available: {ex.CurrentBalance:C}");
}

Exception Filters (when keyword)

// Catch only when condition is true
public async Task<IActionResult> ProcessOrder(Order order)
{
    try
    {
        await _orderService.ProcessAsync(order);
        return Ok();
    }
    catch (InvalidOperationException ex) when (ex.Message.Contains("inventory"))
    {
        // Handle inventory-specific errors
        _logger.LogWarning(ex, "Inventory issue processing order");
        return BadRequest("Item out of stock");
    }
    catch (InvalidOperationException ex) when (ex.Message.Contains("payment"))
    {
        // Handle payment-specific errors
        _logger.LogError(ex, "Payment processing failed");
        return BadRequest("Payment declined");
    }
    catch (InvalidOperationException ex)
    {
        // Handle other InvalidOperationException
        _logger.LogError(ex, "Order processing failed");
        return StatusCode(500, "Processing error");
    }
}

// Filter with method
public async Task<User> GetUserAsync(int id)
{
    try
    {
        return await _repository.GetByIdAsync(id);
    }
    catch (Exception ex) when (ShouldRetry(ex))
    {
        // Retry logic
        await Task.Delay(1000);
        return await _repository.GetByIdAsync(id);
    }
}

private bool ShouldRetry(Exception ex)
{
    return ex is TimeoutException ||
           ex is HttpRequestException;
}

Global Error Handling in ASP.NET Core

// Exception handler middleware
public class GlobalExceptionHandler : IMiddleware
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception occurred");
            await HandleExceptionAsync(context, ex);
        }
    }

    private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var (statusCode, message) = exception switch
        {
            ArgumentException => (400, exception.Message),
            KeyNotFoundException => (404, "Resource not found"),
            UnauthorizedAccessException => (401, "Unauthorized"),
            InsufficientFundsException => (400, exception.Message),
            _ => (500, "Internal server error")
        };

        context.Response.StatusCode = statusCode;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsJsonAsync(new
        {
            error = message,
            statusCode
        });
    }
}

// Register in Program.cs
builder.Services.AddTransient<GlobalExceptionHandler>();
var app = builder.Build();
app.UseMiddleware<GlobalExceptionHandler>();

Bad vs Good Examples

Bad: Swallowing Exceptions

// Bad - hides errors, makes debugging impossible
public void ProcessData()
{
    try
    {
        // Complex operation
        var result = PerformCalculation();
    }
    catch (Exception)
    {
        // Silently swallow - BAD!
    }
}

// Bad - losing stack trace
try
{
    DoWork();
}
catch (Exception ex)
{
    throw ex;  // BAD! Resets stack trace
}

Good: Proper Exception Handling

// Good - log and re-throw
public void ProcessData()
{
    try
    {
        var result = PerformCalculation();
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Calculation failed");
        throw;  // Preserves stack trace
    }
}

// Good - wrap with more context
try
{
    DoWork();
}
catch (Exception ex)
{
    throw new ApplicationException("Work failed during processing", ex);
}

Bad: Catching Too Broadly

// Bad - catches everything including serious errors
try
{
    ProcessOrder(order);
}
catch (Exception)
{
    // Catches OutOfMemoryException, StackOverflowException, etc.
    return "Order failed";
}

Good: Catch Specific Exceptions

// Good - only catch what you can handle
try
{
    ProcessOrder(order);
}
catch (InvalidOperationException ex)
{
    _logger.LogWarning(ex, "Invalid order state");
    return "Invalid order";
}
catch (ArgumentException ex)
{
    _logger.LogWarning(ex, "Invalid order data");
    return "Invalid data";
}
// Let critical exceptions (OutOfMemory, etc.) propagate

Bad: Using Exceptions for Flow Control

// Bad - exceptions are expensive
public bool UserExists(int id)
{
    try
    {
        var user = _repository.GetById(id);
        return true;
    }
    catch (KeyNotFoundException)
    {
        return false;  // Using exception for flow control
    }
}

Good: Use Return Values for Flow Control

// Good - fast, clear intent
public bool UserExists(int id)
{
    return _repository.TryGetById(id, out _);
}

// Or
public async Task<User?> TryGetUserAsync(int id)
{
    var user = await _repository.FindByIdAsync(id);
    return user;  // Returns null if not found
}

Interview Tips

Tip 1: Always explain the difference between throw and throw ex - the former preserves stack trace, the latter resets it.

Tip 2: Mention that exceptions are expensive - use them for exceptional cases, not normal flow control.

Tip 3: Know when to catch vs when to let exceptions propagate. Catch only if you can meaningfully handle the error.

Tip 4: In ASP.NET Core, discuss both exception middleware and exception filters for global error handling.

Common Interview Questions

  1. What's the difference between throw and throw ex?

    • throw preserves the original stack trace. throw ex resets the stack trace to the current location, losing valuable debugging information.
  2. When should you create custom exceptions?

    • When you need domain-specific error information, when built-in exceptions don't clearly express the error, or when you need to include additional data in the exception.
  3. What is the purpose of finally block?

    • Code in finally always executes whether exception occurs or not. Used for cleanup (closing files, connections). Modern C# often uses using statements instead.
  4. What are exception filters?

    • The when keyword allows conditional catch blocks. Exception is only caught if the condition is true. Useful for handling same exception type differently based on state.
  5. How do you implement global error handling in ASP.NET Core?

    • Use exception handling middleware (app.UseExceptionHandler), custom middleware, or exception filters. For APIs, return consistent error response format with status codes.
  6. Should you catch Exception (base class)?

    • Generally no - catch specific exceptions you can handle. Catching Exception can hide bugs and catch critical exceptions (OutOfMemoryException) that shouldn't be handled.
  7. How expensive are exceptions in .NET?

    • Very expensive - stack trace capture, unwinding, etc. Can be 1000x slower than normal return. Don't use for flow control or validation.