As a developer, we all know that errors and exceptions are an inevitable part of software development. No matter how careful we are, errors will always find their way into our code. Therefore, it is important to have a solid understanding of error handling best practices to ensure that our applications are robust and reliable.

In this blog post, I’ll share with you some of the best practices for error handling in C# applications, including tips on handling exceptions, implementing error logging and reporting, using assertions, defensive programming techniques, and more. So, let’s get started!

Understanding Exceptions

In C#, exceptions are classified into two categories: System exceptions and Application exceptions. System exceptions are the exceptions that are thrown by the .NET Framework, while application exceptions are the exceptions that are defined by the application itself. Let’s take a look at each type of exception in detail:

System Exceptions

  1. SystemException: This is the base class for all exceptions that the .NET Framework throws.
  2. InvalidOperationException: This exception is thrown when a method call is invalid for the object’s current state.
  3. ArgumentNullException: This exception is thrown when a method argument is null and the method does not accept null arguments.
  4. ArgumentException: This exception is thrown when one or more of the arguments passed to a method are invalid.
  5. NotSupportedException: This exception is thrown when a method or operation is not supported.

Application Exceptions

  • ApplicationException: This is the base class for all exceptions defined by the application itself.
  • CustomException: This is a user-defined exception that is created for a specific purpose in the application.
  • DatabaseException: This exception is thrown when there is an error in the database.
  • FileException: This exception is thrown when there is an error in reading or writing to a file.

Handling Exceptions in C#

When an exception occurs in a C# application, it is important to handle the exception gracefully. This means catching the exception, providing useful feedback to the user, and taking appropriate action to prevent the error from occurring again.

Handling Exceptions in C#

Here are some best practices for handling exceptions in C#:

Catch only the exceptions you can handle

When handling exceptions in C#, it is important to catch only the exceptions that you can handle. This means catching only the specific exceptions that you are prepared to deal with, rather than catching all exceptions using a generic catch block.

Catching all exceptions using a generic catch block can be tempting because it seems like a simple and easy way to handle any type of exception that may be thrown. However, this can actually be detrimental to your application in the long run. Here’s why:

  • Masking the root cause: By catching all exceptions, you are essentially masking the root cause of the error. This can make it difficult to diagnose and fix the problem, as you may not know what caused the exception in the first place.
  • Unnecessary overhead: Catching all exceptions can also result in unnecessary overhead, as the catch block will be executed even if it is not necessary. This can slow down your application and potentially cause it to crash or become unstable.

To avoid these issues, it is best to catch only the exceptions that you can handle. This means catching specific exceptions that you know how to handle and allowing other exceptions to propagate up the call stack.

For example, let’s say you have a piece of code that reads a file from disk. If the file is not found, an exception of type FileNotFoundException will be thrown.

In this case, you can catch the FileNotFoundException exception and display an appropriate error message to the user. However, if an exception of a different type is thrown, such as a DirectoryNotFoundException, you should not catch it, as you may not know how to handle it.

Here’s an example of how to catch a specific exception in C#:

try
{
    // Code that may throw an exception
}
catch (FileNotFoundException ex)
{
    // Handle the FileNotFoundException
    Console.WriteLine("The file was not found.");
}

By catching only the exceptions that you can handle, you can ensure that your application is more stable and easier to maintain.

Provide useful feedback to the user

Providing useful feedback to the user is an important aspect of error handling in C# applications. This feedback should be informative and helpful in resolving any issues that occur during program execution. Here are some best practices for providing useful feedback to the user:

  1. Use clear and concise language: The error message should be written in a clear and concise manner using simple language. It should be easy to understand and avoid the use of technical jargon or overly complex explanations. For example, instead of using a message like “System.IO.IOException: The network path was not found,” a more user-friendly message like “Unable to connect to the network” can be used.
  2. Be specific: The error message should be specific enough to provide information about what went wrong. The message should include enough detail to help the user understand the issue without overwhelming them with too much information. For example, instead of simply stating “An error occurred,” it is better to include more information such as “The file you are trying to access is not available.”
  3. Offer solutions: If possible, the error message should include suggestions for resolving the issue. These can be in the form of steps that the user can take or resources they can access for more information. This helps to empower the user to take action and solve the problem.
  4. Provide contact information: In some cases, the user may require additional assistance in resolving the issue. In such cases, the error message should include clear contact information such as a phone number or email address. This helps to ensure that the user can reach out for help if needed.
  5. Test error messages: It is important to test error messages to ensure that they are effective in providing useful feedback to the user. This involves testing for different scenarios and ensuring that the message is displayed in an appropriate manner. It is also important to ensure that the message is displayed in a way that is accessible to all users, including those with visual or auditory impairments.

Example code:

try
{
    // Some code that could potentially throw an exception
}
catch (IOException ex)
{
    // Log the error message for debugging purposes
    logger.LogError(ex.Message);

    // Provide useful feedback to the user
    MessageBox.Show("An error occurred while attempting to access the file. Please check that the file exists and that you have the necessary permissions to access it.", "File access error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
catch (Exception ex)
{
    // Log the error message for debugging purposes
    logger.LogError(ex.Message);

    // Provide a generic error message to the user
    MessageBox.Show("An unexpected error occurred. Please try again later or contact support for assistance.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}

In the code example above, we catch specific exceptions that could occur while accessing a file. In the catch block, we log the error message for debugging purposes and then provide useful feedback to the user in the form of a MessageBox. If a generic exception occurs, we provide a generic error message to the user.


Implementing Error Logging and Reporting

Logging errors is an essential part of error handling in C# applications. It helps developers identify and fix errors by providing them with detailed information about the error, such as the stack trace, error message, and the values of relevant variables.

Use a centralized logging framework

Instead of using Console.WriteLine or Debug.WriteLine to log errors, use a logging framework like Log4Net or Serilog. These frameworks provide a more robust and configurable logging solution that can be customized to fit your specific needs. They offer features like logging levels, log formatting, and log output to various targets like file, database, or cloud storage.

Here is an example of how to use Serilog in a C# application:

using Serilog;
public class MyController : Controller {
    private readonly ILogger _logger;
    public MyController(ILogger logger) {
        _logger = logger;
    }
    public IActionResult Index() {
        try {
            // some code that may throw an exception
        } catch (Exception ex) {
            _logger.LogError(ex, "An error occurred in the Index action");
        }
    }
}

Include relevant information in the logs

When logging errors, it’s important to include relevant information such as the exception message, the stack trace, and any relevant context information such as the user’s ID or the request URL.

This information can help developers identify the cause of the error and fix it more quickly. Avoid logging sensitive information like user credentials or personal data.

try {
    // some code that may throw an exception
} catch (Exception ex) {
    _logger.LogError(ex, "An error occurred while processing user {UserId} on URL {Url}", userId, url);
}

Use log levels appropriately

Most logging frameworks support different log levels, such as Debug, Info, Warning, Error, and Critical. It’s important to use these levels appropriately to ensure that developers are alerted to errors that require immediate attention while not overwhelming them with less critical errors.

try {
    // some code that may throw an exception
} catch (Exception ex) {
    _logger.LogError(ex, "An error occurred while processing user {UserId} on URL {Url}", userId, url);
    _logger.LogWarning("Failed to process user {UserId} on URL {Url}", userId, url);
}

Log Exceptions Asynchronously

Logging exceptions synchronously can have a negative impact on application performance. To avoid this, log exceptions asynchronously using a library like AsyncLog4Net or Serilog.Sinks.Async.

try
{
    // Your code here
}
catch (Exception ex)
{
    Log.Error(ex, "An error occurred: {Message}. Stack trace: {StackTrace}", ex.Message, ex.StackTrace);
    await Log.Logger.FlushAsync();
}

Use Contextual Logging

Contextual logging provides additional information about the current state of the application. This includes information like the current user, request ID, or session ID. This can be helpful when troubleshooting issues that occur in a specific context.

using Serilog.Context;

try
{
    using (LogContext.PushProperty("UserId", userId))
    {
        // Your code here
    }
}
catch (Exception ex)
{
    using (LogContext.PushProperty("UserId", userId))
    {
        Log.Error(ex, "An error occurred: {Message}. Stack trace: {StackTrace}", ex.Message, ex.StackTrace);
    }
}

Store logs securely

Logs can contain sensitive information such as user IDs and passwords, so it’s important to store them securely. This can include encrypting the logs, restricting access to them, and rotating them regularly to prevent unauthorized access.

var log = new LoggerConfiguration()
    .WriteTo.File(
        @"C:\Logs\myapp.log",
        rollingInterval: RollingInterval.Day,
        shared: true,
        flushToDiskInterval: TimeSpan.FromSeconds(1),
        fileSizeLimitBytes: 100000,
        rollOnFileSizeLimit: true,
        retainedFileCountLimit: null,
        encoding: Encoding.UTF8,
        restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Error,
        outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] {Message}{NewLine}{Exception}")
    .CreateLogger();

By following these best practices, you can ensure that your application logs errors in a useful and secure way, making it easier to identify and fix bugs in your code.


Using Assertions for Debugging

Assertions are statements that check if certain conditions are met at runtime, and if not, they immediately stop program execution and throw an AssertionException. They can be used to catch programming errors early in development, before the code is released to production. Assertions should not be used to handle user input errors or recoverable errors, as they are intended for catching internal programming errors.

Using Assertions for Debugging

To use assertions, the Debug class can be used. The Debug.Assert method checks the condition and throws an AssertionException if the condition is false. It can also include an optional message string to provide additional information about the assertion.

1 – Use assertions to check assumptions that should always be true: Assertions are most useful when they check assumptions that you expect to always be true. For example, you might use an assertion to check that a method’s input parameter is not null.

public void DoSomething(string input)
{
    Debug.Assert(input != null, "Input parameter should not be null");
    // rest of the method
}

2 – Use descriptive assertion messages: When an assertion fails, the message that is displayed can help you quickly identify the problem. Make sure to use descriptive messages that explain what went wrong and how to fix it.

Debug.Assert(result > 0, "Expected a positive result from the calculation");

3 – Use assertions to catch unexpected behavior: In addition to checking assumptions, you can also use assertions to catch unexpected behavior in your code. For example, you might use an assertion to check that a value is within a valid range.

Debug.Assert(value >= 0 && value <= 100, "Value should be between 0 and 100");

4 – Use assertions sparingly: While assertions can be a valuable debugging tool, they can also add unnecessary overhead to your code. Use them sparingly and only in situations where you need to check assumptions or catch unexpected behavior.

It’s important to note that assertions should only be used for debugging purposes and should be disabled in production code. You can disable assertions by defining the DEBUG symbol in your project’s build configuration.

<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
    <DefineConstants>DEBUG;TRACE</DefineConstants>
</PropertyGroup>

Or This can be done by defining the DEBUG symbol in the project settings for the Debug configuration, but not for the Release configuration. This will ensure that assertions are only enabled in the Debug build.

#define DEBUG

using System.Diagnostics;

public class Example
{
    public void DoSomething(int value)
    {
        Debug.Assert(value > 0, "Value must be greater than 0");
        // Do something with the value
    }
}

Assertions can be disabled by using compiler directives. You can use the #define directive to define a symbol that enables assertions during development and testing, and then use the #undef directive to disable the symbol in the production build. For example:

#define DEBUG

// ...

#if DEBUG
    Debug.Assert(condition, message);
#endif

In this code, the DEBUG symbol is defined using the #define directive, which enables assertions during development and testing.

The #if directive checks whether the DEBUG symbol is defined, and if so, the assertion is executed using Debug.Assert().

However, in the production build, the DEBUG symbol will not be defined because it is not included in the build configuration. As a result, the #if directive will not be true, and the assertion will not be executed.

Another approach to disabling assertions in production is to use a conditional compilation constant. You can define a constant like DISABLE_ASSERTIONS in your production build configuration, and use #if !DISABLE_ASSERTIONS to check whether assertions should be executed. For example:

#if !DISABLE_ASSERTIONS
    Debug.Assert(condition, message);
#endif

In this code, the #if directive checks whether the DISABLE_ASSERTIONS constant is not defined, and if so, the assertion is executed using Debug.Assert(). However, if the DISABLE_ASSERTIONS constant is defined in the production build configuration, the #if directive will not be true, and the assertion will not be executed.

By adding assertions to your code, you can ensure that certain conditions are always true at specific points in your program’s execution. Again, just remember to disable assertions in production code to avoid unnecessary performance overhead.


Implementing Defensive Programming Techniques

Defensive programming is a set of techniques that can be used to prevent errors and mitigate the impact of errors that do occur. In the context of error handling, defensive programming involves anticipating and guarding against potential errors in your code.

Implementing Defensive Programming Techniques

Some techniques for implementing defensive programming in C# include:

Input validation

Validate any input from the user or external systems to ensure it meets the expected format and type. This can include checking for null or empty values, checking for valid data types, and checking for values that fall within acceptable ranges.

public void ProcessInput(string input)
{
    if (string.IsNullOrEmpty(input))
    {
        throw new ArgumentNullException(nameof(input), "Input cannot be null or empty");
    }

    // rest of the processing code
}

Check for null references

Null reference exceptions are a common source of errors in C#. You can use defensive programming techniques to prevent these errors by checking for null references before using them. For example, you can use the null-coalescing operator (??) to provide a default value if a reference is null.

string name = user?.Name ?? "Unknown";

Use guard clauses

Guard clauses are a technique used to check for error conditions at the beginning of a method and fail fast if any errors are detected. This technique can be used to ensure that the method is called with the correct inputs and that any prerequisites are met. For example:

public void AddProduct(Product product)
{
    if(product == null)
    {
        throw new ArgumentNullException(nameof(product));
    }

    if(product.Price <= 0)
    {
        throw new ArgumentException("Price must be greater than zero", nameof(product));
    }

    // add product to database
}

In this example, the AddProduct method uses guard clauses to ensure that the product parameter is not null and that the price is greater than zero. If either of these conditions are not met, an exception is thrown and the method fails fast.

Use defensive copies

Defensive copies are a technique used to prevent unintended modifications to objects. By creating a copy of an object before passing it to a method or returning it, you can ensure that the original object is not modified unintentionally. For example:

public void ModifyPerson(Person person)
{
    // create a defensive copy of the person object
    var copy = new Person(person.FirstName, person.LastName);

    // modify the copy
    copy.Age += 1;

    // replace the original object with the modified copy
    person = copy;
}

In this example, the ModifyPerson method creates a defensive copy of the person object before modifying it. This ensures that the original person object is not modified unintentionally.

Use Code Contracts

Code contracts can help improve the reliability and correctness of your code by specifying the preconditions, postconditions, and invariants that your code must adhere to.

By specifying these conditions using code contracts, you can catch errors earlier in the development process, and make your code more resilient to changes in requirements or inputs.

To use code contracts in your C# applications, you will first need to install the Microsoft.CodeContracts NuGet package. Once you have done this, you can start using code contracts in your code.

1 – Use contracts to specify preconditions: Use code contracts to specify the conditions that must be true before a method can be called. For example, you might use a contract to ensure that a parameter is not null before using it in your method.

public void DoSomething(string value)
{
    Contract.Requires(value != null, "value cannot be null");

    // ...
}

In this example, the Contract.Requires method specifies that the value parameter must not be null before the method is called. If the condition is not met, an ArgumentException will be thrown with the specified error message.

2 – Use contracts to specify postconditions: Use code contracts to specify the conditions that must be true after a method has been called. For example, you might use a contract to ensure that a method returns a non-null value.

public int Calculate(int a, int b)
{
    Contract.Ensures(Contract.Result<int>() >= 0, "result must be non-negative");

    int result = a + b;
    return result;
}

In this example, the Contract.Ensures method specifies that the result of the method must be non-negative. If the condition is not met, an ContractException will be thrown with the specified error message.

3 – Use contracts to specify invariants: Use code contracts to specify the conditions that must always be true within a class or struct. For example, you might use a contract to ensure that a class always has a non-null property.

public class MyClass
{
    public string Name { get; set; }

    public MyClass(string name)
    {
        Contract.Requires(name != null, "name cannot be null");

        this.Name = name;
    }

    [ContractInvariantMethod]
    private void Invariant()
    {
        Contract.Invariant(this.Name != null, "name cannot be null");
    }
}

In this example, the Contract.Invariant method specifies that the Name property must not be null. The ContractInvariantMethod attribute specifies that this method should be called after each method call to ensure that the invariants are still valid.

In conclusion, using defensive programming techniques can help prevent errors in your code and make it more robust. By anticipating potential errors and handling them proactively, you can create software that is more reliable and easier to maintain.


Handling Unhandled Exceptions

Handling unhandled exceptions is an important aspect of error handling in C# applications. Unhandled exceptions occur when an exception is thrown but not caught by any catch block, causing the application to crash.

Handling Unhandled Exceptions

To handle unhandled exceptions, you can use Application.ThreadException event, AppDomain.UnhandledException event and Environment.FailFast method.

Application.ThreadException Event Handler

The Application.ThreadException event is raised when an exception is thrown on a UI thread that is not handled by a try-catch block. You can handle this event by adding an event handler to the Application.ThreadException event. Here’s an example:

static void Main()
{
    Application.ThreadException += new ThreadExceptionEventHandler(Application_ThreadException);
    Application.Run(new MainForm());
}

static void Application_ThreadException(object sender, ThreadExceptionEventArgs e)
{
    // Handle the exception here
}

In this example, we add an event handler to the Application.ThreadException event in the Main method. The event handler logs the exception and displays an error message to the user.

AppDomain.UnhandledException event

The AppDomain.UnhandledException event is raised when an exception is thrown on a non-UI thread that is not handled by a try-catch block. You can handle this event by adding an event handler to the AppDomain.UnhandledException event. Here’s an example:

static void Main()
{
    AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
    Application.Run(new MainForm());
}

static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
    // Handle the exception here
}

In this example, we add an event handler to the AppDomain.UnhandledException event in the Main method. The event handler logs the exception and displays an error message to the user.

Environment.FailFast Method

The Environment.FailFast method can be used to terminate the application immediately in the event of an unhandled exception. This can be useful in cases where the application is in an unstable state and continuing to run could cause further issues.

try
{
    // some code that may throw an exception
}
catch (Exception ex)
{
    // log the exception
    Environment.FailFast("Application terminated due to unhandled exception", ex);
}

It’s important to note that while Environment.FailFast can be useful in certain situations, it should be used with caution as it can result in data loss and other issues if not used correctly.

By handling unhandled exceptions, you can ensure that unexpected errors do not cause your application to crash and that your users are provided with a better experience.


Conclusion

In conclusion, error handling is a crucial aspect of software development, and implementing effective error handling practices can help create stable and reliable applications.

By following the best practices for error handling in C# applications, such as handling exceptions, implementing error logging and reporting, using assertions, defensive programming techniques

References: To ensure the authenticity of the information presented in this blog post, I have used references from reliable sources such as the official Microsoft documentation on error handling in C#, StackOverflow discussions, and articles by industry experts.