Skip to main content

.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:

  1. Semantic clarity: Custom exceptions make error cases more explicit and self-documenting
  2. Better error handling: They allow for more specific catch blocks
  3. Enhanced debugging: Custom properties can provide additional context
  4. 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:

csharp
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:

  1. A default constructor providing a standard error message
  2. A constructor accepting a custom message
  3. 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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

csharp
// 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:

csharp
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:

csharp
[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:

csharp
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:

csharp
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:

  1. Suffix with 'Exception': Always name your exception classes with the "Exception" suffix.

  2. Provide multiple constructors: Include the standard constructors as shown in our examples.

  3. Make exceptions serializable: Add the [Serializable] attribute and serialization constructor for distributed applications.

  4. Include contextual information: Add properties that provide additional context about the error.

  5. Use inheritance wisely: Create a hierarchy that makes sense for your application architecture.

  6. Document your exceptions: Include XML documentation to explain when and why the exception is thrown.

  7. Be specific but not too granular: Don't create an exception class for every possible error condition, but be specific enough to be useful.

  8. Include helpful error messages: Default messages should be clear and informative.

  9. Consider localization: For international applications, consider localizing exception messages.

  10. 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 or SystemException.
  • 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

Exercises

  1. Create a custom exception called ConfigurationException that includes properties for the configuration section and key that caused the error.

  2. Extend the e-commerce example by creating additional specialized exceptions derived from OrderProcessingException for each OrderErrorType.

  3. Implement a logging system that extracts and organizes information from custom exceptions to create structured log entries.

  4. Create a custom exception hierarchy for a banking application that includes exceptions like InsufficientFundsException, AccountNotFoundException, and TransactionLimitExceededException.

  5. 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! :)