.NET Custom Exceptions
Introduction
In the world of .NET development, effective error handling is crucial for building robust applications. While the .NET Framework provides a rich set of built-in exception classes, there are scenarios where you need to create your own custom exceptions to handle application-specific error conditions.
Custom exceptions allow you to:
- Define domain-specific error cases
- Provide more meaningful error information
- Create a consistent error handling strategy
- Improve code readability and maintainability
- Distinguish between different error types in your application
In this tutorial, we'll explore how to create and use custom exceptions in .NET applications, following best practices and demonstrating real-world scenarios.
Why Create Custom Exceptions?
Before diving into how to create custom exceptions, let's understand why they're valuable:
- Semantic clarity: Custom exceptions make error cases more explicit and self-documenting
- Better error handling: They allow for more specific catch blocks
- Enhanced debugging: Custom properties can provide additional context
- Business logic separation: Domain-specific errors can be separated from system errors
Creating a Basic Custom Exception
All custom exceptions should inherit from the Exception
class (or a more specific exception type). Let's create a simple custom exception:
using System;
public class UserNotFoundException : Exception
{
public UserNotFoundException()
: base("The specified user was not found.")
{
}
public UserNotFoundException(string message)
: base(message)
{
}
public UserNotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}
}
This basic pattern includes:
- A default constructor providing a standard error message
- A constructor accepting a custom message
- A constructor accepting both a message and an inner exception
Using Your Custom Exception
Now let's see how to use this custom exception in a simple user management scenario:
public class UserService
{
private readonly Dictionary<int, User> _users = new Dictionary<int, User>();
public User GetUserById(int id)
{
if (!_users.ContainsKey(id))
{
// Throw our custom exception when a user isn't found
throw new UserNotFoundException($"User with ID {id} was not found.");
}
return _users[id];
}
}
And here's how you would handle this exception:
try
{
var user = userService.GetUserById(42);
Console.WriteLine($"Found user: {user.Name}");
}
catch (UserNotFoundException ex)
{
Console.WriteLine($"Error: {ex.Message}");
// Handle the specific error case for missing users
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error: {ex.Message}");
// Handle other exceptions
}
Output when user is not found:
Error: User with ID 42 was not found.
Adding Custom Properties
One of the advantages of custom exceptions is the ability to include additional properties that provide context about the error:
public class UserNotFoundException : Exception
{
public int UserId { get; }
public UserNotFoundException(int userId)
: base($"User with ID {userId} was not found.")
{
UserId = userId;
}
public UserNotFoundException(int userId, string message)
: base(message)
{
UserId = userId;
}
public UserNotFoundException(int userId, string message, Exception innerException)
: base(message, innerException)
{
UserId = userId;
}
}
With this enhanced exception, you can access the UserId
property in your catch block:
try
{
var user = userService.GetUserById(42);
}
catch (UserNotFoundException ex)
{
Console.WriteLine($"Error: {ex.Message}");
Console.WriteLine($"Failed to retrieve user with ID: {ex.UserId}");
// You could use the ID to suggest alternatives or for logging
LogUserLookupFailure(ex.UserId);
}
Serialization Support
If your exceptions need to cross application domain boundaries (like in remoting scenarios), you should make them serializable by using the [Serializable]
attribute and implementing the serialization constructor:
using System;
using System.Runtime.Serialization;
[Serializable]
public class UserNotFoundException : Exception
{
public int UserId { get; }
public UserNotFoundException(int userId)
: base($"User with ID {userId} was not found.")
{
UserId = userId;
}
public UserNotFoundException(int userId, string message)
: base(message)
{
UserId = userId;
}
public UserNotFoundException(int userId, string message, Exception innerException)
: base(message, innerException)
{
UserId = userId;
}
// Special constructor for deserialization
protected UserNotFoundException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
UserId = info.GetInt32("UserId");
}
// Override GetObjectData to serialize custom properties
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue("UserId", UserId);
}
}
Creating an Exception Hierarchy
For more complex applications, you might want to create a hierarchy of exceptions. This allows catching groups of related exceptions with a single catch block:
// Base exception for all application-specific errors
public class AppException : Exception
{
public AppException() : base() { }
public AppException(string message) : base(message) { }
public AppException(string message, Exception innerException)
: base(message, innerException) { }
}
// User-related exceptions
public class UserException : AppException
{
public UserException() : base() { }
public UserException(string message) : base(message) { }
public UserException(string message, Exception innerException)
: base(message, innerException) { }
}
// More specific user exceptions
public class UserNotFoundException : UserException
{
public int UserId { get; }
public UserNotFoundException(int userId)
: base($"User with ID {userId} was not found.")
{
UserId = userId;
}
// Additional constructors...
}
public class InvalidUserDataException : UserException
{
public InvalidUserDataException()
: base("The user data provided is invalid.") { }
// Additional constructors...
}
With this hierarchy, you can catch exceptions at different levels of specificity:
try
{
userService.CreateUser(userData);
}
catch (UserNotFoundException ex)
{
// Handle specifically when a user isn't found
}
catch (InvalidUserDataException ex)
{
// Handle specifically when user data is invalid
}
catch (UserException ex)
{
// Handle any user-related exception
}
catch (AppException ex)
{
// Handle any application-specific exception
}
catch (Exception ex)
{
// Handle any other exception
}
Real-World Example: E-Commerce Application
Let's look at a more comprehensive example for an e-commerce application:
[Serializable]
public class OrderProcessingException : Exception
{
public string OrderId { get; }
public OrderErrorType ErrorType { get; }
public OrderProcessingException(string orderId, OrderErrorType errorType)
: base(GetDefaultMessage(errorType, orderId))
{
OrderId = orderId;
ErrorType = errorType;
}
public OrderProcessingException(string orderId, OrderErrorType errorType, string message)
: base(message)
{
OrderId = orderId;
ErrorType = errorType;
}
public OrderProcessingException(string orderId, OrderErrorType errorType,
string message, Exception innerException)
: base(message, innerException)
{
OrderId = orderId;
ErrorType = errorType;
}
protected OrderProcessingException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
OrderId = info.GetString("OrderId");
ErrorType = (OrderErrorType)info.GetInt32("ErrorType");
}
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue("OrderId", OrderId);
info.AddValue("ErrorType", (int)ErrorType);
}
private static string GetDefaultMessage(OrderErrorType errorType, string orderId)
{
return errorType switch
{
OrderErrorType.PaymentFailed => $"Payment failed for order {orderId}",
OrderErrorType.OutOfStock => $"One or more items in order {orderId} are out of stock",
OrderErrorType.ShippingUnavailable => $"Shipping is unavailable for order {orderId}",
_ => $"An error occurred processing order {orderId}"
};
}
}
public enum OrderErrorType
{
PaymentFailed,
OutOfStock,
ShippingUnavailable,
Other
}
Now let's see how we'd use this in an order processing service:
public class OrderService
{
public void ProcessOrder(Order order)
{
try
{
// Validate inventory
if (!CheckInventory(order))
{
throw new OrderProcessingException(
order.Id,
OrderErrorType.OutOfStock,
GetOutOfStockItems(order));
}
// Process payment
bool paymentSuccess = ProcessPayment(order);
if (!paymentSuccess)
{
throw new OrderProcessingException(
order.Id,
OrderErrorType.PaymentFailed,
"Payment declined by payment processor");
}
// Check shipping availability
if (!IsShippingAvailable(order.ShippingAddress))
{
throw new OrderProcessingException(
order.Id,
OrderErrorType.ShippingUnavailable,
$"Cannot ship to address: {order.ShippingAddress}");
}
// Complete order processing
FinalizeOrder(order);
}
catch (Exception ex) when (ex is not OrderProcessingException)
{
// Wrap unexpected exceptions
throw new OrderProcessingException(
order.Id,
OrderErrorType.Other,
"An unexpected error occurred while processing the order",
ex);
}
}
// Implementation details of other methods...
}
Handling these exceptions might look like:
try
{
orderService.ProcessOrder(order);
NotifyCustomer(order, "Your order has been processed successfully!");
}
catch (OrderProcessingException ex) when (ex.ErrorType == OrderErrorType.PaymentFailed)
{
NotifyCustomer(order, "Your payment was declined. Please try another payment method.");
LogOrderIssue(ex.OrderId, "Payment failure", ex.Message);
}
catch (OrderProcessingException ex) when (ex.ErrorType == OrderErrorType.OutOfStock)
{
NotifyCustomer(order, "Some items in your order are currently out of stock.");
LogOrderIssue(ex.OrderId, "Inventory issue", ex.Message);
}
catch (OrderProcessingException ex) when (ex.ErrorType == OrderErrorType.ShippingUnavailable)
{
NotifyCustomer(order, "We can't ship to your address. Please contact customer service.");
LogOrderIssue(ex.OrderId, "Shipping issue", ex.Message);
}
catch (OrderProcessingException ex)
{
NotifyCustomer(order, "There was an issue processing your order. Our team has been notified.");
LogOrderIssue(ex.OrderId, "General order error", ex.Message);
}
catch (Exception ex)
{
NotifyCustomer(order, "We encountered an unexpected error. Please try again later.");
LogError("Unhandled exception in order processing", ex);
}
Best Practices for Custom Exceptions
When creating custom exceptions, follow these best practices:
-
Suffix with 'Exception': Always name your exception classes with the "Exception" suffix.
-
Provide multiple constructors: Include the standard constructors as shown in our examples.
-
Make exceptions serializable: Add the
[Serializable]
attribute and serialization constructor for distributed applications. -
Include contextual information: Add properties that provide additional context about the error.
-
Use inheritance wisely: Create a hierarchy that makes sense for your application architecture.
-
Document your exceptions: Include XML documentation to explain when and why the exception is thrown.
-
Be specific but not too granular: Don't create an exception class for every possible error condition, but be specific enough to be useful.
-
Include helpful error messages: Default messages should be clear and informative.
-
Consider localization: For international applications, consider localizing exception messages.
-
Don't swallow exceptions: Always ensure exceptions are properly logged or handled.
Common Mistakes to Avoid
- Creating exceptions for non-exceptional cases: Exceptions should represent exceptional conditions, not normal flow control.
- Throwing generic exceptions: Avoid throwing
Exception
orSystemException
. - Catching exceptions and rethrowing as new exceptions without inner exceptions: Always include the original exception as the inner exception when wrapping.
- Creating too many exception types: This can lead to maintenance issues.
- Missing serialization support: This can cause problems in distributed scenarios.
Summary
Custom exceptions are a powerful tool in .NET development that allow you to create more robust, maintainable, and understandable error handling in your applications. By defining domain-specific exceptions with contextual information, you can better communicate what went wrong and provide more targeted handling strategies.
In this tutorial, you've learned:
- Why and when to create custom exceptions
- How to create basic and advanced custom exception classes
- How to add custom properties to exceptions
- How to make exceptions serializable
- How to create exception hierarchies
- Real-world application of custom exceptions in an e-commerce scenario
- Best practices and common mistakes to avoid
Additional Resources
- Microsoft Docs: Creating and Throwing Exceptions
- Microsoft Docs: Exception Handling Best Practices
- C# Exception Handling Guidelines
Exercises
-
Create a custom exception called
ConfigurationException
that includes properties for the configuration section and key that caused the error. -
Extend the e-commerce example by creating additional specialized exceptions derived from
OrderProcessingException
for eachOrderErrorType
. -
Implement a logging system that extracts and organizes information from custom exceptions to create structured log entries.
-
Create a custom exception hierarchy for a banking application that includes exceptions like
InsufficientFundsException
,AccountNotFoundException
, andTransactionLimitExceededException
. -
Practice implementing the
ISerializable
interface properly in a custom exception class with multiple custom properties.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)