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.
What’s covered#
- Global exception handling with a custom ExceptionMiddleware
- Categorizing exceptions (validation, not-found, authorization) and mapping them to status codes
- Returning one consistent ErrorResponse shape for every failure
- The production trap hiding in
DetailedMessage, and how this compares to .NET’s built-inIExceptionHandler/ProblemDetails
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.
DetailedMessage echoes exception.Message back to the caller. Exception messages routinely contain table names, file paths, and connection details, so in production this line is a data leak, not a convenience. Gate it on the environment or drop the property entirely.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.
IExceptionHandler and RFC 7807 ProblemDetails support out of the box. For a new API I would build on those rather than hand-roll middleware; this implementation is still worth reading because it is exactly what those abstractions do under the hood.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.
Worth remembering#
- One middleware at the edge of the pipeline is the whole pattern: domain code throws typed exceptions, the edge translates them to HTTP.
- The exception type is the contract;
NotFoundException→ 404 belongs in exactly one place, not scattered across controllers. - Treat
exception.Messageas internal by default. Only messages thrown specifically for the client should reach the client. - On .NET 8+, reach for
IExceptionHandlerandProblemDetailsfirst; keep custom middleware for the cases they don’t cover.
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.
