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
throwandthrow 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
-
What's the difference between throw and throw ex?
throwpreserves the original stack trace.throw exresets the stack trace to the current location, losing valuable debugging information.
-
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.
-
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
usingstatements instead.
- Code in finally always executes whether exception occurs or not. Used for cleanup (closing files, connections). Modern C# often uses
-
What are exception filters?
- The
whenkeyword allows conditional catch blocks. Exception is only caught if the condition is true. Useful for handling same exception type differently based on state.
- The
-
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.
-
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.
-
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