Handling Errors and Exceptions Gracefully in ASP.NET Core
Building Robust Web Applications That Fail Elegantly
Error handling is a critical aspect of any production-grade application, yet it's often an afterthought in the development process. In ASP.NET Core, implementing proper exception handling strategies not only improves user experience but also provides valuable diagnostic information while maintaining security.
This guide explores comprehensive approaches to gracefully handle errors in your ASP.NET Core applications, from simple middleware solutions to sophisticated global exception handling patterns.
Introduction
We've all been there—deploying what we believe is a flawless application, only to receive reports of users encountering the dreaded "Something went wrong" screen or, worse, detailed exception information that should never have been exposed. Proper error handling is not just about catching exceptions; it's about creating a resilient application that degrades gracefully under unexpected conditions.
ASP.NET Core provides several layers and techniques for handling exceptions, each serving different purposes and scenarios. In this article, we'll explore these various approaches, from simple try-catch blocks to comprehensive global exception handling strategies that can transform how your application behaves when things inevitably go wrong.
Understanding Exceptions in ASP.NET Core
Before diving into implementation details, let's understand the types of exceptions you might encounter in an ASP.NET Core application.
Common Exception Types
ASP.NET Core applications typically encounter several categories of exceptions:
Framework Exceptions: These originate from the ASP.NET Core framework itself, such as
InvalidOperationException
when middleware is configured incorrectly.Infrastructure Exceptions: These include database connectivity issues, network failures, or external service timeouts.
Business Logic Exceptions: These are custom exceptions that represent domain-specific error conditions in your application.
Client-Side Exceptions: These result from invalid client requests, such as improper input validation or unauthorized access attempts.
Understanding the nature of these exceptions helps determine the appropriate handling strategy. For instance, a database connection failure might require a retry mechanism, whereas an invalid user input should simply return a validation error message.
The Exception Handling Pipeline
ASP.NET Core processes requests through a pipeline of middleware components. When an exception occurs, it bubbles up through this pipeline until it's caught or reaches the top level, where it results in a default error response.
The request processing pipeline follows this general flow:
A request enters the application
Middleware components process the request
If an exception occurs and isn't handled, it bubbles up
The exception either gets caught by exception handling middleware or results in a default error page
Let's explore the various ways to handle exceptions at different levels of this pipeline.
Basic Exception Handling with Try-Catch
At its simplest, exception handling involves using try-catch blocks around code that might throw exceptions. While this approach is fundamental, it's also the most granular and can lead to repetitive code if overused.
csharp
[HttpGet("products/{id}")]
public IActionResult GetProduct(int id)
{
try
{
var product = _productRepository.GetById(id);
if (product == null)
return NotFound();
return Ok(product);
}
catch (DatabaseConnectionException ex)
{
_logger.LogError(ex, "Database connection failed when retrieving product {Id}", id);
return StatusCode(503, "The service is temporarily unavailable. Please try again later.");
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while retrieving product {Id}", id);
return StatusCode(500, "An unexpected error occurred. Please try again later.");
}
}
This approach has several advantages:
It provides fine-grained control over how specific exceptions are handled
It allows custom responses based on exception type
It enables you to log detailed context-specific information
However, using try-catch blocks in every action method leads to:
Code duplication
Inconsistent error handling across the application
Difficulty maintaining a unified error response format
For these reasons, try-catch blocks are best reserved for truly exceptional cases where you need specific handling that deviates from your application's standard error handling approach.
Using Middleware for Exception Handling
ASP.NET Core's middleware architecture provides an elegant way to centralize exception handling. The UseExceptionHandler
middleware captures exceptions thrown in the application and processes them through a dedicated error handling path.
csharp
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
// Other middleware registrations...
}
In development environments, the UseDeveloperExceptionPage
middleware provides detailed exception information useful for debugging. In production, UseExceptionHandler
redirects to a designated error handler that can present a user-friendly error page.
For more control, you can implement a custom exception handling middleware:
csharp
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ErrorHandlingMiddleware> _logger;
public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> 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 Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var response = new { error = "An unexpected error occurred" };
var statusCode = StatusCodes.Status500InternalServerError;
if (exception is NotFoundException)
{
response = new { error = "The requested resource was not found" };
statusCode = StatusCodes.Status404NotFound;
}
else if (exception is UnauthorizedAccessException)
{
response = new { error = "You are not authorized to access this resource" };
statusCode = StatusCodes.Status403Forbidden;
}
context.Response.StatusCode = statusCode;
return context.Response.WriteAsJsonAsync(response);
}
}
// Extension method for cleaner registration
public static class ErrorHandlingMiddlewareExtensions
{
public static IApplicationBuilder UseErrorHandling(this IApplicationBuilder builder)
{
return builder.UseMiddleware<ErrorHandlingMiddleware>();
}
}
Register this middleware before other middleware components that might throw exceptions:
csharp
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Exception handling middleware first
app.UseErrorHandling();
// Other middleware
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// ...
}
This approach centralizes exception handling logic, ensuring consistent error responses throughout your application.
Using Filters for Controller-Level Exception Handling
While middleware handles exceptions across the entire application, filters provide more granular control at the controller or action level. ASP.NET Core offers exception filters that run when an exception occurs within a controller action.
csharp
public class GlobalExceptionFilter : IExceptionFilter
{
private readonly ILogger<GlobalExceptionFilter> _logger;
private readonly IWebHostEnvironment _env;
public GlobalExceptionFilter(
ILogger<GlobalExceptionFilter> logger,
IWebHostEnvironment env)
{
_logger = logger;
_env = env;
}
public void OnException(ExceptionContext context)
{
_logger.LogError(context.Exception, "Unhandled exception");
var errorResponse = new ErrorResponse
{
Message = _env.IsDevelopment()
? context.Exception.Message
: "An unexpected error occurred"
};
if (_env.IsDevelopment())
{
errorResponse.Detail = context.Exception.StackTrace;
}
context.Result = new JsonResult(errorResponse)
{
StatusCode = StatusCodes.Status500InternalServerError
};
context.ExceptionHandled = true;
}
}
public class ErrorResponse
{
public string Message { get; set; }
public string Detail { get; set; }
}
You can register this filter globally in Startup.cs
:
csharp
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
});
// Other service registrations...
}
Or apply it to specific controllers or actions:
csharp
[ApiController]
[ServiceFilter(typeof(GlobalExceptionFilter))]
public class ProductsController : ControllerBase
{
// Controller actions...
}
Exception filters offer advantages such as:
More context about the controller and action that caused the exception
Integration with the MVC action invocation pipeline
Ability to override default behaviors for specific controllers or actions
However, they only apply to exceptions occurring within the MVC pipeline, not exceptions in middleware or during request processing outside of controller actions.
Problem Details for HTTP APIs
ASP.NET Core supports the RFC 7807 standard for problem details in HTTP APIs. This standard defines a common format for returning error information from HTTP APIs.
First, add the ProblemDetails support to your application:
csharp
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Instance = context.HttpContext.Request.Path,
Status = StatusCodes.Status400BadRequest,
Type = "https://example.com/modelvalidation",
Detail = "Please refer to the errors property for additional details."
};
return new BadRequestObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json", "application/problem+xml" }
};
};
});
}
Then, in your exception handling middleware or filter:
csharp
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred",
Instance = context.Request.Path,
Detail = exception.Message,
Type = "https://example.com/errors/internal-server-error"
};
if (exception is NotFoundException)
{
problemDetails.Status = StatusCodes.Status404NotFound;
problemDetails.Title = "Resource not found";
problemDetails.Type = "https://example.com/errors/not-found";
}
// Handle other exception types...
context.Response.StatusCode = problemDetails.Status.Value;
context.Response.ContentType = "application/problem+json";
return context.Response.WriteAsJsonAsync(problemDetails);
}
Problem details provide a standardized way to return error information, improving interoperability with other systems and making error handling more consistent.
Handling Model Validation Errors
For Web APIs, model validation errors are a common scenario. ASP.NET Core provides automatic handling for model validation via the [ApiController]
attribute:
csharp
[ApiController]
[Route("[controller]")]
public class ProductsController : ControllerBase
{
[HttpPost]
public IActionResult CreateProduct(ProductDto product)
{
// If model is invalid, a 400 Bad Request is automatically returned
// by the framework with validation errors
// Implementation...
}
}
For more control, you can customize the validation response:
csharp
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value.Errors.Count > 0)
.Select(e => new
{
Field = e.Key,
Errors = e.Value.Errors.Select(er => er.ErrorMessage).ToArray()
})
.ToArray();
return new BadRequestObjectResult(new
{
Message = "Validation failed",
Errors = errors
});
};
});
}
This approach ensures consistent validation error responses throughout your application.
Implementing a Global Exception Handler
For comprehensive exception handling, you can implement a global exception handler that combines middleware and filters with custom exception types.
First, define custom exception types:
csharp
public class AppException : Exception
{
public int StatusCode { get; }
public AppException(string message, int statusCode = StatusCodes.Status500InternalServerError)
: base(message)
{
StatusCode = statusCode;
}
}
public class NotFoundException : AppException
{
public NotFoundException(string message)
: base(message, StatusCodes.Status404NotFound)
{
}
}
public class BadRequestException : AppException
{
public BadRequestException(string message)
: base(message, StatusCodes.Status400BadRequest)
{
}
}
public class ForbiddenException : AppException
{
public ForbiddenException(string message)
: base(message, StatusCodes.Status403Forbidden)
{
}
}
Next, implement a comprehensive exception handling middleware:
csharp
public class GlobalExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionHandlingMiddleware> _logger;
private readonly IWebHostEnvironment _env;
public GlobalExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<GlobalExceptionHandlingMiddleware> logger,
IWebHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private Task HandleExceptionAsync(HttpContext context, Exception exception)
{
_logger.LogError(exception, "An unhandled exception occurred");
var statusCode = StatusCodes.Status500InternalServerError;
var title = "An unexpected error occurred";
var detail = _env.IsDevelopment() ? exception.ToString() : null;
if (exception is AppException appException)
{
statusCode = appException.StatusCode;
title = exception.Message;
}
else if (exception is UnauthorizedAccessException)
{
statusCode = StatusCodes.Status403Forbidden;
title = "Forbidden";
}
// Handle other exception types...
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = detail,
Instance = context.Request.Path
};
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
return context.Response.WriteAsJsonAsync(problemDetails);
}
}
Register this middleware in your Startup.cs
:
csharp
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Custom exception handling first
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();
// Other middleware...
}
Now you can throw these custom exceptions from anywhere in your application:
csharp
public class ProductService
{
public Product GetById(int id)
{
var product = _repository.GetById(id);
if (product == null)
throw new NotFoundException($"Product with ID {id} not found");
return product;
}
}
This approach provides:
Consistent error responses across your application
Type-safe exception handling that maps to HTTP status codes
Centralized logging of exceptions
Environment-aware error details (detailed in development, sanitized in production)
Logging Exceptions Effectively
Proper exception logging is crucial for diagnosing issues in production. ASP.NET Core's built-in logging system supports structured logging, which preserves the context of exceptions.
csharp
try
{
// Code that might throw an exception
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order {OrderId} for customer {CustomerId}",
order.Id, customer.Id);
throw; // Re-throw to be handled by global exception handler
}
When logging exceptions:
Always include the exception object as the first parameter to capture the stack trace
Use structured logging with named placeholders for contextual information
Include enough information to reproduce the issue, but avoid logging sensitive data
Use appropriate log levels (Error for exceptions, Critical for fatal errors)
For production environments, consider integrating with centralized logging systems like:
Azure Application Insights
Elasticsearch/Logstash/Kibana (ELK) stack
Serilog with various sinks
Application Performance Monitoring (APM) solutions
These systems provide aggregation, filtering, and alerting capabilities essential for monitoring production applications.
Implementing Custom Error Pages
For MVC applications, custom error pages improve user experience when exceptions occur. ASP.NET Core supports status code pages for different HTTP status codes:
csharp
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
// Status code pages
app.UseStatusCodePagesWithReExecute("/StatusCode/{0}");
}
// Other middleware...
}
Create a controller to handle these error routes:
csharp
public class StatusCodeController : Controller
{
[Route("/StatusCode/{statusCode}")]
public IActionResult Index(int statusCode)
{
var viewModel = new ErrorViewModel
{
StatusCode = statusCode,
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier
};
switch (statusCode)
{
case 404:
viewModel.Message = "The page you requested could not be found.";
break;
case 500:
viewModel.Message = "An internal server error occurred.";
break;
default:
viewModel.Message = "An error occurred.";
break;
}
return View(viewModel);
}
}
Create corresponding views with user-friendly error messages and potentially helpful actions like returning to the home page or contacting support.
Handling AJAX and API Errors
For single-page applications or AJAX-heavy sites, client-side error handling is equally important. You can create a consistent approach for handling API errors on the client:
javascript
// Example using fetch API
async function fetchData(url) {
try {
const response = await fetch(url);
if (!response.ok) {
let errorData;
try {
// Try to parse response as JSON
errorData = await response.json();
} catch {
// If parsing fails, use status text
errorData = { title: response.statusText };
}
throw new ApiError(
errorData.title || 'An error occurred',
response.status,
errorData.detail
);
}
return await response.json();
} catch (error) {
// Handle network errors
if (!(error instanceof ApiError)) {
error = new ApiError('Network error', 0, 'Could not connect to the server');
}
// Log error
console.error(`API Error (${error.status}): ${error.message}`, error);
// Show user-friendly notification
showErrorNotification(error.message);
// Re-throw for caller to handle if needed
throw error;
}
}
class ApiError extends Error {
constructor(message, status, detail = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.detail = detail;
}
}
function showErrorNotification(message) {
// Show toast, modal, or other UI notification
}
This client-side approach complements your server-side error handling to provide a seamless experience even when errors occur.
Best Practices for Exception Handling
To summarize, here are some best practices for exception handling in ASP.NET Core applications:
Use multiple layers of defense: Combine middleware, filters, and try-catch blocks for comprehensive coverage.
Be environment-aware: Show detailed errors in development, but sanitized information in production.
Create meaningful custom exceptions: Define custom exception types that represent specific error conditions in your domain.
Log exceptions properly: Use structured logging with context information for easier troubleshooting.
Return standardized error responses: Use ProblemDetails for APIs and custom error pages for web applications.
Handle both expected and unexpected errors: Account for predictable errors (like validation failures) and unexpected exceptions.
Don't expose sensitive information: Never return stack traces, connection strings, or other sensitive details to clients in production.
Test error handling paths: Write unit tests specifically for exception scenarios to ensure your error handling works as expected.
Monitor and alert on exceptions: Set up monitoring and alerting for exceptions in production to catch issues early.
Document your error responses: Include error response details in your API documentation so consumers know what to expect.
Advanced Techniques
For mature applications, consider these advanced exception handling techniques:
Circuit breakers: Implement circuit breakers for external service calls to fail fast when a service is experiencing issues.
csharp
public class ResilientHttpClient
{
private readonly HttpClient _httpClient;
private readonly CircuitBreakerPolicy _circuitBreaker;
public ResilientHttpClient(HttpClient httpClient)
{
_httpClient = httpClient;
_circuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromMinutes(1),
onBreak: (ex, breakDuration) =>
_logger.LogWarning(ex, "Circuit broken for {BreakDuration}", breakDuration),
onReset: () =>
_logger.LogInformation("Circuit reset"),
onHalfOpen: () =>
_logger.LogInformation("Circuit half-open")
);
}
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
return await _circuitBreaker.ExecuteAsync(() => _httpClient.SendAsync(request));
}
}
Retry policies: Implement automatic retries for transient failures.
csharp
public class RetryingHttpClient
{
private readonly HttpClient _httpClient;
private readonly RetryPolicy<HttpResponseMessage> _retryPolicy;
public RetryingHttpClient(HttpClient httpClient)
{
_httpClient = httpClient;
_retryPolicy = Policy
.HandleResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (result, timeSpan, retryCount, context) =>
{
_logger.LogWarning(
"Request failed with {StatusCode}. Waiting {RetryTime} before retry {RetryCount}",
result.Result.StatusCode, timeSpan, retryCount);
}
);
}
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
return await _retryPolicy.ExecuteAsync(() => _httpClient.SendAsync(request));
}
}
Exception enrichment: Add context to exceptions as they bubble up.
csharp
public async Task ProcessOrderAsync(Order order)
{
try
{
await _paymentService.ProcessPaymentAsync(order.Payment);
}
catch (Exception ex)
{
// Enrich the exception with context
throw new OrderProcessingException(
$"Failed to process payment for order {order.Id}", ex);
}
}
Health checks and self-healing: Implement health checks that take remedial action when problems are detected.
csharp
public void ConfigureServices(IServiceCollection services)
{
services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("Database")
.AddCheck<ExternalApiHealthCheck>("ExternalApi");
}
public void Configure(IApplicationBuilder app)
{
// Other middleware...
app.UseHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description
})
});
await context.Response.WriteAsync(result);
}
});
}
Conclusion
Error handling is not just an implementation detail—it's a fundamental aspect of application architecture that impacts user experience, security, and maintainability. By implementing robust exception handling in your ASP.NET Core applications, you create systems that fail gracefully, provide meaningful feedback to users, and enable efficient troubleshooting.
The multi-layered approach described in this article—combining middleware, filters, custom exceptions, proper logging, and user-friendly error pages—provides a comprehensive strategy for handling errors at all levels of your application.
Remember that the goal of exception handling is not just to prevent crashes but to create a resilient system that responds appropriately to the inevitable errors and exceptional conditions that occur in any real-world application. By adopting these practices, you'll build more robust, maintainable, and user-friendly ASP.NET Core applications.
Join The Community
Enjoyed this deep dive into ASP.NET Core error handling? Don't miss future articles, tips, and discussions about ASP.NET development. Subscribe to ASP Today on Substack to receive new posts directly in your inbox, and join our community on Substack Chat to connect with other developers, share your experiences, and get your questions answered.