Robust APIs fail predictably. In .NET 8 you can standardize failures with Problem Details (RFC 7807, obsoleted by RFC 9457) and centralize error handling with the exception handler middleware and IExceptionHandler.

  • Returns RFC-compliant error payloads.
  • Surfaces FluentValidation failures as 400 Bad Request.
  • Distinguishes unexpected failures as 500 Internal Server Error.
  • Enriches responses with requestId, traceId, timestamp, path, method, user id, and field-level errors.
  • Keeps controllers/endpoints clean—no per-action try/catch.
Why not handle exceptions in every endpoint? There's little value duplicating try/catch everywhere unless you must log very endpoint-specific request details. Prefer a global handler so behavior stays consistent and easy to test. See Microsoft's guidance: Handle errors in ASP.NET Core and IExceptionHandler.

Problem Details (RFC 7807 → RFC 9457)

Problem Details is a machine-readable error contract for HTTP APIs. The object contains type, title, status, detail, and instance. You can also add extensions—custom fields that your clients can rely on (e.g., traceId, errors). RFC 9457 is the current spec and obsoletes RFC 7807. RFC 9457 · RFC 7807

What Microsoft provides in .NET 8

  • AddProblemDetails() registers Problem Details so built-in middleware can emit application/problem+json responses.
  • IExceptionHandler + AddExceptionHandler<T>(): register your global exception handler implementation.
  • UseExceptionHandler(): activate the middleware early in the pipeline so unhandled exceptions are converted to Problem Details.

The shape of validation responses

For FluentValidation failures, return 400 with field-level messages. Reference 400 semantics per RFC 7231 §6.5.1 in type and add diagnostics in extensions:

{
  "type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
  "title": "Validation error",
  "status": 400,
  "detail": "One or more validation errors occurred.",
  "instance": "/api/notes",
  "traceId": "0HNEHBMOKNNJ9:00000004",
  "requestId": "0HNEHBMOKNNJ9:00000004",
  "timestamp": "2025-08-02T00:44:17.4974375Z",
  "method": "POST",
  "path": "/api/notes",
  "userId": "5cc67c3f-9890-4f69-ab38-00a87941caf3",
  "errors": {
    "CreateNoteDto.Language": [
      "Language code must be exactly 2 characters.",
      "Language code must be a valid ISO 639-1 code (e.g., 'en', 'es', 'fr')."
    ]
  },
  "errorCount": 1,
  "category": "ValidationFailure"
}

Step-by-step implementation

1) Add FluentValidation

dotnet add package FluentValidation.AspNetCore

Register it and suppress the automatic model-state 400 so the global handler can control the response shape:

// Program.cs
builder.Services
    .AddControllers()
    .AddFluentValidation(cfg => cfg.RegisterValidatorsFromAssemblyContaining<Program>());

builder.Services.Configure<ApiBehaviorOptions>(o => o.SuppressModelStateInvalidFilter = true);

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

2) Write a validator and throw ValidationException in your endpoint

// DTO
public sealed record CreateNoteDto(string Title, string Language, string Content);

// Validator
public sealed class CreateNoteDtoValidator : AbstractValidator<CreateNoteDto>
{
    public CreateNoteDtoValidator()
    {
        RuleFor(x => x.Title).NotEmpty().MaximumLength(120);
        RuleFor(x => x.Language)
            .Length(2).WithMessage("Language code must be exactly 2 characters.")
            .Must(code => new[] { "en","es","fr","uk","ru" }.Contains(code))
            .WithMessage("Language code must be a valid ISO 639-1 code (e.g., 'en', 'es', 'fr').");
        RuleFor(x => x.Content).NotEmpty();
    }
}

3) Implement a global exception handler (IExceptionHandler)

public sealed class GlobalExceptionHandler : IExceptionHandler
{
    // ... see repository for full code
    public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken ct)
    {
        if (exception is ValidationException vex) { /* build 400 problem */ }
        // else: build 500 problem
    }
}

4) Wire it up in Program.cs

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseHsts();
}

app.UseExceptionHandler(); // <-- activate handler early
app.MapControllers();
app.Run();

Distinguish internal server errors

Unexpected faults should map to 500 with a generic title and sanitized detail. Link to RFC 7231 §6.6.1 in type:

RFC 7231 §6.6.1 — 500 Internal Server Error

Best practices

  1. Log server errors in the handler with ILogger. Include method, path, and trace id; avoid PII.
  2. Use Problem Details everywhere: AddProblemDetails() wires writers used by exception and status code middleware.
  3. Keep RFC semantics: 400 → RFC 7231 §6.5.1; 500 → §6.6.1.
  4. Correlation IDs: include HttpContext.TraceIdentifier as requestId and Activity.Current?.Id as traceId.
  5. Middleware order matters: UseExceptionHandler() should be early so downstream exceptions are caught.

References