Backend Development with .NET
Session 05
Error Handling &
Clean Error Responses
Eng. Seif Mansour  ·  Andalusia Academy
Week 3  ·  2 hours
Session Goals

By the end of this session, you will be able to:

  • Implement a global exception handling middleware in .NET
  • Return structured error responses following RFC 7807 (ProblemDetails)
  • Distinguish between validation errors, business errors, and unexpected errors
  • Throw domain exceptions from the service layer and handle them in one place
  • Never expose internal stack traces to API consumers
Session Agenda
Time Segment Type Duration
0:00Why error handling mattersDiscussion10 min
0:10The ProblemDetails standard (RFC 7807)Theory20 min
0:30Global exception middlewareDemo25 min
0:55Custom exception typesTheory + Demo20 min
1:15Validation errors shapeDemo15 min
1:30Lab — add error handling to the Task APILab25 min
1:55Wrap-upDiscussion5 min
Why Error Handling Matters
An API that crashes silently, spews stack traces, or returns 200 with an error in the body is not an API — it is a liability. Every caller deserves a clear, consistent, safe signal when something goes wrong.
In this section
  • What a bad error response looks like
  • What a good error response looks like
  • Three categories of errors to distinguish
Which API Would You Rather Consume?

The wrong way

500 Internal Server Error
"An error has occurred."
500 Internal Server Error
"NullReferenceException at TaskService.cs line 42: Object reference not set to an instance of an object."
Problems
Too generic to act on, or leaks your internals to attackers.

The right way

404 Not Found
{ "type": "https://tools.ietf.org/html/ rfc7231#section-6.5.4", "title": "Resource not found", "status": 404, "detail": "Task with ID 99 was not found.", "traceId": "0HN4G2R3DLVCT" }
Benefits
Machine-readable type, human-readable detail, no internals exposed, and a traceId to correlate with server logs.
Three Categories of Errors

Not all errors are equal. Distinguishing them lets you return the right status code and craft a response the client can act on.

400
Validation Error
The request data is invalid — missing required fields, wrong format, failed constraints. The client must fix the request before retrying.
422
Business Error
The data is valid but violates a domain rule — closing a task that is already closed, booking a sold-out slot. User action or a different request is needed.
500
Unexpected Error
Something failed on the server — a database connection dropped, a null reference was hit. Log it server-side. Never return the reason to the client.
Rule of thumb
4xx means the client did something wrong and must act. 5xx means something broke on the server and the client can only wait or retry.
The ProblemDetails Standard
RFC 7807 defines a standard JSON shape for HTTP error responses. ASP.NET Core has built-in support — you do not need to invent your own format, and frontend developers will already know how to parse it.
In this section
  • The six ProblemDetails fields
  • Extending for validation errors
  • Content-Type: application/problem+json
ProblemDetails — RFC 7807
  • type — A URI identifying the error category. Links to RFC documentation or your own error catalogue.
  • title — Short, human-readable summary. Does not change between occurrences of the same error type.
  • status — The HTTP status code. Mirrors the response line so clients do not have to parse it again.
  • detail — Specific explanation for this occurrence — safe to show to end users.
  • instance — URI of this specific request (optional). Useful for support tickets.
  • traceId — Correlation ID for finding the matching server log entry.
Response body
{
  "type": "https://tools.ietf.org/html/
           rfc7231#section-6.5.4",
  "title": "Resource not found",
  "status": 404,
  "detail": "Task with ID 99 was not found.",
  "instance": "/api/tasks/99",
  "traceId": "0HN4G2R3DLVCT"
}
Content-Type
Error responses must use application/problem+json, not application/json.
Extending ProblemDetails for Validation Errors

Validation failures return an errors dictionary — each key is the field name, each value is an array of error messages for that field.

Response body — 400 Bad Request
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Title":   ["The Title field is required."],
    "DueDate": ["DueDate must be in the future."]
  }
}
ASP.NET Core
The built-in ValidationProblemDetails class produces exactly this shape. The [ApiController] attribute triggers it automatically for model binding failures.
Global Exception Middleware
Rather than wrapping every controller action in a try-catch, catch all exceptions in one place — a single middleware that sits at the front of the pipeline and converts them to ProblemDetails responses.
In this section
  • Middleware class structure
  • Catching specific exception types
  • The catch-all for unexpected errors
  • Registering in Program.cs
GlobalExceptionMiddleware — Part 1 of 2
Middleware/GlobalExceptionMiddleware.cs
public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionMiddleware> _logger;

    public GlobalExceptionMiddleware(RequestDelegate next,
        ILogger<GlobalExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (NotFoundException ex)
        {
            await WriteProblemDetails(context, 404, "Resource not found", ex.Message);
        }
        catch (ValidationException ex)
        {
            await WriteProblemDetails(context, 422, "Validation failed", ex.Message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
            await WriteProblemDetails(context, 500, "An unexpected error occurred.",
                "Please contact support with your traceId.");
        }
    }
}

The catch-all logs the full exception server-side but returns only a generic message to the client.

GlobalExceptionMiddleware — Part 2 of 2
Middleware/GlobalExceptionMiddleware.cs
private static async Task WriteProblemDetails(
    HttpContext ctx, int status, string title, string detail)
{
    ctx.Response.StatusCode = status;
    ctx.Response.ContentType = "application/problem+json";
    var problem = new ProblemDetails
    {
        Status = status,
        Title  = title,
        Detail = detail
    };
    await ctx.Response.WriteAsJsonAsync(problem);
}
Why WriteAsJsonAsync?
Once the response status code is set manually on the HttpContext, you cannot use controller return types. Writing directly to the response stream is the correct approach at the middleware level.
Registering the Middleware

Middleware runs in the order it is registered. The exception handler must come first — before routing, auth, and controllers.

  • If it is registered after routing, a routing exception would escape it unhandled.
  • If it is registered after auth, an auth exception would also escape.
  • At the very top of the pipeline, it catches everything that happens downstream.
Order matters
This is the single most common mistake when wiring up global error handling. Always register exception middleware first.
Program.cs
var app = builder.Build();

app.UseMiddleware<GlobalExceptionMiddleware>();

app.UseRouting();
app.UseAuthorization();
app.MapControllers();

app.Run();
Custom Exception Types
For the middleware to map exceptions to the right status codes, it needs to tell them apart. A small exception hierarchy in your domain layer is all it takes — no strings, no booleans, no nullable returns.
In this section
  • The exception hierarchy
  • Throwing from the service layer
  • Why controllers stay clean
Domain Exception Hierarchy
Exceptions/DomainExceptions.cs
public class NotFoundException : Exception
{
    public NotFoundException(string message)
        : base(message) { }
}

public class ConflictException : Exception
{
    public ConflictException(string message)
        : base(message) { }
}

public class ForbiddenException : Exception
{
    public ForbiddenException(string message)
        : base(message) { }
}
Services/TaskService.cs
public TaskItem GetById(int id)
{
    var task = _tasks.FirstOrDefault(t => t.Id == id);
    if (task is null)
        throw new NotFoundException(
            $"Task with ID {id} was not found.");
    return task;
}
Controllers/TasksController.cs
[HttpGet("{id}")]
public IActionResult GetById(int id)
    => Ok(_taskService.GetById(id));
Clean controller
No try-catch. If the service throws, the middleware handles it.
Design Principle
"Throw from the service layer.
Handle in middleware.
The controller should never see a try-catch block."
Controllers are thin translators between HTTP and domain logic. If they are managing exception flow, they are doing too much.
Validation Errors
ASP.NET Core's [ApiController] handles model validation automatically, but the default response shape may not match what you want. You can customise it in Program.cs with a single configuration call.
In this section
  • How [ApiController] handles validation
  • Customising the validation response factory
  • Producing a consistent ValidationProblemDetails shape
Custom Validation Response Factory
Program.cs
builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var errors = context.ModelState
                .Where(e => e.Value?.Errors.Count > 0)
                .ToDictionary(
                    k => k.Key,
                    v => v.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
                );

            var problem = new ValidationProblemDetails(errors)
            {
                Status = 400,
                Title  = "One or more validation errors occurred."
            };
            return new BadRequestObjectResult(problem);
        };
    });

The factory runs before your controller action is invoked — invalid models never reach your code.

Lab — Add Error Handling to the Task API

Starting from the Task API built in Session 03, add structured error handling:

1 Create NotFoundException and ValidationException classes in an Exceptions/ folder
2 Implement GlobalExceptionMiddleware with catch blocks for both exception types plus a catch-all
3 Register it in Program.cs before all other middleware
4 Update TaskService.GetById to throw NotFoundException instead of returning null
5 In Postman: GET /api/tasks/999 should return a clean 404 ProblemDetails JSON body
6 Deliberately throw an unhandled Exception and verify the response contains no stack trace
Summary
  • Never expose stack traces to clients — log them server-side only using ILogger
  • Three error categories — validation (400), business rule violations (422), and unexpected failures (500) require different status codes and handling
  • ProblemDetails (RFC 7807) is the standard JSON shape for error responses — ASP.NET Core supports it out of the box
  • One exception handler to rule them allGlobalExceptionMiddleware keeps every controller free of try-catch blocks
  • Throw from the service, handle in middleware — domain exceptions are a first-class communication mechanism, not a last resort
  • traceId in the response lets clients report errors that you can correlate with server logs without leaking implementation details
What's Next

Session 06 — API Versioning

  • Now that your API returns consistent, structured responses, Session 06 covers how to evolve that API without breaking existing clients
  • You will implement URL-based and header-based versioning using the Asp.Versioning NuGet package
  • We will look at how versioning and error handling interact — what happens when a client calls a deprecated or removed version
  • After Session 06, your Task API will support multiple coexisting versions that can evolve independently
Before next session
Complete today's assignment. Make sure every error path in your Task API returns a ProblemDetails body with the correct status code before Session 06.
Assignment

Extend your Task API with global error handling and structured ProblemDetails responses.

What to build:

  • Create a NotFoundException class and wire it to a 404 response in GlobalExceptionMiddleware
  • Create a ConflictException class and throw it from TaskService.Create if a task with the same title already exists — wire it to 409
  • Register the middleware in Program.cs as the first item in the pipeline
  • Remove all try-catch blocks from your controller — let the middleware handle them

Acceptance criteria:

  • GET /api/tasks/999 returns 404 with a valid ProblemDetails JSON body
  • POST /api/tasks with a duplicate title returns 409 with a ProblemDetails body
  • An unhandled Exception returns 500 with a generic message — no stack trace in the response
  • All error responses use Content-Type: application/problem+json
Bonus
Add a DueDateInPastException that fires in TaskService.Create when the due date is in the past. Return 422 Unprocessable Entity with a descriptive detail field.
Questions?
Session 05 — Error Handling & Clean Error Responses