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:

TypeHTTP StatusUse when
CustomException(message, HttpStatusCode)Caller-specifiedGeneric 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 as a ProblemDetails).

The response shape

HTTP/1.1 409 Conflict
Content-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 Request
Content-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:

  1. Type-switch on the exceptionNotFoundException → 404, ForbiddenException → 403, UnauthorizedException → 401, ValidationException → 400 (with field errors), CustomException → caller-specified, fallthrough → 500.
  2. Build a ProblemDetails with type, title, status, detail, instance, traceId.
  3. Optionally enrich with errors (validation), stackTrace (development only), etc.
  4. Write the response as application/problem+json and return true.

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.", 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(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 bare Exception falls 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 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>. 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.