Skip to main content

C# Exception Hierarchy

Introduction

When developing applications in C#, dealing with errors and unexpected situations is inevitable. The C# language provides a robust mechanism for handling these situations through exceptions. Understanding the exception hierarchy is crucial for writing code that gracefully handles errors and maintains application stability.

In this guide, we'll explore the structure of C#'s exception hierarchy, understand the relationships between different exception types, and learn how to use this knowledge to implement effective exception handling strategies.

The Base: System.Exception

At the root of all C# exceptions is the System.Exception class. Every exception in C# inherits either directly or indirectly from this base class, which provides the fundamental properties and methods that all exceptions share.

Key properties of the System.Exception class include:

  • Message: A human-readable description of the error
  • StackTrace: A string representing the call stack at the time the exception was thrown
  • InnerException: The exception that caused the current exception (if any)
  • HResult: A numeric error code (primarily for interoperability)

Here's a basic example showing how to access these properties:

csharp
try
{
// Code that might cause an exception
int result = 10 / 0;
}
catch (Exception ex)
{
Console.WriteLine($"Exception Message: {ex.Message}");
Console.WriteLine($"Stack Trace: {ex.StackTrace}");
Console.WriteLine($"Inner Exception: {ex.InnerException?.Message ?? "None"}");
Console.WriteLine($"HResult: {ex.HResult}");
}

Output:

Exception Message: Attempted to divide by zero.
Stack Trace: at ConsoleApp.Program.Main() in C:\Projects\Program.cs:line 11
Inner Exception: None
HResult: -2147352558

First-Level Hierarchy

Directly beneath System.Exception, the .NET Framework defines two main categories of exceptions:

1. System.SystemException

These are exceptions thrown by the Common Language Runtime (CLR) or that are predefined in the .NET Framework. Examples include:

  • NullReferenceException
  • IndexOutOfRangeException
  • DivideByZeroException
  • ArgumentException
  • InvalidCastException

2. System.ApplicationException

This was originally intended for exceptions defined by application programmers to differentiate between system exceptions and application exceptions. However, Microsoft now recommends deriving custom exceptions directly from System.Exception instead of ApplicationException.

Key Exception Classes in the Hierarchy

Let's explore some important exception types you'll frequently encounter:

ArgumentException Family

These exceptions are thrown when a method receives an invalid argument.

csharp
// ArgumentException: Base class for argument exceptions
public void ProcessName(string name)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException("Name cannot be null or empty", nameof(name));
}
// Process the name...
}

// ArgumentNullException: More specific than ArgumentException
public void RegisterUser(User user)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user), "User cannot be null");
}
// Register the user...
}

// ArgumentOutOfRangeException: For arguments outside expected range
public void SetAge(int age)
{
if (age < 0 || age > 120)
{
throw new ArgumentOutOfRangeException(nameof(age), "Age must be between 0 and 120");
}
// Set the age...
}

InvalidOperationException

Thrown when a method call is invalid in an object's current state:

csharp
public class FileProcessor
{
private bool _isInitialized = false;

public void Initialize()
{
// Initialization code...
_isInitialized = true;
}

public void ProcessFile(string filePath)
{
if (!_isInitialized)
{
throw new InvalidOperationException("FileProcessor must be initialized before processing files");
}
// Process the file...
}
}

NullReferenceException

This occurs when you try to access members of an object that is null:

csharp
// This code will throw a NullReferenceException
string name = null;
int length = name.Length; // NullReferenceException thrown here

It's generally better to use null checking to prevent these exceptions rather than catching them:

csharp
string name = null;
int length = name?.Length ?? 0; // Null conditional operator prevents exception

FormatException

Thrown when the format of an argument does not meet the parameter specifications:

csharp
try
{
string input = "not_a_number";
int number = int.Parse(input); // This will throw a FormatException
}
catch (FormatException ex)
{
Console.WriteLine($"Invalid format: {ex.Message}");
// A better approach would be to use TryParse:
if (int.TryParse(input, out int result))
{
Console.WriteLine($"Parsed number: {result}");
}
else
{
Console.WriteLine("Could not parse the input as a number");
}
}

IOException and Derived Classes

These exceptions are thrown when I/O operations fail:

csharp
try
{
// Attempt to read from a file that might not exist
string content = File.ReadAllText("nonexistent_file.txt");
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"File not found: {ex.FileName}");
}
catch (IOException ex)
{
Console.WriteLine($"I/O error: {ex.Message}");
}

Visual Representation of the Hierarchy

Here's a simplified view of the C# exception hierarchy:

System.Object
└── System.Exception
├── System.SystemException
│ ├── System.ArithmeticException
│ │ └── System.DivideByZeroException
│ ├── System.ArrayTypeMismatchException
│ ├── System.IndexOutOfRangeException
│ ├── System.InvalidCastException
│ ├── System.NullReferenceException
│ ├── System.ArgumentException
│ │ ├── System.ArgumentNullException
│ │ ├── System.ArgumentOutOfRangeException
│ │ └── System.DuplicateWaitObjectException
│ ├── System.OutOfMemoryException
│ └── System.InvalidOperationException
│ └── System.ObjectDisposedException
└── System.ApplicationException (not recommended for use)
└── Custom Exceptions (derive directly from Exception)

Creating Custom Exceptions

When creating your own exceptions, it's best practice to:

  1. Derive directly from Exception or an appropriate existing exception class
  2. End the class name with "Exception"
  3. Make the exception serializable
  4. Provide constructors that match the standard exception pattern

Here's an example of a well-designed custom exception:

csharp
using System;
using System.Runtime.Serialization;

[Serializable]
public class UserNotFoundException : Exception
{
public string Username { get; }

public UserNotFoundException() : base() { }

public UserNotFoundException(string message) : base(message) { }

public UserNotFoundException(string message, Exception innerException)
: base(message, innerException) { }

public UserNotFoundException(string message, string username)
: base(message)
{
Username = username;
}

// This constructor is needed for serialization
protected UserNotFoundException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
Username = info.GetString(nameof(Username));
}

// Override GetObjectData for serialization
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue(nameof(Username), Username);
}
}

Using the custom exception:

csharp
public User FindUser(string username)
{
User user = _repository.GetUserByName(username);

if (user == null)
{
throw new UserNotFoundException($"User '{username}' was not found in the database", username);
}

return user;
}

// Usage with proper exception handling
try
{
User user = FindUser("johndoe");
Console.WriteLine($"Found user: {user.Name}");
}
catch (UserNotFoundException ex)
{
Console.WriteLine($"Error: {ex.Message}");
Console.WriteLine($"Username that caused the error: {ex.Username}");
}

Real-World Application: Layered Exception Handling

In real-world applications, effective exception handling often involves multiple layers. Let's look at a practical example of a web application with a database:

csharp
// Data access layer
public class UserRepository
{
public User GetUserById(int id)
{
try
{
// Database access code here
using (var connection = new SqlConnection(_connectionString))
{
connection.Open();
// Execute query...
// If user not found, return null
}
}
catch (SqlException ex)
{
// Translate data access specific exception to a more generic one
throw new DatabaseAccessException("Error accessing user database", ex);
}
return null;
}
}

// Business logic layer
public class UserService
{
private readonly UserRepository _repository;

public UserService(UserRepository repository)
{
_repository = repository;
}

public User GetActiveUser(int id)
{
try
{
var user = _repository.GetUserById(id);

if (user == null)
{
throw new UserNotFoundException($"User with ID {id} not found", id.ToString());
}

if (!user.IsActive)
{
throw new InactiveUserException($"User with ID {id} is not active", user);
}

return user;
}
catch (DatabaseAccessException)
{
// Rethrow database exceptions
throw;
}
catch (Exception ex) when (!(ex is UserNotFoundException || ex is InactiveUserException))
{
// Log unexpected errors and convert to business exception
_logger.LogError(ex, "Unexpected error getting user {UserId}", id);
throw new UserOperationException($"Failed to retrieve user with ID {id}", ex);
}
}
}

// API/Presentation layer
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly UserService _userService;

public UsersController(UserService userService)
{
_userService = userService;
}

[HttpGet("{id}")]
public ActionResult<UserDto> GetUser(int id)
{
try
{
var user = _userService.GetActiveUser(id);
return Ok(MapToDto(user));
}
catch (UserNotFoundException ex)
{
return NotFound(new { message = ex.Message });
}
catch (InactiveUserException ex)
{
return BadRequest(new { message = ex.Message });
}
catch (DatabaseAccessException ex)
{
// Log database error
return StatusCode(503, new { message = "Service temporarily unavailable" });
}
catch (Exception ex)
{
// Log unexpected error
return StatusCode(500, new { message = "An unexpected error occurred" });
}
}
}

This example demonstrates how exceptions can be:

  1. Created at the appropriate layer
  2. Translated between layers to hide implementation details
  3. Handled differently based on their type
  4. Used to return appropriate HTTP status codes in an API context

Best Practices for Working with the Exception Hierarchy

  1. Catch Specific Exceptions First: Always arrange catch blocks from most specific to most general.
csharp
try
{
// Code that might throw exceptions
}
catch (ArgumentNullException ex)
{
// Handle null argument
}
catch (ArgumentException ex)
{
// Handle other argument problems
}
catch (Exception ex)
{
// Handle any other exception
}
  1. Don't Catch Exceptions You Can't Handle: Only catch exceptions if you can meaningfully handle them.

  2. Don't Swallow Exceptions: Never use empty catch blocks that hide errors.

csharp
// Bad practice
try
{
// Code
}
catch (Exception) { } // Don't do this!

// Better practice
try
{
// Code
}
catch (Exception ex)
{
// Log the error
_logger.LogError(ex, "An error occurred");
// Either handle it or rethrow
throw;
}
  1. Use Exception Filters: In C# 6 and later, use exception filters to conditionally catch exceptions.
csharp
try
{
// Code that might throw
}
catch (Exception ex) when (ex.Message.Contains("Timeout"))
{
// Handle timeout-related exceptions
}
catch (Exception ex) when (_logger.LogAndReturnFalse(ex))
{
// This block never executes, but the exception gets logged
// Because LogAndReturnFalse always returns false
}
  1. Consider Performance: Exception handling is expensive. Use it for exceptional cases, not for normal control flow.

Summary

Understanding the C# exception hierarchy is essential for writing robust and maintainable code. The hierarchy starts with the base System.Exception class and branches out into system exceptions and application-specific exceptions.

Key points to remember:

  • All exceptions derive from System.Exception
  • Catch specific exceptions before more general ones
  • Create custom exceptions when needed to represent application-specific error conditions
  • Use exception properties like Message, StackTrace, and InnerException for detailed error information
  • Implement proper exception handling strategies based on your application's architecture
  • Don't use exceptions for normal control flow as they impact performance

By mastering the exception hierarchy, you'll be able to write code that gracefully handles errors, provides meaningful feedback to users, and maintains application stability even in the face of unexpected situations.

Additional Resources

Exercises

  1. Create a custom exception class called ProductNotFoundException that includes properties for the product ID and category.

  2. Write a method that demonstrates the proper use of exception handling with multiple catch blocks, catching exceptions in order from most specific to most general.

  3. Refactor the following code to use better exception handling practices:

    csharp
    public double Divide(string numerator, string denominator)
    {
    try
    {
    return int.Parse(numerator) / int.Parse(denominator);
    }
    catch (Exception ex)
    {
    Console.WriteLine("Error occurred.");
    return 0;
    }
    }
  4. Create a class that implements the IDisposable pattern and throws an ObjectDisposedException when someone tries to use it after disposal.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)