Exceptions are thrown in code to indicate something went wrong. The reason we have exceptions instead of merely letting a program crash with a non-zero return code is to garner information on why something bad happened. Most programming languages have some form of unexpected error fact gathering, from exceptions to panicking, as they are a vital instrument in diagnosing the causes of unexpected behavior.
Most languages offer extensibility to these exceptions, and C# is no… exception! Out of the box there are plenty of exception types that are available to use and are thrown by the language itself. Access an array with an invalid index? IndexOutOfRangeException
. Access a property, method, field, or something else off a null object? You get the infamous NullReferenceException
(infamous in that, for the longest time, it provided not much detail to help track down the source of the error). Something not finished? NotImplmentedException
communicates this clearly.
While it can feel just fine to stick to the out-of-the-box exception types, a lot more value can come in the form of custom exception types, and it’s a breeze to create and maintain them in C#. Custom exceptions allow communication of errors using domain terminology of an application and are easy to unit test.
Expressing Errors with Application Domain Terms
Say there’s a simple banking application, with a Deposit(decimal amount)
method, with a business rule of “the provided amount must be greater than 0.” We could use a native exception to throw an error when this rule is violated:
public void Deposit(decimal amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException($"Deposit amount must be greater than 0. Given {amount}");
// Deposit the amount.
}
If we dive into this exception’s stack trace or peek into its details we’d find the error. But instead of relying on magic strings or having to dive past the native exception type, why not just express the error using domain terminology? Let’s update our method with a custom exception instead.
// Define a custom exception in its own file
public class DepositAmountMustBePositiveException(decimal invalidAmount)
: ArgumentOutOfRangeException($"Deposit amount must be greater than 0. Given {invalidAmount}");
// Back in our class
public void Deposit(decimal amount)
{
if (amount <= 0) throw new DepositAmountMustBePositiveException(amount);
// Deposit the amount.
}
What have we accomplished?
- The exception itself informs us exactly what went wrong, without having to dive into details.
- If we are checking deposit amounts anywhere else for any other reason, we now can re-use the same exception without having to copy and paste the error string
- This makes updating easier too. Say we wanted to express the invalid amount using currency formatting instead of a raw value. Instead of having to chase down every instance of this check, we can update the exception class.
- Instead of a generic “Argument out of Range” error, we are expressing our error using our domain terms.
- By making our exception inherit from
ArgumentOutOfRangeException
, we still communicate the same basic problem, except we give context.- Any existing tests or business logic relying on this specific exception type will still work, as
DepositAmountMustBePositiveException
is still of typeArgumentOutOfRangeException
.
- Any existing tests or business logic relying on this specific exception type will still work, as
As illustrated above, defining a custom exception type is trivial: Inherit from Exception
(or a more specific derivative), optionally defining a custom message to pass along as well.
Testing Custom Exceptions
There are two kinds of unit tests typically associated with custom exceptions:
- Testing the exception data itself, for things like data points or messaging that must be included.
- Testing usages of it within the application, ensuring it’s thrown in the correct circumstances.
Taking a look at our custom DepositAmountMustBePositiveException
, we may wish to assert that the message contains the invalid deposit amount value. Since exceptions are nothing more than classes, a test can simply instantiate a new instance of the exception class, and assert its Message
string contains the desired value.
When testing our class with the Deposit
method, we would feed it several invalid values (e.g. 0m
, -0.01m
, -10.55m
) and assert that when calling Deposit()
it throws, specifically, our custom DepositAmountMustBePositiveException
.
Given that C# itself, or other code, could throw the out-of-the-box ArgumentOutOfRangeException
, it’s possible that our testing could contain false positives! If we stick to custom exceptions, the odds of that occurring are reduced drastically. The reality is that very few spots, perhaps just one, will ever throw DepositAmountMustBePositiveException
. And since custom exceptions are trivial to create, it’s completely fine to define a custom exception type that will be used in just that one instance.
Don’t Be Shy: Make Custom Exceptions!
As established, custom exception types are trivial to create and maintain. You might find yourself making a lot once you internalize this, and that’s cool. Expressing errors on your own (domain) terms leads to errors being discovered and fixed more quickly. It leads to tests being easier to read, write, and avoid false positives. It allows compartmentalization of error data and messaging, avoiding having to duplicate strings and formatting across your application. Get your hands dirty and create some custom exceptions!