Skip to content
Slicekit

Backend guides

Error handling

The Result and AppError taxonomy, how failures map to ProblemDetails responses, and how to add an error type.

View .md
On this page

Two values, never an exception

Slicekit handlers do not throw to signal a business failure. They return a Result<T>: either a value-bearing success or an AppError. Endpoints translate that error into an RFC 7807 ProblemDetails response with a single call to error.ToProblem(). The mapping is centralised: handlers never construct HTTP status codes, and endpoints never assemble ad-hoc problem responses.

The whole contract is two small types in api/src/Slicekit.Core/Common/:

  • Result.cs: Result<T> and the non-generic Result.
  • AppError.cs: the abstract AppError record and its concrete subtypes.

The Result<T> type

Result<T> is a readonly struct (no allocation) that holds either a T or an AppError. You rarely construct it explicitly because of two implicit conversions:

public async Task<Result<UserDto>> HandleAsync(GetUserQuery query, CancellationToken ct)
{
    var user = await db.Users.FindAsync([query.UserId], ct);
    if (user is null) return new NotFoundError("User");   // AppError -> Result<T>
    return new UserDto(user.Id, user.Email);              // T        -> Result<T>
}

For commands with no value-bearing success, return the non-generic Result (Result.Ok() or an error). For composing several fallible steps without exception flow, Map, MapAsync, Bind, and BindAsync chain over the success branch and short-circuit on the first error:

return await GetProject(id)
    .BindAsync(p => Rename(p, request.Name))
    .MapAsync(p => new Response(p.Id, p.Name));

IsSuccess / IsFailure gate access: reading .Value on a failure (or .Error on a success) throws, so always branch on IsSuccess first.

The AppError taxonomy

Every error is a subtype of AppError(string Message, string? ErrorCode). The full set lives in api/src/Slicekit.Core/Common/AppError.cs and maps to status codes in api/src/Slicekit.Api/Errors/AppErrorExtensions.cs:

AppError subtypeStatusErrorCodeWhen to use
NotFoundError(resource)404NotFoundResource does not exist, or the caller has no view of it.
ConflictError(resource, detail)409Conflict (overridable)Conflicting state: duplicate, optimistic concurrency.
ForbiddenError(permission)403ForbiddenAuthenticated caller lacks a permission.
UnverifiedEmailError()403EmailNotVerifiedAction requires a verified email address.
UnauthorizedError()401UnauthorizedCaller is not authenticated.
ValidationError(errors)400ValidationErrorBusiness-rule validation inside the handler (see below).
LockedError(unlockedAt)423AccountLockedIdentity lockout; carries the unlock timestamp.
TotpRequiredError(pendingToken)403TotpRequiredFirst factor passed, 2FA still required.
TotpInvalidError()400TotpCodeInvalidSubmitted TOTP code is wrong or expired.
TotpSetupRequiredError()403TotpSetupRequired2FA enrolment is required before continuing.
PasskeyVerificationFailedError400PasskeyVerificationFailedWebAuthn assertion did not verify.
PasskeyCeremonyExpiredError()410PasskeyCeremonyExpiredPasskey challenge expired; restart the ceremony.
InvalidTokenError()422InvalidTokenA link or token is invalid or has expired.
InternalError(detail)500InternalErrorFallback. Prefer letting unexpected failures bubble.

Most slices only ever need NotFoundError, ConflictError, and ValidationError. Reach for the auth-specific subtypes only inside the authentication flows. See Authentication for where those are raised.

Mapping to ProblemDetails

AppErrorExtensions.ToProblem is a single switch over the error type. Each arm builds a ProblemHttpResult with a status, a title, and an extensions dictionary. Every arm emits errorCode; some add extra fields:

public static ProblemHttpResult ToProblem(this AppError error) => error switch
{
    NotFoundError e => TypedResults.Problem(e.Message, statusCode: 404, title: "Not Found", extensions: CodeExt(e)),
    LockedError e => TypedResults.Problem(e.Message, statusCode: 423, title: "Locked",
        extensions: new() { ["errorCode"] = e.ErrorCode, ["unlockedAt"] = e.UnlockedAt }),
    ValidationError e => TypedResults.Problem(e.Message, statusCode: 400, title: "Validation Error",
        extensions: new() { ["errorCode"] = e.ErrorCode, ["errors"] = e.Errors }),
    // ...
    _ => TypedResults.Problem("An unexpected error occurred.", statusCode: 500, title: "Internal Server Error")
};

The _ fallthrough is the trap: an AppError subtype with no switch arm compiles fine but returns an opaque 500 with no errorCode. Adding a subtype always means adding an arm.

The endpoint contract

Endpoints stay thin (see Adding a vertical slice). They invoke the handler over Wolverine and map the result. For the common shapes there are extension shortcuts on Result:

private static async Task<Results<Ok<UserDto>, ProblemHttpResult>> HandleAsync(
    Guid id, IMessageBus bus, CancellationToken ct)
{
    var result = await bus.InvokeAsync<Result<UserDto>>(new GetUserQuery(id), ct);
    return result.ToOkOrProblem();
}
return result.ToCreatedOrProblem($"/users/{result.Value.Id}");  // Results<Created<T>, ProblemHttpResult>
return result.ToNoContentOrProblem();                           // on the non-generic Result

Declare every status code the endpoint can return so OpenAPI stays the source of truth for clients:

.Produces<UserDto>()
.ProducesProblem(400)   // validation
.ProducesProblem(403)   // permission filter
.ProducesProblem(404);  // not-found from the handler

Two validation surfaces, kept apart

Slicekit validates in two distinct places, and mixing them is the most common mistake:

  1. Request shape, at the filter layer. ValidationEndpointFilter<TRequest> runs FluentValidation before the handler and returns a 400 with the standard errors dictionary. This is where RuleFor(x => x.Email).EmailAddress() lives.
  2. Domain rules, inside the handler. Return new ValidationError(errors) as a Result<T> failure when the rule needs domain state: “cannot delete the last admin”, “email is taken”.

Do not express domain rules in FluentValidation, and do not re-check request shape in the handler.

Unhandled exceptions

GlobalExceptionHandler is the net under everything that escapes a handler:

  • UnauthorizedAccessException becomes 401.
  • FluentValidation.ValidationException becomes 400 with a validation problem.
  • Anything else becomes 500, with the stack trace logged and the trace id surfaced on the response.

Do not catch and rethrow inside a handler to “improve” a message. Return an AppError if you recognise the case, otherwise let the exception bubble.

How the frontend surfaces it

The API client reads the ProblemDetails body into an AppError class (frontend/src/shared/api/errors.ts). Two behaviours matter:

  • Auto-translation. The constructor takes errorCode, snake-cases it, and looks up errors.<snake_code> in the active locale. If a translation exists it overwrites detail, so a plain toast.error(err.detail) renders the localised message for free.
  • Form binding. applyToForm(setError) walks the errors dictionary and pushes per-field messages into react-hook-form, using validationParams as interpolation values.

That is why a new error code needs i18n keys on both sides. The wiring is detailed in The API client.

Adding a new error type

Say you need a PaymentFailedError. Four edits, all small:

  1. Declare the subtype in AppError.cs with a stable code:

    public sealed record PaymentFailedError(string Reason)
        : AppError($"Payment failed: {Reason}", "PaymentFailed");
  2. Add a switch arm in AppErrorExtensions.ToProblem (skip this and it falls through to 500):

    PaymentFailedError e => TypedResults.Problem(e.Message, statusCode: 402,
        title: "Payment Required", extensions: CodeExt(e)),
  3. Return it from the handler as you would any other AppError:

    if (!charge.Succeeded) return new PaymentFailedError(charge.FailureReason);
  4. Add the i18n key (camelCase of the code) to both frontend/src/shared/i18n/locales/en.json and nl.json:

    "errors": { "paymentFailed": "Payment could not be processed." }

For a custom FluentValidation rule (any .Must(...), .Matches(...), or hand-written validator), chain .WithErrorCode("MyCode") after .WithMessage(...), map the code in frontend/src/shared/i18n/errorCodeMap.ts, and add the matching validation.* key to both locales.

Checklist

  • Handler returns Result<T> (or Result), never throws for a business failure.
  • Failure cases return a fitting AppError subtype; reuse before adding a new one.
  • New subtype carries a stable ErrorCode and has a switch arm in AppErrorExtensions.ToProblem.
  • Endpoint maps with ToOkOrProblem / ToCreatedOrProblem / ToNoContentOrProblem and declares every .ProducesProblem(...).
  • Request-shape rules live in FluentValidation; domain rules return ValidationError from the handler.
  • New error code has errors.<camelCase> keys in both en.json and nl.json.
  • dotnet test api/tests/Slicekit.Unit.Tests and cd frontend && pnpm test both pass.