Table of Contents
- What Is an Exception in C#?
- Types of Exceptions
- Using
try-catch-finally
- How to Avoid Exceptions
- Centralizing Exception Handling
- Using Result Objects to Avoid Exceptions
- Creating Custom Exceptions
- Best Practices for Custom Exceptions
- 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
}
-
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.");
}
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.");
}
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);
}
});
});
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);
}
}
Adding the Custom Middleware:
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
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;
}
}
In Program.cs
:
builder.Services.AddExceptionHandler<ExceptionToProblemDetailsHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
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": "*"
}
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();
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);
}
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);
}
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);
}
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 genericException
. - 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) { }
}
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);
}
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;
}
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);
}
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>();
}
}
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)