Hi Friends,
Welcome to the 188th issue of the Polymathic Engineer newsletter.
Errors and exceptions are inevitable. Every code can fail when something unexpected happens, or when it runs in an environment it wasn’t designed for. Many unexpected things can occur like running out of memory errors, and so on.
That’s why handling failures should be one of the first things we think about when designing our code. However, the dangerous failures are not the ones that crash our application. They are the silent ones, where an exception gets dropped and the system keeps running in a bad state. Such problems are the toughest to discover and to fix.
In this issue, we’ll look at the patterns and anti-patterns of exception handling. The code examples are in C#, but the ideas apply to most languages.
The outline is as follows:
A quick map of exceptions
How wide should your catch be?
Designing failures into your API
The anti-patterns that hide failures
Cleaning up when things go wrong
Exceptions at the boundary: third-party code
When failures go async
To learn technical skills, you must work on real projects. CodeCrafters is a great platform for that. You can build your own Redis, Kafka, DNS server, SQLite, HTTP server, or Git from scratch using your chosen programming language.
A quick map of exceptions
Every C# exception is an object. It has a message describing the problem and might wrap an inner exception. What is more important, it contains the stack trace. This is an array of elements, each identifying a line of code in a specific class that leads to an exception. It is essential diagnostic information.
Not all exceptions need to be handled. If an exception signals a critical runtime error (e.g., OutOfMemoryException or StackOverflowException), we can do little.
We care about exceptions that signal problems in the code or its environment: for example, an IOException when a file operation fails, or an InvalidOperationException if an object gets used in the wrong state.
How wide should the catch be?
A method that exports reports to disk might throw an IOException if there are file system issues, or an OperationCanceledException if the user decides to cancel the operation. The most granular option for the caller is to use a separate catch block for each exception type; this is the correct approach if you want to respond differently based on the error type. In the following example, only a disk error is an event that should be recorded in the error logs:
try
{
ExportReport(path); // write to a file
}
catch (IOException e)
{
logger.LogError(e, “Could not write the report”);
}
catch (OperationCanceledException e)
{
logger.LogInformation(”The export was canceled by the user”);
}Another approach is to catch a more generic exception. FileNotFoundException and DirectoryNotFoundException are IOExceptions, so they can be caught together in a single catch block. We can treat both the same way, at the price that the code no longer indicates to the reader which errors we expected.
By taking the concept to the extreme, we can catch everything:
try
{
ExportReport(path);
}
catch (Exception e)
{
logger.LogError(e, “Something went wrong”);
}Now if a bug in our function throws a NullReferenceException, this block swallows it together with the actual I/O failures, and a coding error is logged as a normal disk problem. An exception that ought to be propagated and resolved ends up buried in a log file instead. This is the first way in which error information can be lost.
As many other languages, C# also offers an intermediate solution known as exception filters. A when clause allows you to refine a catch block without having to duplicate it:
try
{
(path);
}
catch (IOException e) when (e is FileNotFoundException or DirectoryNotFoundException)
{
logger.LogError(e, “The report path is not valid”);
}If the filter does not match, the exception keeps propagating as if this catch block didn’t exist. We handle only those cases we are actually equipped to deal with. The general rule is to catch exceptions as specifically as possible, based on handling requirements. You should broaden the scope only for failures you genuinely treat the same way.
Designing failures into your API
When developing a component, it is likely that someone else will call it, perhaps a colleague working on another part of the system or an unknown user in the case of a library. In Java, the contract is partially enforced by the compiler: a method declares its checked exceptions, and the caller is required to handle them. In C#, however, the method signature provides no information about potential errors. The contract must be expressed elsewhere through guard clauses, the choice of exception types, and documentation.
A good practice is to validate arguments and state at the beginning of a method. For a task scheduler with a StartWorkers() method, we could write something like this:
public void StartWorkers(int workerCount)
{
if (workerCount <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(workerCount), “The number of workers must be positive.”);
}
if (running)
{
throw new InvalidOperationException(
“The scheduler is already running.”);
}
// start the workers...
}These guard clauses follow an established convention: the ArgumentException family is reserved for invalid inputs provided by the caller, while InvalidOperationException is for the object being used in an invalid state. It is important to understand that such exceptions are not intended to be caught; they signal a bug in the calling code, and attempting to recover normal operation in the face of a bug makes little sense. It is better to fail fast and loud, bringing the issue to light during development rather than letting it show up in production more subtly.
All failures that are not bugs demand a different treatment. If the scheduler can fail due to a missing configuration file, the caller is actually in a position to respond; consequently, they should be informed of this possibility.
The closest thing C# has to a contract is the XML documentation comment: adding /// <exception cref=”IOException”> at the top of the method ensures that the potential failure appears in IntelliSense for anyone calling that method. It’s a weaker guarantee than a checked exception (nothing forces a developer to read it), but it remains the mechanism provided by the language. This practice should be reserved exclusively for public interfaces. Private methods do not require documented error contracts, as you control both ends of the call, and the extra verbosity adds no real advantage.
The anti-patterns that hide failures
Now that the API signals problems explicitly, callers need to react appropriately, and this is where things typically go wrong. Let’s discuss the most common anti-patterns, all variations of the same mistake: losing information about a failure.
The first and worst one is swallowing the exception:
try
{
ExportReport(path);
}
catch (Exception e)
{
// this cannot happen
}It is often tempting to analyze the above code and conclude that such an exception cannot be thrown under any circumstances. Perhaps that might even be true for the time when the code is analyzed. However, the underlying behavior may change in the future. Even if it does not throw an exception when we are writing our caller code, the implementation might change over time. In that case, the exception that is swallowed never propagates up in the call stack. The system continues to operate in a bad state without anyone noticing. Those are some of the hardest problems to debug.


