Skip to content
fullstackhero

Concept

Error handling

Global exception handler converts CustomException / NotFoundException / ForbiddenException / UnauthorizedException into RFC 9457 ProblemDetails responses.

views 0 Last updated

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:

TypeHTTP StatusUse when
CustomException(message, errors?, HttpStatusCode)Caller-specified (500 default)Generic domain failure; pick the status
NotFoundException(message)404Resource missing
ForbiddenException(message)403Authenticated but not authorised
UnauthorizedException(message)401Not 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:

ExceptionStatusNotes
FluentValidation.ValidationException400Per-field errors map in the response
CustomException (incl. subclasses)e.StatusCodetitle = exception type name, detail = message, errors = ErrorMessages when present
UnauthorizedAccessException401BCL fallback
KeyNotFoundException404BCL fallback
BadHttpRequestExceptionIts own StatusCode (usually 400)Malformed request — missing required header/param, unreadable or oversized body
Anything else500Generic “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 Conflict
Content-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 Request
Content-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.”

// Aggregate
public 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/catch
public 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 bare Exception falls 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 to IResult via 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.