Skip to main content

Error Handling and Exception Management in the API

Error Handling and Exception Management in the API
Table of Contents

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-in IExceptionHandler/ProblemDetails
This post is part of the Clean Architecture series built around the Contact Management Application. Start with the series introduction for the project structure and the full list of articles.

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.

Watch out: 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.

Since .NET 8, ASP.NET Core ships 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.Message as internal by default. Only messages thrown specifically for the client should reach the client.
  • On .NET 8+, reach for IExceptionHandler and ProblemDetails first; 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.

Related

Using Dapper for Data Access and Repository Pattern

··9 mins
Introduction # On the Contact Management Application I reached for Dapper instead of Entity Framework Core, and the reason was boring: I wanted to see the SQL. EF Core is fine until a generated query does something surprising under load, and then you are reverse-engineering LINQ translations at 2 a.m.

Best Practices for Creating and Using DTOs in the API

··6 mins
Serializing domain entities straight to the client looks like a shortcut until the first schema change. Rename one property on the entity and every consumer of the API breaks with it, and you find out from their bug reports. DTOs are the cheap insurance against that: the entity can change shape freely because the contract the client sees is a separate class you control. This post covers the DTO conventions I use in the Contact Management Application and why each one exists.

Validating Inputs with FluentValidation

··6 mins
Introduction # Validation is the one part of a request I refuse to trust anywhere but the edge. Domain objects assume they are already valid; rejecting a malformed email or a missing name belongs at the API boundary, before that data reaches a service or a repository.