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 (FSH.Framework.Core.Exceptions). NotFoundException, ForbiddenException, and UnauthorizedException all derive from CustomException, which carries an HttpStatusCode and an optional ErrorMessages list:
| Type | HTTP Status | Use when |
|---|---|---|
CustomException(message, errors?, HttpStatusCode) | Caller-specified (500 default) | 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).
How the handler decides the response
GlobalExceptionHandler (in the Web block) is registered via services.AddExceptionHandler<GlobalExceptionHandler>() and implements IExceptionHandler.TryHandleAsync(...). The type-switch:
| Exception | Status | Notes |
|---|---|---|
FluentValidation.ValidationException | 400 | Per-field errors map in the response |
CustomException (incl. subclasses) | e.StatusCode | title = exception type name, detail = message, errors = ErrorMessages when present |
UnauthorizedAccessException | 401 | BCL fallback |
KeyNotFoundException | 404 | BCL fallback |
BadHttpRequestException | Its own StatusCode (usually 400) | Malformed request — missing required header/param, unreadable or oversized body |
| Anything else | 500 | Generic “An unexpected error occurred” — no internals leaked |
The BadHttpRequestException mapping matters more than it looks: a request to an anonymous endpoint that omits a required parameter (like the tenant header binding) throws it, and before this mapping existed the client saw a misleading 500 instead of a 400 (issue #1245).
Every response also carries traceId (the OpenTelemetry trace id, falling back to HttpContext.TraceIdentifier) and correlationId (the X-Correlation-ID request header when present) as ProblemDetails extensions.
The response shape
HTTP/1.1 409 ConflictContent-Type: application/problem+json
{ "title": "CustomException", "status": 409, "detail": "Cannot resolve a closed ticket.", "instance": "/api/v1/tickets/3d2c.../resolve", "traceId": "4a7d8e1f2c...", "correlationId": "..."}Field-level errors from validation:
HTTP/1.1 400 Bad RequestContent-Type: application/problem+json
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "Validation error", "status": 400, "detail": "One or more validation errors occurred.", "instance": "/api/v1/identity/users/register", "errors": { "Email": ["Email is not a valid address.", "Email is already in use."], "Password": ["Password must be at least 12 characters."] }, "traceId": "4a7d8e1f2c...", "correlationId": "..."}The traceId matches the OpenTelemetry trace id, so client-side error logs cross-reference cleanly with server traces.
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.", null, 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($"Ticket {cmd.TicketId} not found.");
ticket.Resolve(cmd.ResolutionNote); await _db.SaveChangesAsync(ct).ConfigureAwait(false); return Unit.Value;}No try/catch in the handler. The global handler maps the Conflict CustomException 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 makes the behavior throw 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.
What never reaches the client
Stack traces and inner exception details are never included in the response — there is no “development mode” flag that adds them. The handler pushes the stack trace, title, detail, and status onto the Serilog LogContext instead, so the full picture lands in your logs while the client only sees the structured ProblemDetails. Unexpected exceptions (the 500 fallthrough) get a fixed generic message — internal type names and messages stay server-side.
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 typed exceptions. A bareExceptionfalls through to “500 Internal Server Error” with a generic message — 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> (structured message template — path, status code, title), with the exception detail and stack trace attached through LogContext properties. 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 kit’s MediatorTracingBehavior marks the command/query span as errored and tags it with exception.type and exception.message before re-throwing, 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.