The Exception Hierarchy Dilemma
If you've ever caught every exception as catch (Exception ex) and wondered whether you should be catching SystemException or ApplicationException instead, you're not alone. The .NET exception hierarchy includes both types, but their intended purpose doesn't match how modern applications handle errors.
This article shows you the practical differences between these exception types and why current best practices recommend ignoring the SystemException/ApplicationException distinction entirely. You'll learn how to design custom exceptions that communicate intent clearly, when to catch broad exception types versus specific ones, and how to structure error handling that's both robust and maintainable.
You'll build a small exception hierarchy with proper constructors and domain-specific properties, then see how to catch and handle exceptions based on recoverability rather than base class. By the end, you'll understand why ApplicationException exists but why you shouldn't use it.
The Original Design Intent
When .NET Framework 1.0 launched, Microsoft designed two parallel exception hierarchies. SystemException would be the base for all runtime and CLR errors—things like NullReferenceException, ArgumentException, and InvalidOperationException. ApplicationException would be the base for all user-defined exceptions in application code.
The idea was that code could differentiate between "system" problems (runtime failures) and "application" problems (business rule violations). In practice, this distinction proved useless. Most frameworks and third-party libraries ignored ApplicationException, creating exceptions derived directly from Exception. The BCL itself wasn't consistent—some exceptions derive from SystemException, others don't follow the pattern.
// The .NET exception hierarchy (simplified)
// System.Object
// └── System.Exception
// ├── System.SystemException
// │ ├── ArgumentException
// │ ├── InvalidOperationException
// │ ├── NullReferenceException
// │ ├── IndexOutOfRangeException
// │ └── ... (most BCL exceptions)
// │
// ├── System.ApplicationException (Avoid - obsolete design)
// │
// └── Other exceptions (IOException, WebException, etc.)
// Modern recommendation: Derive from Exception directly
public class OrderNotFoundException : Exception
{
public int OrderId { get; }
public OrderNotFoundException(int orderId)
: base($"Order {orderId} not found")
{
OrderId = orderId;
}
public OrderNotFoundException(int orderId, Exception inner)
: base($"Order {orderId} not found", inner)
{
OrderId = orderId;
}
}
Microsoft's current guidance explicitly states: "Do not throw or derive from ApplicationException." The class remains in the BCL for backward compatibility, but new code should ignore it. This means the SystemException vs ApplicationException distinction is historical baggage, not a pattern to follow.
Creating Modern Custom Exceptions
When you need a custom exception, derive directly from Exception or from a more specific base that fits your domain. Include at least three constructors: a default constructor, one that accepts a message, and one that accepts a message and inner exception. This matches the pattern used throughout the BCL.
Add domain-specific properties to carry context about the failure. For a validation exception, include which field failed and what rule it violated. For a not-found exception, include the ID or query that failed. This makes logging and debugging significantly easier because the exception itself contains the relevant details.
// Domain-specific exception with proper structure
public class ValidationException : Exception
{
public string FieldName { get; }
public object? AttemptedValue { get; }
public string ValidationRule { get; }
public ValidationException()
{
}
public ValidationException(string message) : base(message)
{
}
public ValidationException(string message, Exception inner)
: base(message, inner)
{
}
public ValidationException(
string fieldName,
object? attemptedValue,
string validationRule)
: base($"Validation failed for {fieldName}: {validationRule}")
{
FieldName = fieldName;
AttemptedValue = attemptedValue;
ValidationRule = validationRule;
}
}
// Usage
public class Customer
{
private string _email = string.Empty;
public string Email
{
get => _email;
set
{
if (string.IsNullOrEmpty(value) || !value.Contains("@"))
{
throw new ValidationException(
nameof(Email),
value,
"Email must contain @ symbol");
}
_email = value;
}
}
}
The extra properties let you handle the exception programmatically. You can log the field name, display the attempted value in error messages, or even retry with corrected input. Without these properties, you'd need to parse the exception message with string operations—fragile and error-prone.
Catching Exceptions: Specific vs Broad
The question isn't "Should I catch SystemException or ApplicationException?" but rather "Which specific exceptions can I recover from?" Catch exceptions you know how to handle, not broad base classes. If you're calling a file operation, catch IOException. If you're parsing user input, catch FormatException. Don't catch Exception unless you're at an application boundary.
Application boundaries are places where exceptions must be translated for external callers: ASP.NET middleware, background job handlers, or WCF service boundaries. Here, catching Exception is acceptable because you need to log everything and return appropriate responses to clients. Even then, consider whether certain critical exceptions (like OutOfMemoryException) should crash the process rather than being silently logged.
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger<OrderService> _logger;
// Business logic: catch only expected, recoverable exceptions
public async Task<Order> GetOrderAsync(int orderId)
{
try
{
var order = await _repository.GetByIdAsync(orderId);
if (order == null)
{
throw new OrderNotFoundException(orderId);
}
return order;
}
catch (OrderNotFoundException)
{
// Expected exception - re-throw to let caller handle
throw;
}
catch (TimeoutException ex)
{
// Recoverable - log and retry or fail gracefully
_logger.LogWarning(ex, "Timeout loading order {OrderId}", orderId);
throw new ServiceUnavailableException("Order service timeout", ex);
}
// Don't catch Exception here - let unexpected errors bubble up
}
}
// Application boundary: catch broad exceptions for logging
public class GlobalExceptionMiddleware
{
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (ValidationException vex)
{
_logger.LogWarning(vex, "Validation failed");
context.Response.StatusCode = 400;
await context.Response.WriteAsJsonAsync(new
{
error = vex.Message,
field = vex.FieldName
});
}
catch (OrderNotFoundException nfex)
{
_logger.LogInformation(nfex, "Order not found");
context.Response.StatusCode = 404;
await context.Response.WriteAsJsonAsync(new { error = nfex.Message });
}
catch (Exception ex)
{
// Log everything else as errors
_logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new
{
error = "An error occurred"
});
}
}
}
Notice how the business logic only catches exceptions it can meaningfully handle, while the middleware catches all exceptions because it sits at the boundary between your app and HTTP clients. This layered approach keeps error handling close to where it's relevant.
Using Exception Filters for Conditional Handling
C# 6 introduced exception filters with the when keyword, letting you catch exceptions conditionally. This is cleaner than catching an exception and immediately re-throwing it if it doesn't match your criteria. Filters don't unwind the stack until they match, preserving better stack traces in debuggers.
public async Task<Product> GetProductAsync(int productId)
{
try
{
return await _api.GetProductAsync(productId);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
throw new ProductNotFoundException(productId, ex);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
_logger.LogWarning("API authentication failed");
throw new ApiAuthenticationException("API key expired", ex);
}
catch (HttpRequestException ex)
when ((int?)ex.StatusCode >= 500 && (int?)ex.StatusCode < 600)
{
// Server errors - might be transient
_logger.LogError(ex, "API server error");
throw new ServiceUnavailableException("Product API unavailable", ex);
}
// Other HttpRequestExceptions bubble up unhandled
}
// Without filters (worse - stack trace gets modified)
// try
// {
// return await _api.GetProductAsync(productId);
// }
// catch (HttpRequestException ex)
// {
// if (ex.StatusCode == HttpStatusCode.NotFound)
// throw new ProductNotFoundException(productId, ex);
// throw; // Stack trace point changes here
// }
Exception filters let you handle the same base exception type differently based on properties or state. This is particularly useful with HTTP exceptions, SQL exceptions, or any exception type that uses properties to differentiate error conditions.
Try It Yourself
Build a simple order processing system with custom exceptions that demonstrate proper hierarchy design. You'll see how domain-specific exception types make error handling clearer than relying on base classes.
Steps
- Create a new console app:
dotnet new console -n ExceptionDemo
- Change into the directory:
cd ExceptionDemo
- Replace Program.cs with the code below
- Run it:
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>
// Custom exceptions derived from Exception, not ApplicationException
public class OrderException : Exception
{
public int OrderId { get; }
public OrderException(int orderId, string message) : base(message)
{
OrderId = orderId;
}
}
public class OrderNotFoundException : OrderException
{
public OrderNotFoundException(int orderId)
: base(orderId, $"Order {orderId} not found")
{
}
}
public class InsufficientInventoryException : OrderException
{
public string ProductName { get; }
public int Available { get; }
public InsufficientInventoryException(
int orderId, string productName, int available)
: base(orderId, $"Insufficient inventory for {productName}")
{
ProductName = productName;
Available = available;
}
}
// Service demonstrating exception handling
public class OrderProcessor
{
public void ProcessOrder(int orderId, bool exists, bool hasInventory)
{
if (!exists)
throw new OrderNotFoundException(orderId);
if (!hasInventory)
throw new InsufficientInventoryException(orderId, "Widget", 0);
Console.WriteLine($"Order {orderId} processed successfully");
}
}
// Demo
var processor = new OrderProcessor();
try
{
processor.ProcessOrder(101, true, true);
}
catch (OrderException ex)
{
Console.WriteLine($"Order error: {ex.Message} (OrderId: {ex.OrderId})");
}
try
{
processor.ProcessOrder(102, false, true);
}
catch (OrderNotFoundException ex)
{
Console.WriteLine($"Not found: {ex.Message}");
}
try
{
processor.ProcessOrder(103, true, false);
}
catch (InsufficientInventoryException ex)
{
Console.WriteLine(
$"Inventory issue: {ex.Message}, Available: {ex.Available}");
}
Run result
Order 101 processed successfully
Not found: Order 102 not found
Inventory issue: Insufficient inventory for Widget, Available: 0
Avoiding Common Mistakes
Deriving from ApplicationException: This base class serves no purpose in modern .NET. Microsoft's own guidance says to avoid it. Derive from Exception or a domain-specific base like ValidationException. You're not gaining anything by using ApplicationException, and you're following an obsolete pattern that confuses other developers.
Catching Exception in business logic: When you catch Exception in the middle of your call stack, you're hiding bugs. A NullReferenceException in your code is a bug that should fail tests and get fixed, not caught and logged. Save broad exception catching for application boundaries where you need to translate all errors into external responses.
Throwing System.Exception directly: Always create a specific exception type, even if it just derives from Exception with no added properties. throw new Exception("User not found") forces callers to catch all exceptions or parse your message string. throw new UserNotFoundException(userId) lets callers handle it specifically and access the userId programmatically.
Not including standard constructors: Every custom exception should have at least three constructors: default, message, and message+inner. This matches the BCL pattern and ensures your exception can be used in all the same ways as built-in exceptions. Many serialization and logging frameworks expect these constructors to exist.
Swallowing exceptions without logging: Empty catch blocks catch { } are rarely correct. If you truly don't care about an exception, at least add a comment explaining why. Usually, you should log it even if you don't re-throw. Swallowed exceptions make debugging production issues nearly impossible.