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>();

When to Use vs. When to Avoid

| Scenario | Use It? | Why | |----------|---------|-----| | External API/database calls | ✅ Yes | Network operations fail unpredictably | | User input validation | ❌ No | Use validation with return values instead | | File operations | ✅ Yes | Files can be missing, locked, or corrupted | | Null checks | ❌ No | Use null-conditional operators or validation | | Expected business rule violations | ⚠️ Depends | Consider Result pattern for expected failures |

Common Patterns

Global Exception Handler in ASP.NET Core

app.UseExceptionHandler(options =>
{
    options.Run(async context =>
    {
        var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(new { error = "An error occurred" });
    });
});

Common Mistakes

Mistake: Swallowing Exceptions

// ❌ Bad - hides errors, makes debugging impossible
try
{
    var result = PerformCalculation();
}
catch (Exception)
{
    // Silently swallow - errors are hidden forever!
}

// ✅ Better - log and rethrow or handle meaningfully
try
{
    var result = PerformCalculation();
}
catch (Exception ex)
{
    _logger.LogError(ex, "Calculation failed");
    throw;  // Preserves stack trace
}

Why: Silent exceptions hide bugs, make debugging impossible, and leave the system in unknown states.

Mistake: Using throw ex Instead of throw

// ❌ Bad - resets stack trace
catch (Exception ex)
{
    _logger.LogError(ex, "Error");
    throw ex;  // Stack trace starts here, not at original error!
}

// ✅ Better - preserves original stack trace
catch (Exception ex)
{
    _logger.LogError(ex, "Error");
    throw;  // Full stack trace preserved
}

Why: throw ex resets the stack trace, making it impossible to find where the error actually originated.

Mistake: Using Exceptions for Flow Control

// ❌ Bad - exceptions are expensive (~1000x slower)
public bool UserExists(int id)
{
    try
    {
        var user = _repository.GetById(id);
        return true;
    }
    catch (KeyNotFoundException) { return false; }
}

// ✅ Better - use return values for expected cases
public bool UserExists(int id)
{
    return _repository.TryGetById(id, out _);
}

Why: Exceptions are expensive (stack trace capture, unwinding). Use them for exceptional cases, not expected outcomes.

Practical Example

Scenario: Implement a service method that handles errors gracefully and provides meaningful feedback.

public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request)
{
    try
    {
        // Validation - don't use exceptions for expected failures
        if (request.Items.Count == 0)
            return Result<Order>.Fail("Order must contain at least one item");

        var order = new Order { CustomerId = request.CustomerId };

        // External calls - wrap in try-catch
        try
        {
            await _paymentService.ChargeAsync(request.PaymentMethod, order.Total);
        }
        catch (PaymentException ex)
        {
            _logger.LogWarning(ex, "Payment failed for order");
            return Result<Order>.Fail($"Payment declined: {ex.Message}");
        }

        await _orderRepository.AddAsync(order);
        return Result<Order>.Ok(order);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected error creating order");
        return Result<Order>.Fail("An unexpected error occurred");
    }
}

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.

Related Topics

  • Logging in ASP.NET Core: Structured logging with Serilog, log levels, correlation IDs
  • Result Pattern: Alternative to exceptions for expected failures
  • Polly Resilience Library: Retry, circuit breaker, timeout policies
  • Unit Testing: Testing exception scenarios with Assert.Throws