DEV Community

Emmanuel Di Nicola
Emmanuel Di Nicola

Posted on

Exception Handling in C#

Table of Contents

  1. What Is an Exception in C#?
  2. Types of Exceptions
  3. Using try-catch-finally
  4. How to Avoid Exceptions
  5. Centralizing Exception Handling
  6. Using Result Objects to Avoid Exceptions
  7. Creating Custom Exceptions
  8. Best Practices for Custom Exceptions
  9. Final Recommendation: Limit the Use of Exceptions

1. What Is an Exception in C#?

In C#, an exception is an unexpected event that occurs when an operation cannot be executed as intended. Exceptions allow you to handle runtime errors gracefully and define specific actions to prevent your program from crashing abruptly. Proper exception handling is essential for maintaining a good user experience and ensuring the stability of your application.

Example: In a data processing application, an exception might alert you to an error (like missing data) without stopping the entire process.

2. Types of Exceptions

  • Managed Exceptions: Predictable errors that your program can handle, such as a missing file. In this case, the program can create a new file or notify the user, allowing execution to continue smoothly.
  • Unhandled Exceptions: Serious errors often beyond the immediate control of your program. These errors might require stopping the program to prevent malfunctions or data loss.

3. Using try-catch-finally

Use try-catch-finally blocks to manage exceptions in a structured way:

try
{
    // Attempt to execute an operation
}
catch (Exception ex)
{
    // Handle the exception if the operation fails
}
finally
{
    // Execute code that should always run
}
Enter fullscreen mode Exit fullscreen mode
  • catch: Captures specific exceptions. For example, when trying to open a nonexistent file, you can inform the user without crashing the program.
  • finally: Executes cleanup operations, like closing a file or releasing resources, ensuring resources are available even if an error occurs.

4. How to Avoid Exceptions

4.1 Input Validation

Before performing an operation, validate inputs to avoid common errors.

Example: Before dividing two numbers, ensure the denominator isn't zero.

if (divisor != 0)
{
    int result = numerator / divisor;
}
else
{
    Console.WriteLine("Error: The divisor cannot be zero.");
}
Enter fullscreen mode Exit fullscreen mode

This proactive check prevents errors.

4.2 Use TryParse for Safe Conversions

Use TryParse to avoid errors when converting text to numbers, preventing exceptions from non-numeric input.

if (int.TryParse(input, out int result))
{
    Console.WriteLine("Conversion successful: " + result);
}
else
{
    Console.WriteLine("Invalid input.");
}
Enter fullscreen mode Exit fullscreen mode

TryParse safely checks if a conversion is possible before attempting it.

5. Centralizing Exception Handling

In large applications, it's advisable to centralize exception handling using middleware, acting as a single control point, simplifying maintenance and enhancing reliability.

5.1 Built-in Middleware in ASP.NET Core

ASP.NET Core provides built-in middleware, UseExceptionHandler, which intercepts all exceptions in the application when configured in Program.cs. This middleware adds an error-handling mechanism to the application's processing pipeline, managing exceptions centrally.

Basic Implementation of Built-in Middleware:

app.UseExceptionHandler(options =>
{
    options.Run(async context =>
    {
        context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
        context.Response.ContentType = "application/json";
        var exception = context.Features.Get<IExceptionHandlerFeature>();
        if (exception != null)
        {
            var message = $"{exception.Error.Message}";
            await context.Response.WriteAsync(message).ConfigureAwait(false);
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

5.2 Custom Middleware

In some cases, creating custom middleware allows for more detailed exception handling.

Custom Middleware Example for ASP.NET Core:

using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;

public class GlobalExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionHandlingMiddleware> _logger;

    public GlobalExceptionHandlingMiddleware(RequestDelegate next, ILogger<GlobalExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An unhandled exception has occurred.");
            await HandleExceptionAsync(context, ex);
        }
    }

    private async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var problemDetails = new ProblemDetails
        {
            Status = (int)HttpStatusCode.InternalServerError,
            Title = exception.Message,
            Detail = exception.StackTrace
        };

        context.Response.ContentType = "application/problem+json";
        context.Response.StatusCode = problemDetails.Status.Value;

        await context.Response.WriteAsJsonAsync(problemDetails);
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding the Custom Middleware:

app.UseMiddleware<GlobalExceptionHandlingMiddleware>();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
});
Enter fullscreen mode Exit fullscreen mode

Integrating this middleware ensures all incoming requests are processed centrally. Any unhandled exceptions will be captured and managed, providing a uniform response to users.

5.3 IExceptionHandler in .NET 8 and Later [Recommended]

.NET 8 introduces the IExceptionHandler interface, the recommended method for global exception handling. It's now used internally by ASP.NET Core applications for default exception handling.

Implementing IExceptionHandler:

using System.Net;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;

public class ExceptionToProblemDetailsHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetailsService;
    private readonly IHostEnvironment _hostEnvironment;

    public ExceptionToProblemDetailsHandler(IProblemDetailsService problemDetailsService, IHostEnvironment hostEnvironment)
    {
        _problemDetailsService = problemDetailsService;
        _hostEnvironment = hostEnvironment;
    }

    public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        bool isDevEnv = _hostEnvironment.IsDevelopment() || _hostEnvironment.EnvironmentName == "qa" || _hostEnvironment.EnvironmentName == "acc";

        httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

        var problemDetails = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An error occurred",
            Detail = "An unexpected error occurred. Please try again later."
        };

        if (isDevEnv)
        {
            problemDetails.Extensions["exception"] = new
            {
                details = exception.ToString(),
                headers = httpContext.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()),
                path = httpContext.Request.Path,
                endpoint = $"{httpContext.Request.Method}: {httpContext.Request.Path}",
                routeValues = httpContext.Request.RouteValues.ToDictionary(r => r.Key, r => r.Value?.ToString() ?? string.Empty)
            };
        }

        await _problemDetailsService.WriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            ProblemDetails = problemDetails,
            Exception = exception
        });

        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

In Program.cs:

builder.Services.AddExceptionHandler<ExceptionToProblemDetailsHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
Enter fullscreen mode Exit fullscreen mode

This code registers your IExceptionHandler implementation with the application's service container, along with ProblemDetails.

Suppressing ExceptionHandlerMiddleware Logs

The built-in middleware generates additional logs in the console. To avoid duplicate error messages, disable these logs by adding the following line in appsettings.json or using Serilog:

Configuration in appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "None"
    }
  },
  "AllowedHosts": "*"
}
Enter fullscreen mode Exit fullscreen mode

With Serilog:

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .Filter.ByExcluding(logEvent =>
        logEvent.Properties.ContainsKey("SourceContext") &&
        logEvent.Properties["SourceContext"].ToString().Contains("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware"))
    .CreateLogger();
Enter fullscreen mode Exit fullscreen mode

6. Using Result Objects to Avoid Exceptions

6.1 What Is a Result Object?

Result<T> allows you to return a result instead of throwing an exception. This gives you better control over errors, such as returning a response indicating an operation failed without interrupting the application's execution.

6.2 Example of a Result<T> Class

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(bool isSuccess, T? value, string? error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) =>
        new Result<T>(true, value, null);

    public static Result<T> Failure(string error) =>
        new Result<T>(false, default, error);
}
Enter fullscreen mode Exit fullscreen mode

This class returns information about the success or failure of an operation without using exceptions.

6.3 Using Result<T> in a Method

Here's an example of using the Result<T> class:

public Result<int> Divide(int numerator, int denominator)
{
    if (denominator == 0)
    {
        return Result<int>.Failure("The denominator cannot be zero.");
    }
    return Result<int>.Success(numerator / denominator);
}
Enter fullscreen mode Exit fullscreen mode

If the denominator is zero, an error message is returned instead of throwing an exception.

6.4 Handling Results

To check if an operation succeeded or failed:

var result = Divide(10, 0);

if (result.IsSuccess)
{
    Console.WriteLine("The result is: " + result.Value);
}
else
{
    Console.WriteLine("Error: " + result.Error);
}
Enter fullscreen mode Exit fullscreen mode

This allows more flexible error handling and avoids interruptions.

7. Creating Custom Exceptions

7.1 Why Create Custom Exceptions?

  • Clarity: Custom exceptions provide clearer messages about what went wrong. For example, an InvalidUserInputException is more informative than a generic Exception.
  • Precision: They allow you to capture specific errors, making them easier to resolve.

7.2 How to Create a Custom Exception

To create a custom exception, define a new class that inherits from Exception:

public class MyCustomException : Exception
{
    public MyCustomException() { }

    public MyCustomException(string message)
        : base(message) { }

    public MyCustomException(string message, Exception innerException)
        : base(message, innerException) { }
}
Enter fullscreen mode Exit fullscreen mode

Custom exceptions provide more details about errors and useful contextual information.

7.3 Converting Standard Exceptions to Custom Exceptions

Here's how to transform a standard exception into a custom exception:

try
{
    int result = 10 / divisor;
}
catch (DivideByZeroException ex)
{
    throw new MyCustomException("An error occurred during division because the divisor was zero.", ex);
}
Enter fullscreen mode Exit fullscreen mode

Custom exceptions offer better context on errors, making them easier to resolve.

8. Best Practices for Custom Exceptions

8.1 Use Descriptive Names

Ensure the exception name is explicit and reflects the nature of the error. For example, InvalidUserInputException is more descriptive than MyException. A good name makes the code more readable and helps developers quickly understand the problem.

8.2 Avoid Unnecessary Exceptions

Don't create custom exceptions for every minor issue. Focus on exceptions that add real value and help differentiate important scenarios. Too many exceptions can make the code confusing and hard to maintain.

9. Final Recommendation: Limit the Use of Exceptions

It's recommended to limit the use of exceptions in an application because managing them is performance-intensive. Exceptions interrupt the normal program flow and trigger a series of handling and stack unwinding operations, which can be costly, especially when used in loops or for regular control flow.

Instead of systematically using exceptions, consider mechanisms like Result<T> to signal operational failures in methods and control flow. This avoids the overhead of exceptions while maintaining lighter and more performant control.

Example: Exception vs. Result Object

Here's a simple example comparing the use of an exception with a Result<T>:

Using an Exception

public int DivideWithException(int numerator, int denominator)
{
    if (denominator == 0)
    {
        throw new DivideByZeroException("The denominator cannot be zero.");
    }
    return numerator / denominator;
}
Enter fullscreen mode Exit fullscreen mode

Using Result<T>

public Result<int> DivideWithResult(int numerator, int denominator)
{
    if (denominator == 0)
    {
        return Result<int>.Failure("The denominator cannot be zero.");
    }
    return Result<int>.Success(numerator / denominator);
}
Enter fullscreen mode Exit fullscreen mode

By using Result<T>, we avoid the cost of an exception when the denominator is zero. The method simply returns a failure result, allowing the calling code to handle the failure without triggering a costly interruption.

Performance Benchmark: Exception vs. Result Object

A simple performance test was conducted using BenchmarkDotNet, with memory and CPU diagnostics enabled to accurately measure the difference between the two approaches. The benchmark code is configured with BenchmarkDotNet diagnosers, including MemoryDiagnoser, ThreadingDiagnoser, and EtwProfiler (specific to Windows for detailed CPU profiling).

Benchmark Code

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnostics.Windows;
using BenchmarkDotNet.Running;
using System.Threading.Tasks;

[MemoryDiagnoser]
[ThreadingDiagnoser]
[EtwProfiler] // Usable only on Windows for detailed CPU profiling
public class ExceptionVsResultBenchmark
{
    [Benchmark]
    public void TestDivideWithException()
    {
        for (int i = 0; i < 1000; i++)
        {
            try
            {
                DivideWithException(10, i % 2 == 0 ? 1 : 0);
            }
            catch { }
        }
    }

    [Benchmark]
    public void TestDivideWithResult()
    {
        for (int i = 0; i < 1000; i++)
        {
            DivideWithResult(10, i % 2 == 0 ? 1 : 0);
        }
    }

    public int DivideWithException(int numerator, int denominator)
    {
        return denominator == 0 ? throw new DivideByZeroException() : numerator / denominator;
    }

    public Result<int> DivideWithResult(int numerator, int denominator)
    {
        return denominator == 0
            ? Result<int>.Failure("The denominator cannot be zero.")
            : Result<int>.Success(numerator / denominator);
    }
}

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    protected Result(bool isSuccess, T? value, string? error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) => new Result<T>(true, value, null);
    public static Result<T> Failure(string error) => new Result<T>(false, default, error);
}

public class Program
{
    public static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<ExceptionVsResultBenchmark>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Benchmark Results

Method Mean Error StdDev Gen0 Allocations Allocated Memory
TestDivideWithException 1,734.131 μs 34.5458 μs 77.9755 μs 7.8125 109.38 KB
TestDivideWithResult 2.531 μs 0.0662 μs 0.1846 μs 2.4452 31.25 KB

Interpretation:

  • Exception Approach: Significantly higher execution time and memory allocation.
  • Result Object Approach: Much faster and uses less memory.

These results highlight the importance of limiting the use of exceptions to exceptional or unforeseen situations and favoring alternatives like Result<T> to signal errors more efficiently in code.


Top comments (0)