By the end of this session, you will be able to:
| Time | Segment | Type | Duration |
|---|---|---|---|
| 0:00 | Why error handling matters | Discussion | 10 min |
| 0:10 | The ProblemDetails standard (RFC 7807) | Theory | 20 min |
| 0:30 | Global exception middleware | Demo | 25 min |
| 0:55 | Custom exception types | Theory + Demo | 20 min |
| 1:15 | Validation errors shape | Demo | 15 min |
| 1:30 | Lab — add error handling to the Task API | Lab | 25 min |
| 1:55 | Wrap-up | Discussion | 5 min |
The wrong way
The right way
Not all errors are equal. Distinguishing them lets you return the right status code and craft a response the client can act on.
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.{
"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"
}
application/problem+json, not application/json.
Validation failures return an errors dictionary — each key is the field name, each value is an array of error messages for that field.
{
"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."]
}
}
ValidationProblemDetails class produces exactly this shape. The [ApiController] attribute triggers it automatically for model binding failures.
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.
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);
}
HttpContext, you cannot use controller return types. Writing directly to the response stream is the correct approach at the middleware level.
Middleware runs in the order it is registered. The exception handler must come first — before routing, auth, and controllers.
var app = builder.Build();
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();
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) { }
}
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;
}
[HttpGet("{id}")]
public IActionResult GetById(int id)
=> Ok(_taskService.GetById(id));
[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.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.
Starting from the Task API built in Session 03, add structured error handling:
NotFoundException and ValidationException classes in an Exceptions/ folder
GlobalExceptionMiddleware with catch blocks for both exception types plus a catch-all
Program.cs before all other middleware
TaskService.GetById to throw NotFoundException instead of returning null
GET /api/tasks/999 should return a clean 404 ProblemDetails JSON body
Exception and verify the response contains no stack trace
ILoggerGlobalExceptionMiddleware keeps every controller free of try-catch blocksSession 06 — API Versioning
Asp.Versioning NuGet packageExtend your Task API with global error handling and structured ProblemDetails responses.
What to build:
NotFoundException class and wire it to a 404 response in GlobalExceptionMiddlewareConflictException class and throw it from TaskService.Create if a task with the same title already exists — wire it to 409Program.cs as the first item in the pipelinetry-catch blocks from your controller — let the middleware handle themAcceptance criteria:
GET /api/tasks/999 returns 404 with a valid ProblemDetails JSON bodyPOST /api/tasks with a duplicate title returns 409 with a ProblemDetails bodyException returns 500 with a generic message — no stack trace in the responseContent-Type: application/problem+jsonDueDateInPastException that fires in TaskService.Create when the due date is in the past. Return 422 Unprocessable Entity with a descriptive detail field.