In this article, we will dive into exception handling, explore user-defined exception creation, and demonstrate implementing a global exception handler in C#.
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.
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#:
Keyword | Description |
---|---|
try | The 'try' block is used to enclose the code that may throw an exception. It is followed by one or more catch blocks. |
catch | The 'catch' block is used to handle exceptions that are thrown within the associated try block. |
finally | The 'finally' block is used to execute code after the try block, regardless of whether an exception is thrown or not. |
throw | The '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.
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 Class | Description |
---|---|
System.IO.IOException | Thrown for various I/O-related errors. |
System.IndexOutOfRangeException | Thrown when an index is outside the bounds of an array or collection. |
System.NullReferenceException | Thrown when a null object is accessed. |
System.DivideByZeroException | Thrown when an attempt to divide an integer or decimal by zero. |
System.InvalidCastException | Thrown when an invalid cast is attempted. |
System.OutOfMemoryException | Thrown when there is not enough memory to continue the execution. |
System.StackOverflowException | Thrown when the execution stack overflows due to excessive recursion. |
System.ArgumentException | Thrown when an argument is not valid. |
System.InvalidOperationException | Thrown 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.
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.