The Catch-All Trap
It's tempting to wrap code in catch (Exception ex) to prevent crashes. It works—until it hides critical bugs like null references, threading errors, or out-of-memory conditions that should fail fast instead of silently continuing.
Catching the base Exception type has legitimate uses at application boundaries, but in business logic it often masks problems you need to see during development and testing. The key is knowing where broad catches are safe and where they're dangerous.
You'll learn when catching Exception makes sense, how to catch it safely with proper logging and rethrow patterns, and which exceptions should never be suppressed. By the end, you'll write exception handlers that protect production systems without hiding bugs.
Catch Specific Exceptions First
When you can handle an exception meaningfully, catch that specific type. This makes your intent clear: you expected this failure mode and have a recovery strategy. Specific catches also let other unexpected exceptions bubble up where they belong.
Catching specific exceptions improves code readability. Future maintainers see exactly which errors you anticipated. It also prevents accidentally handling exceptions you can't recover from.
namespace DataProcessing;
public class FileProcessor
{
public async Task<string> ReadConfigFileAsync(string path)
{
try
{
return await File.ReadAllTextAsync(path);
}
catch (FileNotFoundException)
{
// Expected: file might not exist yet
return CreateDefaultConfig();
}
catch (UnauthorizedAccessException ex)
{
// Expected: permission issues
throw new InvalidOperationException(
"Cannot access configuration file. Check permissions.", ex);
}
// IOException, OutOfMemoryException, etc. bubble up
}
private string CreateDefaultConfig()
{
return "{ \"version\": \"1.0\" }";
}
}
This code handles two specific exceptions it can deal with meaningfully. FileNotFoundException gets a default config. UnauthorizedAccessException wraps into a more descriptive error. Other exceptions like OutOfMemoryException propagate because there's no sensible recovery.
Catching Exception at Application Boundaries
At the outermost layers of your application—like ASP.NET middleware, Main methods, or background job harnesses—catching Exception prevents the entire process from crashing. These boundary handlers ensure errors are logged and responses are returned gracefully.
The critical rule: always log the full exception before swallowing it. Include stack traces, inner exceptions, and context. Even if you return a generic error to users, your logs need complete diagnostic information.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Global exception handler middleware
app.Use(async (context, next) =>
{
try
{
await next(context);
}
catch (Exception ex)
{
var logger = context.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "Unhandled exception during request {Path}",
context.Request.Path);
context.Response.StatusCode = 500;
await context.Response.WriteAsync(
"An error occurred processing your request.");
}
});
app.MapGet("/", () => "Hello World!");
app.Run();
This middleware catches all exceptions to prevent application crashes. It logs the full exception with context, sets an appropriate status code, and returns a safe message. Users get feedback while operators get full diagnostics in logs.
Using Exception Filters for Conditional Handling
Exception filters let you catch specific exception types only when additional conditions are met. The when clause evaluates before the catch block runs. If the filter returns false, the exception continues up the stack without unwinding.
This preserves stack traces better than catching and rethrowing. It also makes your intent explicit: you're only handling this exception under specific circumstances.
namespace HttpClients;
public class ApiClient
{
private readonly HttpClient _httpClient;
public ApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<Product?> GetProductAsync(int id)
{
try
{
var response = await _httpClient.GetAsync($"/api/products/{id}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Product>();
}
catch (HttpRequestException ex)
when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Only catch 404s - return null for missing products
return null;
}
// Other HttpRequestExceptions (500, 401, etc.) propagate
}
}
public record Product(int Id, string Name, decimal Price);
The when clause ensures we only catch 404 errors. Network failures, server errors, or authentication problems propagate because we can't handle them here. This pattern is clearer than catching and checking the status code inside the handler.
Never Suppress Critical Exceptions
Some exceptions indicate process-level corruption or resource exhaustion. Even when catching Exception, check for these critical types and rethrow immediately. Attempting to continue after OutOfMemoryException or StackOverflowException can corrupt state further.
Modern .NET prevents catching some critical exceptions entirely, but you should still write defensive code. Always rethrow exceptions you can't meaningfully handle.
namespace Utilities;
public class SafeExecutor
{
private readonly ILogger<SafeExecutor> _logger;
public SafeExecutor(ILogger<SafeExecutor> logger)
{
_logger = logger;
}
public async Task<bool> TryExecuteAsync(Func<Task> operation)
{
try
{
await operation();
return true;
}
catch (Exception ex)
{
// Never suppress critical exceptions
if (IsCriticalException(ex))
{
_logger.LogCritical(ex, "Critical exception - rethrowing");
throw;
}
// Log and swallow non-critical exceptions
_logger.LogError(ex, "Operation failed but continuing");
return false;
}
}
private static bool IsCriticalException(Exception ex)
{
return ex is OutOfMemoryException
or StackOverflowException
or AccessViolationException;
}
}
This helper wraps operations and returns false on failure instead of throwing. But it still rethrows critical exceptions that indicate corrupted process state. Logging separately for critical exceptions helps operators identify catastrophic failures quickly.
Mistakes to Avoid
Swallowing exceptions silently: Empty catch blocks or catching without logging hides bugs completely. You'll never know the code is failing. Always log exceptions before suppressing them, even if you believe they're harmless.
Rethrowing with throw ex: Using throw ex; instead of throw; resets the stack trace, losing valuable debugging information. The parameterless throw preserves the original stack while adding your handler's context.
Catching Exception in libraries: Library code should let exceptions propagate unless it can add meaningful context. Wrapping exceptions in catch-all blocks forces callers to dig through layers of InnerException to find root causes.
Ignoring OperationCanceledException: When using CancellationToken, don't catch and suppress OperationCanceledException. It's not an error—it's a control flow signal. Let it propagate or handle it specifically if you need cleanup.
Try It Yourself
Build a small program that demonstrates safe exception handling patterns. You'll see how specific catches, filters, and proper logging work together to handle errors gracefully without hiding bugs.
Steps
- Initialize a new project:
dotnet new console -n ExceptionDemo
- Move into the folder:
cd ExceptionDemo
- Replace Program.cs with the implementation below
- Update the .csproj as shown
- Run with
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console.WriteLine("=== Exception Handling Patterns ===\n");
// Pattern 1: Specific exception handling
SafeOperation("Specific catch", () =>
{
int[] numbers = { 1, 2, 3 };
return numbers[5]; // IndexOutOfRangeException
});
// Pattern 2: Exception filter
SafeOperation("Exception filter", () =>
{
throw new InvalidOperationException("Not ready");
});
// Pattern 3: Critical exception (simulated)
SafeOperation("Critical check", () =>
{
throw new OutOfMemoryException("Simulated OOM");
});
static void SafeOperation(string name, Func<int> operation)
{
try
{
var result = operation();
Console.WriteLine($"{name}: Success - {result}");
}
catch (IndexOutOfRangeException ex)
{
Console.WriteLine($"{name}: Caught index error - {ex.Message}");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("ready"))
{
Console.WriteLine($"{name}: Not ready - using filter");
}
catch (Exception ex) when (!IsCritical(ex))
{
Console.WriteLine($"{name}: Caught non-critical - {ex.GetType().Name}");
}
// Critical exceptions propagate (would crash here)
}
static bool IsCritical(Exception ex) =>
ex is OutOfMemoryException or StackOverflowException;
What you'll see
=== Exception Handling Patterns ===
Specific catch: Caught index error - Index was outside the bounds of the array.
Exception filter: Not ready - using filter
Unhandled exception. System.OutOfMemoryException: Simulated OOM
at Program...
The first two operations handle exceptions safely. The third demonstrates critical exception handling—in real code, OutOfMemoryException would propagate and terminate the process, which is correct behavior.