Error Handling in Large .NET Projects - Best Practices

Abstract: Effective error and exception handling in any kind of an application plays an important role in providing a pleasant experience to the user, when unexpected failures occur. This article talks about some effective error handling strategies that you can use in your projects.

As our applications develop, we need to receive a sensible methodology for taking care of mistakes to keep the client's experience steady and all the more significantly, to give us intends to investigate and fix gives that happen.

Best Practices for Exception Handling

The idiomatic way to express error conditions in .NET framework is by throwing exceptions. In C#, we can handle them using the try-catch-finally statement:

try
{
    // code which can throw exceptions
}
catch
{
    // code executed only if exception was thrown
}
finally
{
    // code executed whether an exception was thrown or not
}

Whenever an exception is thrown inside the try block, the execution continues in the catch block. The finally block executes after the try block has successfully completed. It also executes when exiting the catch block, either successfully or with an exception.

In the catch block, we usually need information about the exception we are handling. To grab it, we use the following syntax:

catch (Exception e)
{
    // code can access exception details in variable e
}

The type used (Exception in our case), specifies which exceptions will be caught by the catch block (all in our case, as Exception is the base type of all exceptions).

Any exceptions that are not of the given type or its descendants, will fall through.

We can even add multiple catch blocks to a single try block. In this case, the exception will be caught by the first catch block with matching exception type:

catch (FileNotFoundException e)
{
    // code will only handle FileNotFoundException
}
catch (Exception e)
{
    // code will handle all the other exceptions
}

This allows us to handle different types of exceptions in different ways. We can recover from expected exceptions in a very specific way, for example:

If a user selected a non-existing or invalid file, we can allow him to select a different file or cancel the action.

If a network operation timed out, we can retry it or invite the user to check his network connectivity.

For remaining unexpected exceptions, e.g. a NullReferenceExceptions caused by a bug in the code, we can show the user a generic error message, giving him an option to report the error, or log the error automatically without user intervention.

It is also very important to avoid swallowing exceptions silently:

catch (Exception e)
{ }

Doing this is bad for both the user and the developer.

The user might incorrectly assume that an action succeeded, where infact it silently failed or did not complete; whereas the developer will not get any information about the exception, unaware that he might need to fix something in the application.

Hiding errors silently is only appropriate in very specific scenarios, for example to catch exceptions thrown when attempting to log an error in the handler, for unexpected exceptions.

Even if we attempted to log this new error or retried logging the original error, there is a high probability that it would still fail.

Silently aborting is very likely the lesser evil in this case.

Centralized Exception Handling
When writing exception-handling code, it is important to do it in the right place. You might be tempted to do it as close to the origin of the exception as possible, e.g. at the level of each individual function:

void MyFunction()
{
    try
    {
        // actual function body
    }
    catch
    {
        // exception handling code
    }
}
This is often appropriate for exceptions which you can handle programmatically, without any user interaction. This is in cases when your application can fully recover from the exception and still successfully complete the requested operation, and therefore the user does not need to know that the exception occurred at all.

However, if the requested action failed because of the exception, the user needs to know about it.

In a desktop application, it would technically be possible to show an error dialog from the same method where the exception originally occurred, but in the worst case, this could result in a cascade of error dialogs, because the subsequently called functions might also fail – e.g. since they will not have the required data available:

void UpdatePersonEntity(PersonModel model)
{
    var person = GetPersonEntity(model.Id);
    ApplyChanges(person, model);
    Save(person);
}
Let us assume an unrecoverable exception is thrown inside GetPersonEntity (e.g., there is no PersonEntity with the given Id):

GetPersonEntity will catch the exception and show an error dialog to the user.
Because of the previous failure, ApplyChanges will fail to update the PersonEntity with the value of null, as returned by the first function. According to the policy of handling the exception where it happens, it will show a second error dialog to the user.
Similarly, Save will also fail because of PersonEntity having null value, and will show a third error dialog in a row.
To avoid such situations, you should only handle unrecoverable exceptions and show error dialogs in functions directly invoked by a user action.

In a desktop application, these will typically be event handlers:

private void SaveButton_Click(object sender, RoutedEventArgs e)
{
    try
    {
        UpdatePersonEntity(model);
    }
    catch
    {
        // show error dialog
    }
}
In contrast, UpdatePersonEntity and any other functions called by it should not catch any exceptions that they cannot handle properly. The exception will then bubble up to the event handler, which will show only one error dialog to the user.

This also works well in a web application.

In an MVC application, for example, the only functions directly invoked by the user are action methods in controllers. In response to unhandled exceptions, these methods can redirect the user to an error page instead of the regular one.

To make the process of catching unhandled exceptions in entry functions simpler, .NET framework provides means for global exception handling. Although the details depend on the type of the application (desktop, web), the global exception handler is always called after the exception bubbles up from the outermost function as the last chance, to prevent the application from crashing.

In WPF, a global exception handler can be hooked up to the application’s DispatcherUnhandledException event:

public partial class App : Application
{
    public App()
    {
        DispatcherUnhandledException += App_DispatcherUnhandledException;
    }
 
    private void App_DispatcherUnhandledException(object sender,
        DispatcherUnhandledExceptionEventArgs e)
    {
        var exception = e.Exception; // get exception
        // ...                          show the error to the user
        e.Handled = true;            // prevent the application from crashing
        Shutdown();                  // quit the application in a controlled way
    }
}
In ASP.NET, the global exception handler is convention based – a method named Application_Error in global.asax:

private void Application_Error(object sender, EventArgs e)
{
    var exception = Server.GetLastError(); // get exception
    Response.Redirect("error");     // show error page to user
}
What exactly should a global exception handler do?

From the user standpoint, it should display a friendly error dialog or error page with instructions on how to proceed, e.g. retry the action, restart the application, contact support, etc. For the developer, it is even more important to log the exception details for further analysis:

File.AppendAllText(logPath, exception.ToString());
Calling ToString() on an exception will return all its details, including the description and call stack in a text format. This is the minimum amount of information you want to log.

To make later analysis easier, you can include additional information, such as current time and any other useful information.

Using Exception Logging Libraries
Although writing errors to log files might seem simple, there are several challenges to address:

Where should the files be located? Most applications do not have administrative privileges, therefore they cannot write to the installation directory.
How to organize the log files? The application can log everything to a single log file, or use multiple log files based on date, origin of the error or some other criteria.
How large may the log files grow? Your application log files should not occupy too much disk space. You should delete old log files based on their age or total log file size.
Will the application write errors from multiple threads? Only one thread can access a file at a time. Multiple threads will need to synchronize access to the file or use separate files.
There are also alternatives to log files that might be more suitable in certain scenarios:

Windows Event Log was designed for logging errors and other information from applications. It solves all of the above challenges, but requires dedicated tooling for accessing the log entries.
If your application already uses a database, it could also write error logs to the database. If the error is caused by the database that’s not accessible, the application will not be able to log that error to the database though.
You might not want to decide on the above choices in advance, but rather configure the behavior during the installation process.

Supporting all of that is not a trivial job. Fortunately, this is a common requirement for many applications. Several dedicated libraries support all of the above and much more.

Popular Error Logging libraries for .NET
For .NET framework, the most popular logging libraries are probably log4net and NLog.

Although, there are of course differences between the two, the main concepts are quite similar.

In NLog, for example, you will typically create a static logger instance in every class that needs to write anything to the log:

private static Logger logger = LogManager.GetLogger("LoggerName");
To write to the log, you will simply call a method of that class:

logger.Error(exception.ToString());
As you have probably noticed, you have not yet specified where you want to log the errors. Instead of hardcoding this information in the application, you will put it in the NLog.config configuration file:

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns=http://www.nlog-project.org/schemas/NLog.xsd
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <targets>
        <target name="logfile" xsi:type="File" fileName="errors.log"
                layout="${date:format=yyyyMMddHHmmss} ${message}" />
    </targets>
    <rules>
        <logger name="*" minlevel="Error" writeTo="logfile" />
    </rules>
</nlog>
The above configuration specifies that all errors will be logged to the errors.log file and accompanied with a timestamp.

However, you can easily specify a different type of target instead of a file or even add additional targets to log the errors to multiple locations.

With the layout attribute, you define which additional metadata you want to log along with the message. Different targets have different additional attributes to further control the logging process, e.g. archiving policy for log files and naming based on current date or other properties.

Using the rules section, you can even configure logging of different errors to different targets, based on the name of the logger that was used to emit the error.

Log levels can add additional flexibility to your logging.

Instead of only logging errors, you can log other information at different levels (warning, information, trace…) and use the minlevel attribute to specify which levels of log messages to write to the log and which to ignore.

You can log less information most of the time and selectively log more when troubleshooting a specific issue.

Editorial Note: If you are doing error handling in ASP.NET applications, check out Elmah.

Analyzing Production Log Files
Logging information about errors is only the first step.

Based on the logged information, we want to be able to detect any problems with the application and act on them, i.e. fix them to ensure that the application runs without errors in the future.

In order to do so effectively, the following tips can help us tremendously:

Receive notifications when errors happen, so that we do not need to check all the log files manually. If errors are exceptional events, there is a high probability that we will not check the logs daily and will therefore not notice the errors as soon as we could.
Aggregate similar errors in groups, so that we do not need to check each one of them individually. This becomes useful when a specific error starts occurring frequently and we have not fixed it yet since it can prevent us from missing a different error among all the similar ones.
As always, there are ready-made solutions for these problems available so that we do not need to develop them ourselves.

A common choice in .NET ecosystem is Application Insights. It is an Azure SaaS (Software as a Service) offering, with a free tier to get us started, and a usage based pricing model.

The product is tightly integrated into Visual Studio. There is a wizard available to add Application Insights telemetry to an existing project or to a newly created one. It will install the required NuGet package, create an accompanying resource in Azure and link the project to it with configuration entries.

This is enough for the application to send exception data to Application Insights. By adding custom code, more data can be sent to it. There is even an NLog custom target available for writing the logs to Application Insights.

Logged data can be analyzed in Azure Portal or inside Visual Studio.
Figure 1: Application Insights search window in Visual Studio

If you want or are required to keep all the information on-premise, there is an open source alternative available: OneTrueError.

Before you can start using it, you first need to install the server application on your own machine.

There is no wizard available for configuring OneTrueError in your application, but the process is simple:

- Install the NuGet package for your project type, e.g. OneTrueError.Client.AspNet for ASP.NET applications.

- Initialize automatic exception logging at application startup, e.g. in ASP.NET applications, add the following code to your Application_Start method (replace the server URL, app key and shared secret with data from your server, of course):

var url = new Uri("http://yourServer/onetrueerror/");
OneTrue.Configuration.Credentials(url, "yourAppKey", "yourSharedSecret");
OneTrue.Configuration.CatchAspNetExceptions();
As with Application Insights, you can send more telemetry information to OneTrueError with custom logging code. You can inspect the logged information using the web application on your server.

Both Application Insights and OneTrueError, as well as other competitive products, solve another problem with log files: large modern applications which consist of multiple services (application server, web server, etc.), and are installed on multiple machines for reliability and load balancing.

Each service on each machine creates its own log and to get the full picture, data from all these logs needs to be consolidated in a single place.

By logging to a centralized server, all logs will automatically be collected there with information about its origin.

Error handling specifics for Mobile Applications
Until now, we focused on server side applications and partially desktop applications, however mobile applications are becoming an increasingly important part of a complete software product.

Of course, we would want to get similar error information for those applications as well, but there are some important differences in comparison to server and desktop applications that need to be considered:

There is no way to easily retrieve data stored on mobile devices, not even in a corporate environment. The application needs to send the logs to a centralized location itself.
You cannot count on mobile devices being always connected; therefore, the application cannot reliably report errors to a server as they happen. It needs to also store them locally and send them later when connectivity is restored.
Just like Application Insights and OneTrueError provide a complete solution for server application, there are dedicated products available for mobile applications.

Typically, they are not limited to error or crash reporting but also include support for usage metrics and (beta) application distribution.

Microsoft’s companion to Application Insights for mobile and desktop applications is HockeyApp, but there are other alternatives available, such as Raygun and Crashlytics. They have similar feature set and mostly differentiate themselves by varying support for specific development platforms and pricing models.

Most of them require only a couple of lines of code to get started.

Conclusion:
Error handling is a broad topic, but also a very important part of application development. The actual project requirements will depend on various factors such as application size, deployment model, available budget, planned lifetime and others.

Post a Comment

0 Comments