Every uncaught exception in fullstackhero turns into a ProblemDetails response (RFC 9457). One handler, one shape, every endpoint. React clients (clients/admin + clients/dashboard) parse the shape directly so error UI is consistent across the kit.
The exception hierarchy
Four exception types ship in the Core building block:
| Type | HTTP Status | Use when |
|---|---|---|
CustomException(message, HttpStatusCode) | Caller-specified | Generic domain failure; pick the status |
NotFoundException(message) | 404 | Resource missing |
ForbiddenException(message) | 403 | Authenticated but not authorised |
UnauthorizedException(message) | 401 | Not authenticated |
Plus ValidationException from FluentValidation (returned as 400 with the field errors as a ProblemDetails).
The response shape
HTTP/1.1 409 ConflictContent-Type: application/problem+json
{ "type": "https://fullstackhero.net/errors/conflict", "title": "Cannot resolve a closed ticket.", "status": 409, "detail": "Cannot resolve a closed ticket.", "instance": "/api/v1/tickets/3d2c.../resolve", "traceId": "00-4a7d8e1f2c..."}Field-level errors from validation:
HTTP/1.1 400 Bad RequestContent-Type: application/problem+json
{ "type": "https://fullstackhero.net/errors/validation", "title": "One or more validation errors occurred.", "status": 400, "errors": { "Email": ["Email is not a valid address.", "Email is already in use."], "Password": ["Password must be at least 12 characters."] }, "instance": "/api/v1/identity/users/register", "traceId": "00-4a7d8e1f2c..."}The traceId matches the OpenTelemetry trace id, so client-side error logs cross-reference cleanly with server traces.
How the handler decides the response
GlobalExceptionHandler is registered via services.AddExceptionHandler<GlobalExceptionHandler>(). It implements IExceptionHandler.TryHandleAsync(...). On exception:
- Type-switch on the exception —
NotFoundException→ 404,ForbiddenException→ 403,UnauthorizedException→ 401,ValidationException→ 400 (with field errors),CustomException→ caller-specified, fallthrough → 500. - Build a
ProblemDetailswithtype,title,status,detail,instance,traceId. - Optionally enrich with
errors(validation),stackTrace(development only), etc. - Write the response as
application/problem+jsonand returntrue.
Throwing from the domain
The pattern is “the aggregate enforces the invariant; the handler catches the exception.”
// Aggregatepublic void Resolve(string? resolutionNote){ if (Status == TicketStatus.Closed) throw new CustomException("Cannot resolve a closed ticket.", HttpStatusCode.Conflict); Status = TicketStatus.Resolved; // ...}
// Handler — no try/catchpublic async ValueTask<Unit> Handle(ResolveTicketCommand cmd, CancellationToken ct){ var ticket = await _db.Tickets.FindAsync([cmd.TicketId], ct).ConfigureAwait(false) ?? throw new NotFoundException(nameof(Ticket), cmd.TicketId);
ticket.Resolve(cmd.ResolutionNote); await _db.SaveChangesAsync(ct).ConfigureAwait(false); return Unit.Value;}No try/catch in the handler. The global handler maps CustomException with Conflict to a 409 ProblemDetails; the NotFoundException becomes a 404. The endpoint’s caller sees a structured response and the kit’s React client renders the right toast / inline error.
Validation errors
FluentValidation runs via the ValidationBehavior Mediator pipeline before the handler. A failing validator throws ValidationException; the global handler converts it into a 400 with per-field errors:
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>{ public CreateProductCommandValidator() { RuleFor(x => x.Sku).NotEmpty().MaximumLength(64); RuleFor(x => x.Name).NotEmpty().MaximumLength(255); RuleFor(x => x.PriceAmount).GreaterThanOrEqualTo(0); // ... }}The response carries an errors map keyed by field name with arrays of message strings. React clients render them inline next to the field.
Production vs development
In production, the response includes type, title, status, detail, instance, traceId — no stack traces, no inner exceptions. Clients see what they need to display; everything else stays on the server side (logged + traced).
In development, the handler can optionally include the stack trace via IncludeDetailsInResponse = true. Don’t ship this flag set in production — never expose stack traces or internal type names to external clients.
Anti-patterns to avoid
catch { return BadRequest(); }in handlers. The global handler already does this; wrapping it just hides intent. Throw the right exception type from the domain.throw new Exception("...")from a handler. Use one of the four typed exceptions. A bareExceptionfalls through to “500 Internal Server Error” with no detail — useless for the client and harder to debug.- Returning
IActionResult/Results.X(...)from a handler. Handlers return DTOs. The endpoint adapts toIResultvia the endpoint builder. Keeping handlers DTO-only means the same handler is unit-testable without HTTP context.
Logging vs responding
The handler writes the HTTP response and logs the exception via ILogger<GlobalExceptionHandler>. The log message includes the same traceId that lands in the response. If a customer reports “I got an error”, you ask for the traceId, look it up in your log aggregator, and you have the full picture.
The OpenTelemetry instrumentation tags the request span with the exception type and message, so you can graph error rates per exception type and alert on spikes.
Related
- Core building block —
CustomExceptionand friends. - Web building block —
GlobalExceptionHandler, validation behavior. - Observability —
traceIdlinking responses to server traces. - Vertical Slice Architecture — the handler conventions that work with this error model.