In this article, we will dive into exception handling, explore user-defined exception creation, and demonstrate implementing a global exception handler in C#.

To download the source code for this article, you can visit our GitHub repository.

Let’s start.

What Is an Exception

An exception is an unexpected behavior that can disrupt the execution of our program. Common exceptions include invalid inputs, dividing by zero, and file not found exceptions.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Exception handling in C# is defined by four keywords; try, catch, finally, and throw. We use these keywords in our code to handle any exception that might occur during execution.

Let’s take a look at the key exception-handling keywords in C#:

KeywordDescription
tryThe 'try' block is used to enclose the code that may throw an exception. It is followed by one or more catch blocks.
catchThe 'catch' block is used to handle exceptions that are thrown within the associated try block.
finallyThe 'finally' block is used to execute code after the try block, regardless of whether an exception is thrown or not.
throwThe 'throw' statement is used to explicitly throw an exception. It can be used within a try block or elsewhere.

Exception Handling Project Setup

Let’s create a simple application. The purpose of our project is to retrieve a user from our database.

Let’s start by creating our User class:

public class User
{
    public int Id { get; set; }
    public required string Name { get; set; }
}

Then we need to create a GetUserResult class:

public class GetUserResult
{
    public bool IsSuccessful { get; set; }
    public string? ErrorMessage { get; set; }
    public User? User { get; set; }

    public static GetUserResult Success(User user)
    {
        return new GetUserResult
        {
            User = user,
            IsSuccessful = true,
        };
    }

    public static GetUserResult Error(string errorMessage)
    {
        return new GetUserResult
        {
            ErrorMessage = errorMessage
        };
    }
}

Next, let’s create a UserService class that handles our user lookup:

public class UserService
{
    private readonly List<User> _users = new()
    {
        new User { Id = 1, Name = "Code Maze"},
        new User { Id = 2, Name = "John Doe"}
    };

    public User GetById(int id)
    {
        var user = _users.First(x => x.Id == id);

        return user;
    }
}

Now, let’s have a look at a sample application:

var userService = new UserService();
var user = userService.GetById(id);
Console.WriteLine($"Got user with ID: {user.Id}");

return GetUserResult.Success(user);

However, as simple as this code might seem, there are a lot of things that might break our application. We need to make sure that our user lookup does not crash no matter what happens during the execution.

In C#, we achieve this by using the try-catch blocks.

The try block is the section of code where possible exceptions could occur. Once an exception occurs in this section, the respective catch block is executed. The executed catch block depends on the type of exception that was thrown.

To handle exceptions that might be thrown in the try block, a catch block is used. The catch block is the part that gets executed when an exception occurs in the respective try block.

Single Try-Catch Block for Exception Handling

The most basic exception-handling unit is the try-catch block which consists of a single try with a single catch block.

This is usually used when we need to handle all exceptions that occur in a specific part of our code, regardless of the type of exception encountered.

In C#, we can implement exception handling using the try-catch block:

var userService = new UserService();

try
{
    var user = userService.Get(id);
    Console.WriteLine($"Got user with ID: {user.Id}");

    return GetUserResult.Success(user);
}
catch (Exception ex)
{
    Console.WriteLine("An Unexpected Error has occurred.");
    throw;
}

Here, we wrap the code in a try block proceeded with a catch block that handles all exceptions that may occur in the try block.

Nested Try-Catch Blocks for Exception Handling

In other cases, we might need different levels of exception handling. In such cases, we can use nested try-catch blocks, where the inner try-catch block handles specific exceptions, while the outer try-catch block deals with the general exceptions.

However, we should avoid excessive use of nested try-catch blocks as it makes the code harder to read and maintain.

First, we need to extend the UserService class to introduce a method that might cause an exception:

public class UserService
{
    private readonly List<User> _users = new()
    {
        new User { Id = 1, Name = "Code Maze"},
        new User { Id = 2, Name = "John Doe"}
    };

    public User GetById(int id)
    {
        ValidateID(id);

        var user = _users.First(x => x.Id == id);

        return user;
    }

    private static void ValidateID(int id)
    {
        if (id <= 0 || id >= 1000)
            throw new ArgumentException();
    }
}

Here, we add a ValidateID() method which will throw an ArgumentException if the id argument we provide is outside the bounds.

Then we need to update our sample application so that it handles the ArgumentException:

var userService = new UserService();

try
{
    try
    {
        var user = userService.GetById(id);
        Console.WriteLine($"Got user with ID: {user.Id}");

        return GetUserResult.Success(user);
    }
    catch (ArgumentException)
    {
        return GetUserResult.Error("User Id should be between 1 and 1000.");
    }
}
catch (Exception)
{
    Console.WriteLine("An Unexpected Error has occurred.");
    throw;
}

Here, we introduce another try-catch block inside the first try block to handle any ArgumentException thrown by our code. This way, we are specific in our error handling depending on the type of exception thrown.

Multiple Catch Blocks for Exception Handling

Sometimes we might need to handle different types of exceptions for the same try block. In such cases, we can use multiple catch blocks under the same try block, then we can add specific logic for different types of exceptions.

To dive a bit deeper into catching multiple exceptions, check out our Catch Multiple Exceptions in C# article.

Let’s update our sample application to return a custom message in case a InvalidOperationException is thrown during execution:

var userService = new UserService();

try
{
    try
    {
        var user = userService.GetById(id);
        Console.WriteLine($"Got user with ID: {user.Id}");

        return GetUserResult.Success(user);
    }
    catch (ArgumentException)
    {
        return GetUserResult.Error("User Id should be between 1 and 1000.");
    }
    catch (InvalidOperationException)
    {
        return GetUserResult.Error("User not found.");
    }
}
catch (Exception)
{
    Console.WriteLine("An Unexpected Error has occurred.");
    throw;
}

Here, we add another catch block under the inner try-catch block to handle the InvalidOperationException. We must place specific exception handlers before general ones, otherwise, the compiler will show an error.

Try-Catch-Finally Block for Exception Handling

The finally block executes whether an exception has occurred or not. This is the block where we execute cleanup activities such as closing database connections, releasing resources, and ensuring that required procedures are executed.

In most scenarios, the finally block is associated with a try-catch block so that it gets executed at the end of the try block, or the end of the catch block if an exception occurs. Let’s consider that we should clear our database at the end of our operation, regardless of whether an exception occurs.

First, let’s add a Clear() method to our UserService class:

public class UserService
{
    private readonly List<User> _users = new()
    {
        new User { Id = 1, Name = "Code Maze"},
        new User { Id = 2, Name = "John Doe"}
    };

    public User GetById(int id)
    {
        ValidateID(id);

        var user = _users.First(x => x.Id == id);

        return user;
    }

    private static void ValidateID(int id)
    {
        if (id <= 0 || id > 1000)
            throw new ArgumentException();
    }

    public void Clear() => _users.Clear();
}

We implement a Clear() method to reset the _users field that contains our data.

Then, in our sample application, let’s add a finally block:

var userService = new UserService();

try
{
    var user = userService.GetById(id);
    Console.WriteLine($"Got user with ID: {user.Id}");

    return GetUserResult.Success(user);
}
catch (Exception)
{
    Console.WriteLine("An Unexpected Error has occurred.");
    throw;
}
finally
{
    userService?.Clear();
}

In the finally block, we call the Clear() method in the UserService class, ensuring that it is called whether our code executes successfully or fails.

Using Try-Finally Block

In other scenarios, we might need to ensure that certain cleanup operations occur regardless of whether an exception occurs during the execution, without a need for handling the exception. In such cases, we use the try-finally block without the catch block.

Let’s add a new method that logs the user returned:

private static void LogUserDetails(User user)
{
    var previousColor = Console.ForegroundColor;
    try
    {
        Console.ForegroundColor = ConsoleColor.Green;
        ArgumentNullException.ThrowIfNull(user, nameof(user));
        Console.WriteLine($"ID: {user.Id}");
        Console.WriteLine($"Name: {user.Name}");
    }
    finally
    {
        Console.ForegroundColor = previousColor;
    }
}

Here, in the try block, we change Console.ForegroundColor to ConsoleColor.Green so that we can distinguish it from the other logs. However, we also add a finally block to ensure that the Console.Foreground property is switched to the previous color even if an exception occurs during the logging process.

Custom Exceptions

C# also allows us to create custom exceptions that go along with our application domain. We can achieve this by creating a class that extends the Exception class. 

Let’s create a custom exception to be thrown by the ValidateID() method:

public class InvalidUserIdException : Exception
{
}

Here, we create the InvalidUserIdException that inherits from the generic Exception class.

Now let’s update our ValidateID() method to throw our custom exception:

private static void ValidateID(int id) 
{ 
    if (id <= 0 || id > 1000) 
        throw new InvalidUserIdException(); 
}

Now, if the provided id argument is outside the specified bounds, the runtime will throw our custom exception.

Finally, we have to update the GetUserById() method to handle our custom exception:

var userService = new UserService();

try
{
    try
    {
        var user = userService.GetById(id);
        Console.WriteLine($"Got user with ID: {user.Id}");

        return GetUserResult.Success(user);
    }
    catch (InvalidUserIdException)
    {
        return GetUserResult.Error("User Id should be between 0 and 1000.");
    }
    catch (InvalidOperationException) 
    { 
        return GetUserResult.Error("User not found."); 
    }
}
catch (Exception)
{
    Console.WriteLine("An Unexpected Error has occurred.");
    throw;
}

Here, we replace the ArgumentException with our InvalidUserIdException, making our code more readable and easier to maintain.

Conditional Exception Handling

Sometimes we need to return different messages to the user for the same exception depending on different conditions. In this case, we can use conditional exception handling where we would have multiple catch blocks for the same exception with different conditions.

Let’s update our GetUserById() method so it returns different error messages according to the value of the id parameter:

var userService = new UserService();

try
{
    try
    {
        var user = userService.GetById(id);
        Console.WriteLine($"Got user with ID: {user.Id}");

        return GetUserResult.Success(user);
    }
    catch (InvalidUserIdException) when (id <= 0)
    {
        return GetUserResult.Error("User Id should be a positive number.");
    }
    catch (InvalidUserIdException) when (id >= 1000)
    {
        return GetUserResult.Error("User ID should be smaller than 1000.");
    }
    catch (InvalidOperationException) 
    { 
        return GetUserResult.Error("User not found."); 
    }
}
catch (Exception)
{
    Console.WriteLine("An Unexpected Error has occurred.");
    throw;
}

Using the when keyword, we return a specific error message in the case where the id parameter is less than 1, and another error message where the id parameter is greater than 999.

Common Exceptions in C#

Let’s take a look at some of the common exceptions we might encounter as C# developers, along with a brief explanation for each exception:

Exception ClassDescription
System.IO.IOExceptionThrown for various I/O-related errors.
System.IndexOutOfRangeExceptionThrown when an index is outside the bounds of an array or collection.
System.NullReferenceExceptionThrown when a null object is accessed.
System.DivideByZeroExceptionThrown when an attempt to divide an integer or decimal by zero.
System.InvalidCastExceptionThrown when an invalid cast is attempted.
System.OutOfMemoryExceptionThrown when there is not enough memory to continue the execution.
System.StackOverflowExceptionThrown when the execution stack overflows due to excessive recursion.
System.ArgumentExceptionThrown when an argument is not valid.
System.InvalidOperationExceptionThrown when a method call is invalid for the object's current state.

Global Exception Handling

With global exception handling, we can handle exceptions on the application’s global level. That way, we ensure that any exception in any part of our application is correctly handled. This will help us handle all unexpected exceptions, where we can either terminate the application or use it for global exception logging.

To learn more about global exception handling in other ASP.NET Core applications, check out our Global Error Handling in ASP.NET Core Web API article.

Let’s create a GlobalExceptionHandler class which will include a HandleException() method:

public static class GlobalExceptionHandler
{
    public static void HandleException(object sender, UnhandledExceptionEventArgs e)
    {
        if (e.ExceptionObject is not Exception exception) 
        {
            return;
        }

        Console.WriteLine("Global Exception Handler caught an exception: " + exception.Message);
    }
}

Here, in our HandleException() method, we check if the ExceptionObject passed in the method parameter is an Exception or not, logging into the console if it is.

Next, we have to register our HandleException() method in our application:

AppDomain.CurrentDomain.UnhandledException += GlobalExceptionHandler;

We achieve this by using the AppDomain.CurrentDomain.UnhandledException event to register the HandleException() method. This provides the ability to centralize error management, allowing developers to handle unexpected issues uniformly and implement consistent error-handling strategies across the entire application.

Conclusion

Exception handling in C# is a vital practice for creating robust software. By employing try-catch blocks and global handlers, developers ensure graceful responses to unexpected errors, enhancing application reliability.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!