Error Handling and Exception Management in the API
Implement reliable error handling in Clean Architecture with centralized middleware, custom exceptions, and consistent HTTP responses.
An unhandled exception in a default ASP.NET Core API gives the client an empty 500 and gives you a stack trace in a log nobody is watching. The only thing worse is the API that returns the stack trace to the caller. The Contact Management Application takes the middle path: one middleware catches everything, maps known exception types to sensible status codes, returns a predictable JSON shape, and logs the details server-side. This post walks through that implementation and where I would lean on the newer built-in alternatives instead.
1. Why Centralized Error Handling Earns Its Keep
Error handling in an API means anticipating failures and responding so that:
-
The system degrades gracefully: one failing request doesn’t take the process into an undefined state.
-
Clients get meaningful feedback: useful error messages that say what went wrong.
-
Internals stay internal: stack traces and infrastructure details never reach the client.
-
Developers can trace problems: structured logs carry the detail the response body deliberately omits.
Clean architecture separates domain logic from infrastructure and presentation. Error handling follows the same lines: services and repositories throw meaningful exceptions, and one place at the edge decides what the client sees.
2. Global Exception Handling with Middleware
In ASP.NET Core, error handling can be centralized in custom middleware, which captures every exception the pipeline produces in one place.
In the Contact Management Application, the custom ExceptionMiddleware does three jobs: catch all exceptions, return a meaningful error message to the client, and log the details for investigation.
2.1 ExceptionMiddleware Implementation
Here is the implementation of the ExceptionMiddleware.cs:
using Microsoft.AspNetCore.Http;using Microsoft.Extensions.Logging;using System;using System.Net;using System.Text.Json;using System.Threading.Tasks;
namespace Contact.Api.Core.Middleware{ public class ExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger) { _next = next; _logger = logger; }
public async Task InvokeAsync(HttpContext context) { try { // Process the request await _next(context); } catch (Exception ex) { // Handle any unhandled exceptions _logger.LogError(ex, "An unhandled exception occurred"); await HandleExceptionAsync(context, ex); } }
private Task HandleExceptionAsync(HttpContext context, Exception exception) { context.Response.ContentType = "application/json"; context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var errorResponse = new ErrorResponse { StatusCode = context.Response.StatusCode, Message = "An unexpected error occurred. Please try again later.", DetailedMessage = exception.Message // Can be omitted in production to avoid exposing sensitive information };
var errorJson = JsonSerializer.Serialize(errorResponse);
return context.Response.WriteAsync(errorJson); } }
public class ErrorResponse { public int StatusCode { get; set; } public string Message { get; set; } public string DetailedMessage { get; set; } }}In this middleware:
-
InvokeAsync() processes incoming requests and captures any unhandled exceptions.
-
HandleExceptionAsync() formats the exception into a standardized error response and sends it back to the client.
-
ErrorResponse defines the structure of the error message returned to the client.
2.2 Registering the Middleware
Register the middleware first in the pipeline, in Startup.cs or Program.cs, so it wraps everything that runs after it:
public class Startup{ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseMiddleware<ExceptionMiddleware>();
// Other middleware like routing, authentication, etc. app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }}With this setup, all unhandled exceptions in the application pass through the ExceptionMiddleware.
3. Categorizing and Managing Exceptions
Not all errors are the same. Some represent client-side issues (e.g., bad input), while others are server-side failures. A good practice is to categorize exceptions into specific types:
-
Validation exceptions: Errors related to invalid client inputs.
-
Not found exceptions: Errors when a resource (e.g., contact) is not found.
-
Authorization exceptions: Errors when users attempt to access unauthorized resources.
-
General exceptions: Server-side errors or unhandled exceptions.
Custom exception types per category let the middleware map each one to the right status code and message, while the domain code just throws the exception that describes what happened.
3.1 Creating Custom Exception Classes
public class NotFoundException : Exception{ public NotFoundException(string message) : base(message) { }}
public class ValidationException : Exception{ public ValidationException(string message) : base(message) { }}3.2 Handling Specific Exceptions in Middleware
We can modify the ExceptionMiddleware to handle specific exceptions differently:
private Task HandleExceptionAsync(HttpContext context, Exception exception){ context.Response.ContentType = "application/json";
var errorResponse = new ErrorResponse();
if (exception is NotFoundException) { context.Response.StatusCode = (int)HttpStatusCode.NotFound; errorResponse.Message = exception.Message; } else if (exception is ValidationException) { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; errorResponse.Message = exception.Message; } else { context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; errorResponse.Message = "An unexpected error occurred. Please try again later."; }
var errorJson = JsonSerializer.Serialize(errorResponse);
return context.Response.WriteAsync(errorJson);}Note the deliberate asymmetry: known exception types surface their own message, because those messages were written for the client. Everything else collapses to a generic 500 line, because an unexpected exception’s message was written for us, not for them.
4. Returning Consistent Error Responses
Consistency is what makes an API’s failures usable. Every error from the Contact Management Application comes back as JSON in the ErrorResponse shape defined in section 2.1, whatever the failure. Clients write one error handler, not one per endpoint.
For example, if a resource is not found:
{ "statusCode": 404, "message": "Contact not found", "detailedMessage": ""}If a validation error occurs:
{ "statusCode": 400, "message": "Invalid email format", "detailedMessage": ""}For internal server errors:
{ "statusCode": 500, "message": "An unexpected error occurred. Please try again later.", "detailedMessage": ""}5. Best Practices for Error Handling in APIs
-
Use meaningful status codes: 400 for bad requests, 404 for not found, 500 for server errors; clients route on the code before they read the body.
-
Never expose internals: no stack traces or infrastructure details in production responses.
-
Log every exception: with structured logging, so the log entry carries what the response body left out.
-
Handle known exceptions deliberately: validation and not-found cases deserve messages written for the client.
-
Fall back to a generic response: for anything unexpected, a bland “An unexpected error occurred” is the safe default.
What’s next
Pull the Contact Management Application, throw a NotFoundException from a service method, and watch it come out as a clean 404 without a single try/catch in the controller. That round trip is the fastest way to internalize the pattern. The series continues with Dockerizing the .NET Core API, Angular and MS SQL Server.
Comments
Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.