Skip to main content
ANVISoftware Solutions
Lesson 13 of 23Intermediate16 min

Error Handling

By the end of this lesson

Handle exceptions centrally and return safe responses that leak no internals.

Something will throw. A database connection will drop, a third-party call will time out, a null will appear where your code assumed an object. The question is not whether it happens but what the caller receives when it does.

Two audiences need different things from the same failure. The caller needs a correct status code and enough information to decide what to do next. You need the exception type, the message, the stack trace and the request details, so you can find the cause. Those two needs pull in opposite directions, and the whole design of error handling comes from keeping them separate.

Central handling means one place at the top of the request pipeline that catches whatever escaped, logs the full detail, and writes a deliberately plain response. Without it, error formatting is scattered across every action and each one is slightly different.

Three categories of failure, which deserve different treatment:

A caller error
The request was wrong: a malformed body, a missing field, an id that does not exist. These are expected, they are not bugs, and they get a 400 or 404 written deliberately by your code. They do not belong in the exception path.
A rule the domain refuses
The request was well formed but the operation is not allowed right now — transferring an employee to a closed department, for example. A specific exception type here is reasonable, mapped to a 409 or 422 by your central handler.
An unexpected failure
Anything you did not anticipate. This is a 500. The caller gets a short, generic body; your logs get everything. Treat every one of these as a defect to investigate, not noise to suppress.
One central handler, registered at the top of the pipeline
C#
// EmployeeApiExceptionHandler.cs
public sealed class EmployeeApiExceptionHandler(
    ILogger<EmployeeApiExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context,
        Exception exception,
        CancellationToken cancellationToken)
    {
        // Full detail, server side only. The exception object goes in as the
        // first argument so the logger records type, message and stack trace.
        logger.LogError(
            exception,
            "Unhandled exception for {Method} {Path}",
            context.Request.Method,
            context.Request.Path);

        (int status, string title) = exception switch
        {
            DepartmentClosedException => (StatusCodes.Status409Conflict,
                "The department is closed to new employees."),
            EmployeeNotFoundException => (StatusCodes.Status404NotFound,
                "The employee does not exist."),
            _ => (StatusCodes.Status500InternalServerError,
                "An unexpected error occurred."),
        };

        ProblemDetails problem = new()
        {
            Status = status,
            Title = title,
            Instance = context.Request.Path,
        };
        problem.Extensions["traceId"] = context.TraceIdentifier;

        context.Response.StatusCode = status;
        await context.Response.WriteAsJsonAsync(problem, cancellationToken);

        return true;
    }
}
  • IExceptionHandler is the interface the built-in exception handling middleware calls. Returning true means you have written the response and the middleware should stop; returning false lets the next handler try.
  • The log call happens first and receives the exception itself. That is what captures the stack trace. Everything after it is about the caller.
  • The switch maps exception types to status codes in one place. Add a case when you introduce a domain exception; the default case is the catch-all 500.
  • Notice what the ProblemDetails does not contain: no exception message, no type name, no stack trace. The title is a fixed string you wrote, not something derived from the failure.
  • traceId goes into Extensions so it appears as an extra JSON property. It is the thread that connects this response to the log entry above.
Program.cs — wiring it up, and the environment difference
C#
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<EmployeeApiExceptionHandler>();
builder.Services.AddControllers();

WebApplication app = builder.Build();

if (app.Environment.IsDevelopment())
{
    // Full stack trace in the browser. Never reachable in production.
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler();
    app.UseHsts();
}

app.UseHttpsRedirection();
app.MapControllers();

app.Run();
  • AddProblemDetails registers the service that produces standard problem responses, including for status codes the framework generates itself.
  • AddExceptionHandler registers your handler. Register more than one if you want them tried in order; the first to return true wins.
  • UseExceptionHandler adds the middleware. It goes near the top of the pipeline because it can only catch exceptions thrown by components that come after it.
  • The developer exception page is genuinely useful and genuinely dangerous. It renders the exception, the stack trace, the source lines and the request headers. Keeping it inside the development branch is what stops that page ever reaching a real user.
What the caller sees, and what you see
JSON
// Response body for an unexpected failure (HTTP 500)
{
  "title": "An unexpected error occurred.",
  "status": 500,
  "instance": "/api/employees/148",
  "traceId": "00-4bd9f0c1a7e34f52-9c1f2ab7-00"
}

// The matching server log entry, structured
{
  "Timestamp": "2025-04-08T09:14:22.8410Z",
  "Level": "Error",
  "Message": "Unhandled exception for GET /api/employees/148",
  "Exception": "Npgsql.NpgsqlException: Connection refused ... at HrDbContext...",
  "TraceId": "00-4bd9f0c1a7e34f52-9c1f2ab7-00",
  "RequestPath": "/api/employees/148"
}
  • The response tells the caller three things: it failed, it was not their fault, and here is a reference. That is everything a well-behaved client needs.
  • The log holds the cause. The driver name, the connection failure and the code path are all present, and none of it crossed the network.
  • The same traceId appears in both. That is the whole mechanism for supporting a user: they quote the identifier, you find the entry.
  • Notice that the response gives away nothing about your stack, your database or your file layout. Internal detail in an error response is information about your system given to whoever asked for it.

A short checklist for the error path before a service goes live:

  • The developer exception page is inside a development-only branch
  • One central handler owns the response shape, and actions do not format their own 500s
  • Every unexpected failure is logged with the exception object, not only its message
  • Responses carry a trace identifier that also appears in the logs
  • Domain exceptions map to specific status codes, and the default is a generic 500
  • You have deliberately triggered a failure against a production-like build and read the response

Summary

  • Callers need a correct status code and a reference; you need the full exception. Keep those two outputs separate
  • One central handler owns the response shape so error formatting is not repeated across actions
  • ProblemDetails is the standard response body; populate it with strings you chose, never with exception text
  • Catching an exception and returning 200 hides failures from every client, retry policy and dashboard
  • The developer exception page belongs in a development-only branch, because it renders stack traces and request detail

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Write the middleware by hand

IExceptionHandler is called by middleware. Write that middleware yourself once, as a class with an InvokeAsync method that wraps the rest of the pipeline in a try/catch, so you can see where the catch sits.

Then throw from an action and confirm the response contains no stack trace.

Show solution

The shape is a try around await next(context) and a catch that logs, sets the status code and writes the body. There is no magic in the built-in version; it is this with more configuration.

The reason this is worth doing once is placement. Because the catch surrounds the call to the next component, it can only see exceptions thrown downstream of itself. Anything thrown by middleware registered earlier escapes it, which explains why the exception handler is registered first.

Use the built-in UseExceptionHandler in real work. It deals with the details a hand-written version misses, such as clearing a partially written response and re-executing the pipeline correctly.

C#
public sealed class ExceptionLoggingMiddleware(
    RequestDelegate next,
    ILogger<ExceptionLoggingMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unhandled exception for {Path}", context.Request.Path);

            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await context.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "An unexpected error occurred.",
            });
        }
    }
}

// Program.cs — first, so it surrounds everything after it
app.UseMiddleware<ExceptionLoggingMiddleware>();

Think about it

Which status code, and why

A request asks to move employee 148 into department 7. Department 7 was closed last month.

Is this a 400, a 404, a 409, or a 500? Argue for your choice, then consider what the caller can do with each.

Show solution

409 Conflict is the strongest answer. The request is well formed and both records exist, so nothing about the input is invalid; the current state of the resource is what refuses the operation. 409 says exactly that.

400 is defensible if you treat the closed department as an invalid value for that field, and returning it as a field-level validation error is reasonable. What matters is that you choose consistently across the API rather than deciding per endpoint.

404 is wrong here because the department exists, and a 404 would send the caller looking for a missing record. 500 is wrong because nothing failed — your code made a correct decision.

The test for any of these is what the caller does next. A 409 or 400 tells them to change something and retry. A 500 tells them to retry unchanged, which would fail forever.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why is the exception handling middleware registered at the top of the pipeline?
A production API returns 500 with a body containing the full exception message. What is the main problem?

Saved in this browser only.