Back to Deep Dives

Global Exception Handling in C#: Production-Ready Patterns

C#.NETError HandlingASP.NET CoreBest Practices

Comprehensive guide to implementing robust global exception handling in C# applications, covering ASP.NET Core middleware, logging strategies, and error response patterns.

Global Exception Handling in C#: Production-Ready Patterns

Exception handling can make or break production systems. After debugging countless production incidents, I've learned that proper global exception handling is critical for building reliable applications.

Table of Contents

Why Global Exception Handling Matters

Unhandled exceptions lead to:

  • Poor user experience - Cryptic error messages
  • Security risks - Exposed stack traces and internal details
  • Lost context - Missing diagnostic information
  • Inconsistent responses - Different error formats across endpoints

ASP.NET Core Exception Handling Middleware

Built-in Exception Handler

// Program.cs
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

// Error handling endpoint
app.MapGet("/error", (HttpContext context) =>
{
    var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
    var exception = exceptionFeature?.Error;
    
    // Log exception
    var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
    logger.LogError(exception, "Unhandled exception occurred");
    
    return Results.Problem(
        title: "An error occurred",
        statusCode: StatusCodes.Status500InternalServerError);
});

Custom Exception Handling Middleware

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

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

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

    private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        context.Response.ContentType = "application/json";
        
        var response = exception switch
        {
            ValidationException validationEx => new
            {
                StatusCode = StatusCodes.Status400BadRequest,
                Message = "Validation failed",
                Errors = validationEx.Errors
            },
            NotFoundException notFoundEx => new
            {
                StatusCode = StatusCodes.Status404NotFound,
                Message = notFoundEx.Message
            },
            UnauthorizedException => new
            {
                StatusCode = StatusCodes.Status401Unauthorized,
                Message = "Unauthorized access"
            },
            ForbiddenException => new
            {
                StatusCode = StatusCodes.Status403Forbidden,
                Message = "Access forbidden"
            },
            _ => new
            {
                StatusCode = StatusCodes.Status500InternalServerError,
                Message = "An internal server error occurred"
            }
        };

        context.Response.StatusCode = response.StatusCode;
        await context.Response.WriteAsJsonAsync(response);
    }
}

// Extension method for registration
public static class ExceptionMiddlewareExtensions
{
    public static IApplicationBuilder UseGlobalExceptionHandler(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<GlobalExceptionHandlerMiddleware>();
    }
}

// Usage in Program.cs
app.UseGlobalExceptionHandler();

Custom Exception Types

Domain-Specific Exceptions

// Base exception for domain errors
public abstract class DomainException : Exception
{
    protected DomainException(string message) : base(message) { }
    
    protected DomainException(string message, Exception innerException) 
        : base(message, innerException) { }
}

// Validation errors
public class ValidationException : DomainException
{
    public ValidationException(Dictionary<string, string[]> errors)
        : base("One or more validation errors occurred")
    {
        Errors = errors;
    }

    public Dictionary<string, string[]> Errors { get; }
}

// Not found errors
public class NotFoundException : DomainException
{
    public NotFoundException(string entityName, object key)
        : base($"{entityName} with key '{key}' was not found")
    {
        EntityName = entityName;
        Key = key;
    }

    public string EntityName { get; }
    public object Key { get; }
}

// Business rule violations
public class BusinessRuleViolationException : DomainException
{
    public BusinessRuleViolationException(string rule, string message)
        : base(message)
    {
        Rule = rule;
    }

    public string Rule { get; }
}

// Authorization errors
public class UnauthorizedException : DomainException
{
    public UnauthorizedException(string message = "Unauthorized") 
        : base(message) { }
}

public class ForbiddenException : DomainException
{
    public ForbiddenException(string message = "Forbidden") 
        : base(message) { }
}

Problem Details (RFC 7807) Response Format

Standard Problem Details

public class ProblemDetailsMiddleware
{
    private readonly RequestDelegate _next;

    public ProblemDetailsMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var problemDetails = exception switch
        {
            ValidationException validationEx => new ValidationProblemDetails(validationEx.Errors)
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "One or more validation errors occurred",
                Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
                Instance = context.Request.Path
            },
            NotFoundException notFoundEx => new ProblemDetails
            {
                Status = StatusCodes.Status404NotFound,
                Title = "Resource not found",
                Detail = notFoundEx.Message,
                Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
                Instance = context.Request.Path,
                Extensions =
                {
                    ["entityName"] = notFoundEx.EntityName,
                    ["key"] = notFoundEx.Key
                }
            },
            BusinessRuleViolationException businessEx => new ProblemDetails
            {
                Status = StatusCodes.Status422UnprocessableEntity,
                Title = "Business rule violation",
                Detail = businessEx.Message,
                Type = "https://tools.ietf.org/html/rfc4918#section-11.2",
                Instance = context.Request.Path,
                Extensions =
                {
                    ["rule"] = businessEx.Rule
                }
            },
            _ => new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "An error occurred while processing your request",
                Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
                Instance = context.Request.Path
            }
        };

        // Add trace ID for debugging
        problemDetails.Extensions["traceId"] = context.TraceIdentifier;

        context.Response.StatusCode = problemDetails.Status ?? StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/problem+json";
        
        await context.Response.WriteAsJsonAsync(problemDetails);
    }
}

Logging Best Practices

Structured Logging with Context

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

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

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await LogExceptionWithContextAsync(context, ex);
            await HandleExceptionAsync(context, ex);
        }
    }

    private async Task LogExceptionWithContextAsync(HttpContext context, Exception exception)
    {
        var userId = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var requestBody = await GetRequestBodyAsync(context);

        using (_logger.BeginScope(new Dictionary<string, object>
        {
            ["TraceId"] = context.TraceIdentifier,
            ["UserId"] = userId ?? "Anonymous",
            ["RequestPath"] = context.Request.Path,
            ["RequestMethod"] = context.Request.Method,
            ["QueryString"] = context.Request.QueryString.ToString(),
            ["RequestBody"] = requestBody,
            ["UserAgent"] = context.Request.Headers["User-Agent"].ToString(),
            ["IpAddress"] = context.Connection.RemoteIpAddress?.ToString()
        }))
        {
            _logger.LogError(exception, 
                "Unhandled exception occurred for {RequestMethod} {RequestPath}",
                context.Request.Method,
                context.Request.Path);
        }
    }

    private static async Task<string> GetRequestBodyAsync(HttpContext context)
    {
        context.Request.EnableBuffering();
        
        using var reader = new StreamReader(
            context.Request.Body,
            Encoding.UTF8,
            leaveOpen: true);
        
        var body = await reader.ReadToEndAsync();
        context.Request.Body.Position = 0;
        
        // Sanitize sensitive data
        return SanitizeSensitiveData(body);
    }

    private static string SanitizeSensitiveData(string data)
    {
        // Remove passwords, tokens, credit cards, etc.
        var patterns = new[]
        {
            @"""password""\s*:\s*""[^""]*""",
            @"""token""\s*:\s*""[^""]*""",
            @"""creditCard""\s*:\s*""[^""]*"""
        };

        foreach (var pattern in patterns)
        {
            data = Regex.Replace(data, pattern, "\"***REDACTED***\"", RegexOptions.IgnoreCase);
        }

        return data;
    }

    private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        // Handle exception response
    }
}

Result Pattern (Alternative to Exceptions)

Result Type Implementation

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

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

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

    public TResult Match<TResult>(
        Func<T, TResult> onSuccess,
        Func<Error, TResult> onFailure)
    {
        return IsSuccess ? onSuccess(Value) : onFailure(Error);
    }
}

public record Error(string Code, string Message);

// Usage in service layer
public class OrderService
{
    public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request)
    {
        // Validation
        if (request.Items.Count == 0)
        {
            return Result<Order>.Failure(
                new Error("EMPTY_ORDER", "Order must contain at least one item"));
        }

        // Business logic
        try
        {
            var order = await _repository.CreateAsync(request);
            return Result<Order>.Success(order);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to create order");
            return Result<Order>.Failure(
                new Error("ORDER_CREATION_FAILED", "Failed to create order"));
        }
    }
}

// Usage in controller
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
    var result = await _orderService.CreateOrderAsync(request);

    return result.Match(
        onSuccess: order => CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order),
        onFailure: error => error.Code switch
        {
            "EMPTY_ORDER" => BadRequest(new { error.Message }),
            "ORDER_CREATION_FAILED" => StatusCode(500, new { error.Message }),
            _ => StatusCode(500, new { Message = "An unexpected error occurred" })
        });
}

Background Job Exception Handling

Hangfire Example

public class GlobalJobFilter : JobFilterAttribute, IServerFilter
{
    private readonly ILogger<GlobalJobFilter> _logger;

    public GlobalJobFilter(ILogger<GlobalJobFilter> logger)
    {
        _logger = logger;
    }

    public void OnPerforming(PerformingContext filterContext)
    {
        _logger.LogInformation(
            "Starting job {JobType} with ID {JobId}",
            filterContext.BackgroundJob.Job.Type.Name,
            filterContext.BackgroundJob.Id);
    }

    public void OnPerformed(PerformedContext filterContext)
    {
        if (filterContext.Exception != null)
        {
            _logger.LogError(
                filterContext.Exception,
                "Job {JobType} with ID {JobId} failed",
                filterContext.BackgroundJob.Job.Type.Name,
                filterContext.BackgroundJob.Id);

            // Send alert to monitoring system
            SendAlertAsync(filterContext.Exception, filterContext.BackgroundJob);

            // Don't rethrow - let Hangfire handle retry
        }
        else
        {
            _logger.LogInformation(
                "Completed job {JobType} with ID {JobId}",
                filterContext.BackgroundJob.Job.Type.Name,
                filterContext.BackgroundJob.Id);
        }
    }

    private void SendAlertAsync(Exception exception, BackgroundJob job)
    {
        // Send to Slack, PagerDuty, etc.
    }
}

// Registration
GlobalJobFilters.Filters.Add(new GlobalJobFilter(logger));

Entity Framework Exception Handling

public class DbContextExceptionHandler
{
    public static async Task<Result<T>> ExecuteDbOperationAsync<T>(
        Func<Task<T>> operation,
        ILogger logger)
    {
        try
        {
            var result = await operation();
            return Result<T>.Success(result);
        }
        catch (DbUpdateConcurrencyException ex)
        {
            logger.LogWarning(ex, "Concurrency conflict occurred");
            return Result<T>.Failure(
                new Error("CONCURRENCY_CONFLICT", "The record was modified by another user"));
        }
        catch (DbUpdateException ex) when (ex.InnerException is SqlException sqlEx)
        {
            return sqlEx.Number switch
            {
                2627 or 2601 => // Unique constraint violation
                    Result<T>.Failure(new Error("DUPLICATE_ENTRY", "A record with this value already exists")),
                547 => // Foreign key violation
                    Result<T>.Failure(new Error("FOREIGN_KEY_VIOLATION", "Cannot delete record due to related data")),
                _ => Result<T>.Failure(new Error("DATABASE_ERROR", "A database error occurred"))
            };
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unexpected database error");
            return Result<T>.Failure(
                new Error("UNEXPECTED_ERROR", "An unexpected error occurred"));
        }
    }
}

// Usage
public async Task<Result<Order>> UpdateOrderAsync(Order order)
{
    return await DbContextExceptionHandler.ExecuteDbOperationAsync(
        async () =>
        {
            _context.Orders.Update(order);
            await _context.SaveChangesAsync();
            return order;
        },
        _logger);
}

Exception Handling in Minimal APIs

// Program.cs
app.MapGet("/api/users/{id}", async (int id, IUserService userService) =>
{
    try
    {
        var user = await userService.GetByIdAsync(id);
        return Results.Ok(user);
    }
    catch (NotFoundException ex)
    {
        return Results.NotFound(new { Message = ex.Message });
    }
    catch (Exception ex)
    {
        // Log exception
        return Results.Problem("An error occurred");
    }
});

// Better: Use filters
public class ExceptionHandlingFilter : IEndpointFilter
{
    private readonly ILogger<ExceptionHandlingFilter> _logger;

    public ExceptionHandlingFilter(ILogger<ExceptionHandlingFilter> logger)
    {
        _logger = logger;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        try
        {
            return await next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception in endpoint");
            
            return ex switch
            {
                NotFoundException notFound => Results.NotFound(new { Message = notFound.Message }),
                ValidationException validation => Results.BadRequest(new { validation.Errors }),
                _ => Results.Problem("An error occurred")
            };
        }
    }
}

// Apply globally
app.MapGet("/api/users/{id}", async (int id, IUserService userService) =>
{
    var user = await userService.GetByIdAsync(id);
    return Results.Ok(user);
})
.AddEndpointFilter<ExceptionHandlingFilter>();

Testing Exception Handling

public class ExceptionHandlingMiddlewareTests
{
    [Fact]
    public async Task Should_Return_NotFound_For_NotFoundException()
    {
        // Arrange
        var middleware = new GlobalExceptionHandlerMiddleware(
            context => throw new NotFoundException("User", 123),
            Mock.Of<ILogger<GlobalExceptionHandlerMiddleware>>());

        var context = new DefaultHttpContext();
        context.Response.Body = new MemoryStream();

        // Act
        await middleware.InvokeAsync(context);

        // Assert
        Assert.Equal(404, context.Response.StatusCode);
        
        context.Response.Body.Seek(0, SeekOrigin.Begin);
        var response = await JsonSerializer.DeserializeAsync<ProblemDetails>(
            context.Response.Body);
        
        Assert.Equal("Resource not found", response.Title);
    }

    [Fact]
    public async Task Should_Return_500_For_UnhandledException()
    {
        // Arrange
        var middleware = new GlobalExceptionHandlerMiddleware(
            context => throw new InvalidOperationException("Unexpected error"),
            Mock.Of<ILogger<GlobalExceptionHandlerMiddleware>>());

        var context = new DefaultHttpContext();
        context.Response.Body = new MemoryStream();

        // Act
        await middleware.InvokeAsync(context);

        // Assert
        Assert.Equal(500, context.Response.StatusCode);
    }
}

Production Checklist

  • ✅ Implement global exception middleware
  • ✅ Use custom exception types for domain errors
  • ✅ Return consistent error responses (Problem Details)
  • ✅ Log exceptions with context (user, request, trace ID)
  • ✅ Sanitize sensitive data in logs
  • ✅ Never expose stack traces to clients
  • ✅ Set up monitoring and alerting
  • ✅ Handle database-specific exceptions
  • ✅ Implement retry logic for transient failures
  • ✅ Test exception handling paths
  • ✅ Document error codes for API consumers
  • ✅ Use correlation IDs across distributed systems

Key Takeaways

Proper exception handling is not about catching every exception—it's about:

  1. Providing meaningful feedback to users
  2. Logging enough context for debugging
  3. Protecting sensitive information from exposure
  4. Maintaining consistency across your API
  5. Making systems resilient to failures

Remember: Exceptions are expensive. Use them for exceptional cases, not flow control. Consider the Result pattern for expected failures.


Have questions about exception handling strategies? Let's discuss!