Key takeaways
|
Exceptions are an integral part of working with .NET, but far too many developers don’t think about them from an API design perspective. Most of their work begins and ends with knowing which exceptions they need to catch and which should be allowed to hit the global logger. You can significantly reduce the time it takes to correct bugs if you design the API to use exceptions correctly.
Whose fault is it?
The basic theory behind designing with exceptions begins with the question, “Whose fault is it?” For the purpose of this discussion, the answer will always be one of these three:
- The Library
- The Application
- The Environment
When we say the “library” is at fault, we mean there is an internal flaw in whatever method is currently being executed. In this context, the “application” is the code that invoked the library’s method. (This is a bit of a fiction because both the library and the application code may be in the same assembly.) Finally there is the “environment”, which is anything external to the application that can’t be controlled.
Library Flaws
The quintessential library flaw is the NullReferenceException. There is never a legitimate reason for a library to throw a null reference exception that can observed by the application. If a null is encountered, the library code should always throw a more specific exception explaining what was null and how the problem should be corrected. For parameters this is clearly going to be an ArgumentNullException
. If instead there is a null in a property or field, the InvalidOperationException
is usually appropriate.
By definition, any exception that indicates a library flaw is a bug in the library that needs to be fixed. That doesn’t mean there isn’t also a bug in the application code, but the library needs to be fixed first. Only then is it appropriate to let the application developer know he has also made a mistake.
The reason for this rule is many people may be using the same library. If one person makes a mistake by passing in a null where it doesn’t belong, surely others will too. By replacing the NullReferenceException
with one clearly indicating what went wrong, the application developers will immediately understand what went wrong.
The Pit of Success
If you read early literature on .NET design patterns, you’ll often come across the phrase “pit of success”. The basic concept is this: make the code easy to use correctly, hard to use incorrectly, and ensure the exceptions tell you what you did wrong. This philosophy of API design guides the developer into writing correct code almost by default.
This is why a naked NullReferenceException
is so bad. Other than the stack trace, which may be quite deep into the library code, there is no information to help the developer figure out what they did wrong. ArgumentNullException
and InvalidOperationException
, on the other hand, give the library author a way to explain to the application developer how to fix the problem.
Other Library Flaws
The next library flaw is the ArithmeticException family. This includes DivideByZeroException
, FiniteNumberException
, and OverflowException
. Again, this always represents an internal flaw in the library method, even if that flaw is just a missing parameter validation check.
Another example of a library flaw is the IndexOutOfRangeException. Semantically it is no different than an ArgumentOutOfRangeException
, as seen in IList.Item
, yet it only applies to array indexers. And since naked arrays are not normally used by application code, this implies that there is bug in a custom collection class.
ArrayTypeMismatchException
has been rarely seen since .NET 2.0 introduced generic lists. The situation that triggers it is… well weird. From the documentation,
ArrayTypeMismatchException
is thrown when the system cannot convert the element to the type declared for the array. For example, an element of type String cannot be stored in an Int32 array because conversion between these types is not supported. It is generally unnecessary for applications to throw this exception.
For this to happen, the aforementioned Int32
array has to be placed into a variable of type Object[]
. If you are working with raw arrays, the library needs to check for this. For this reason, and many others, it is better to just not use raw arrays and instead wrap them in an appropriate collection class.
Other casting problems are usually revealed with the InvalidCastException
. Continuing our same theme, type checks should mean InvalidCastException
is never thrown and instead the caller gets an ArgumentException
or an InvalidOperationException
.
MemberAccessException
is a base class covering a wide variety of reflection-based errors. In addition to the direct use of reflection, both COM interopt and incorrect use of the dynamic keyword can trigger it.
Application Flaws
The quintessential application flaw is the ArgumentException
and its subclasses; ArgumentNullException
, ArgumentOutOfRangeException
. There are other subclasess you may not be aware of including:
System.ComponentModel.InvalidAsynchronousStateException
System.ComponentModel.InvalidEnumArgumentException
System.DuplicateWaitObjectException
System.Globalization.CultureNotFoundException
System.IO.Log.ReservationNotFoundException
System.Text.DecoderFallbackException
System.Text.EncoderFallbackException
All of these unequivocally indicate the application code is at fault and the flaw is on the line invoking the library method. Both parts of that statement are important. Consider this code:
foo.Customer = null; foo.Save();
If this were to throw an ArgumentNullException
the application developer would be quite confused. Instead it should throw an InvalidOperationException
to indicate something before the current line was messed up.
Exceptions as Documentation
A typical programmer doesn’t read the documentation. At least not at first. Instead he or she will read the public API, write some code, run it, and then, if it doesn’t work, search for the exception’s message on Stack Overflow. If the programmer is lucky, the answer will be readily found there with links to the right documentation. But even still, our programmer is unlikely to actually read it.
So as a library author how can we address this? The first step is to literally paste some of the documentation into the exception.
More Object State Exceptions
The most well-known subclass of InvalidOperationException
is the ObjectDisposedException
. Its use is pretty obvious, yet it isn’t unusual to see disposable classes that forget to throw this exception. The usual result of forgetting this is a NullReferenceException
caused by the Dispose
method nulling out disposable child objects.
Closely related to InvalidOperationException
is the NotSupportedException
. The difference between them is easy: InvalidOperationException
means, “You can’t do that right now,” while “NotSupportedException
” means, “You can never do that with this class”. In theory NotSupportedException
should only occur when working with abstract interfaces.
For example, an immutable collection should throw a NotSupportedException
in response to the IList.Add
method. By contrast, a freezable collection would throw an InvalidOperationException
when frozen.
An increasingly important subclass of NotSupportedException
is PlatformNotSupportedException
. This indicates the operation is allowed on some runtimes, but not others. For example, you may need to use this when porting code from .NET to UWP or .NET Core, as they don’t offer all the features found in the full .NET Framework.
The Problematic FormatException
Microsoft made a few mistakes when designing the first version of .NET. For example, FormatException
is, logically speaking, a type of argument exception. The documentation even says “thrown when the format of an argument is invalid”. But for whatever reason, it doesn’t actually inherit from ArgumentException
. Nor does it have a place to put the argument name.
Our tentative recommendation is to not throw FormatException. Instead, create your own subclass of ArgumentException
called “ArgumentFormatException
” or something to that effect. This will allow you to reduce debugging time by including essential information such as the argument name and the actual value being used.
This leads us back to our original thesis about “designing with exceptions”. Yes you could just throw a FormatException
when your custom parser detects a problem, but that doesn’t help the application developer trying to use your library.
Environmental Flaws
An environmental flaw comes from the fact the world isn’t perfect. This includes scenarios such as when the database is down, a web server is unresponsive, a file is missing, etc. When environmental flaws appear in bug reports two things need to be considered:
- Did the application handle the flaw correctly?
- What in the environment caused the flaw?
Usually this is going to involve a division of labor. First off, the application developer is going to look into the answer for question number one. This doesn’t mean just error handling and recovery, it also means generating a useful log.
You may be wondering why we started with the application developer. The application developer has a responsibility to the operations team. If a call to a web server fails, the application developer can’t just throw up his arms and shout, “Not my problem”. He or she needs to first make sure the exception has enough detail to allow operations to do their job. If the exception just says “Server connection timeout”, how are they supposed to know which server was involved?
Specialized Exceptions
NotImplementedException
The NotImplementedException
means one thing and one thing only: this feature is a work in progress. As such, the message for a NotImplementedException
should always include a reference to your task tracking software. For example:
throw new NotImplementedException("See ticket #42.");
You could provide more details in the message, but realistically anything you write is going to be out of date almost immediately. So it is better to just direct the reader to the ticket where they can see things such as when you plan on implementing the feature.
AggregateException
The AggregateException
is a necessary evil, but hard to work with. On its own it contains no information of value, all of the details are hidden in its InnerExceptions
collection.
Since the AggregateException
usually only contains one item, it seems logical for the library to unwrap it and return real exception. Normally you can’t rethrow an inner exception without destroying the original stack trace, but starting with .NET 4.5 there is a way to leverage ExceptionDispatchInfo
.
Unwrapping an AggregateException
catch (AggregateException ex)
{
if (ex.InnerExceptions.Count == 1) //unwrap
ExceptionDispatchInfo.Capture(ex.InnerExceptions[0]).Throw();
else
throw; //we actually need an AggregateException
}
Unanswerable Cases
There are some exceptions that simply don’t fit into this scheme. For example, AccessViolationException
indicates there was a problem reading unmanaged memory. Well, that could be caused by the native library code or could be caused by the application misusing the same. Only thorough research will reveal the nature of this bug.
Whenever possible, unanswerable exceptions should be avoided in your designs. In some cases, Visual Studio’s static code analyzer will even go so far as to flag violations of this guideline.
For example, ApplicationException
is effectively deprecated. The Framework Design Guidelines explicitly says, “DO NOT throw or derive from ApplicationException
.” And for good reason; an ApplicationException
isn’t necessarily thrown by the application. Though that was the original intent, look at these subclasses:
Microsoft.JScript.BreakOutOfFinally
Microsoft.JScript.ContinueOutOfFinally
Microsoft.JScript.JScriptException
Microsoft.JScript.NoContextException
Microsoft.JScript.ReturnOutOfFinally
System.Reflection.InvalidFilterCriteriaException
System.Reflection.TargetException
System.Reflection.TargetInvocationException
System.Reflection.TargetParameterCountException
System.Threading.WaitHandleCannotBeOpenedException
Clearly some of these should be argument exceptions while others represent environmental problems. None of them are “application exceptions” because they are thrown only by libraries in the .NET framework.
Along the same lines, developers shouldn’t work directly with SystemException. Like ApplicationException
, the subclasses of SystemException
are all over the map, including ArgumentException
, NullReferenceException
, and AccessViolationException
. Microsoft even goes so far as to suggest you forget this exists and only work with its subclasses.
A subcategoy of the unanswerable case are the infrastructure exceptions. We’ve already seen one, the AccessViolationException
. Others include:
CannotUnloadAppDomainException
BadImageFormatException
DataMisalignedException
TypeLoadException
TypeUnloadedException
These tend the be very difficult to diagnose and may reveal esoteric bugs in either the library or the code that is calling it. So unlike ApplicationException
, they legitimately fall into the unanswerable category.
Putting it into practice: Redesigning SqlException
Keeping these principles in mind, let’s take a look at SqlException
. In addition to network errors where you can’t reach the server at all, there are over 11,000 distinct error codes in SQL Server’s master.dbo.sysmessages. So while the exception has all of the low level information you need, it is actually difficult to do anything with it beyond simply catch & log.
If we were to redesign SqlException, we would want it broken into distinct categories based on what we expect the user or developer to do.
SqlClient.NetworkException
would represent all the error codes that indicate there is an environmental problem external to the database server itself.
SqlClient.InternalException
would include the error codes that indicate something is critically wrong with the server such as database corruption or the inability to access a hard drive.
SqlClient.SyntaxException
is our equivalent to ArgumentException. It means that you passed bad SQL to the server (either directly or by a bug in your ORM).
SqlClient.MissingObjectException
would occur when the syntax is correct, but the database object (table, view, procedure, etc.) simply doesn’t exist.
SqlClient.DeadlockException
happens when there is a conflict between two or more processes that try to modify the same information.
Each of these exception types imply a course of action:
SqlClient.NetworkException
: Retry the operation. If it happens frequently, contact network ops.SqlClient.InternalException
: Contact a DBA immediately.SqlClient.SyntaxException
: Notify the application or database developerSqlClient.MissingObjectException
: Have ops check to see if something was missed during the last database deployment.SqlClient.DeadlockException
: Retry the operation. If it happens frequently, look for design errors.
To do this in real life we’d have to map all 11,000+ SQL Server error codes to one of those categories, a rather daunting proposition which explains why SqlException
looks like it does.
Conclusions
When designing an API, organize your exceptions around the type of action that needs to be performed in order to correct the problem. This will make it easier to write self-correcting code, allows for more accurate logs, and makes routing the problem to the right person or team much faster.
About the Author
Jonathan Allen got his start working on MIS projects for a health clinic in the late 90's, bringing them up from Access and Excel to an enterprise solution by degrees. After spending five years writing automated trading systems for the financial sector, he became a consultant on a variety of projects including the UI for a robotic warehouse, the middle tier for cancer research software, and the big data needs of a major real estate insurance company. In his free time he enjoys studying and writing about martial arts from the 16th century.