Backend guides
Error handling
The Result and AppError taxonomy, how failures map to ProblemDetails responses, and how to add an error type.
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-genericResult.AppError.cs: the abstractAppErrorrecord 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 subtype | Status | ErrorCode | When to use |
|---|---|---|---|
NotFoundError(resource) | 404 | NotFound | Resource does not exist, or the caller has no view of it. |
ConflictError(resource, detail) | 409 | Conflict (overridable) | Conflicting state: duplicate, optimistic concurrency. |
ForbiddenError(permission) | 403 | Forbidden | Authenticated caller lacks a permission. |
UnverifiedEmailError() | 403 | EmailNotVerified | Action requires a verified email address. |
UnauthorizedError() | 401 | Unauthorized | Caller is not authenticated. |
ValidationError(errors) | 400 | ValidationError | Business-rule validation inside the handler (see below). |
LockedError(unlockedAt) | 423 | AccountLocked | Identity lockout; carries the unlock timestamp. |
TotpRequiredError(pendingToken) | 403 | TotpRequired | First factor passed, 2FA still required. |
TotpInvalidError() | 400 | TotpCodeInvalid | Submitted TOTP code is wrong or expired. |
TotpSetupRequiredError() | 403 | TotpSetupRequired | 2FA enrolment is required before continuing. |
PasskeyVerificationFailedError | 400 | PasskeyVerificationFailed | WebAuthn assertion did not verify. |
PasskeyCeremonyExpiredError() | 410 | PasskeyCeremonyExpired | Passkey challenge expired; restart the ceremony. |
InvalidTokenError() | 422 | InvalidToken | A link or token is invalid or has expired. |
InternalError(detail) | 500 | InternalError | Fallback. 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:
- Request shape, at the filter layer.
ValidationEndpointFilter<TRequest>runs FluentValidation before the handler and returns a 400 with the standarderrorsdictionary. This is whereRuleFor(x => x.Email).EmailAddress()lives. - Domain rules, inside the handler. Return
new ValidationError(errors)as aResult<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:
UnauthorizedAccessExceptionbecomes 401.FluentValidation.ValidationExceptionbecomes 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 uperrors.<snake_code>in the active locale. If a translation exists it overwritesdetail, so a plaintoast.error(err.detail)renders the localised message for free. - Form binding.
applyToForm(setError)walks theerrorsdictionary and pushes per-field messages into react-hook-form, usingvalidationParamsas 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:
-
Declare the subtype in
AppError.cswith a stable code:public sealed record PaymentFailedError(string Reason) : AppError($"Payment failed: {Reason}", "PaymentFailed"); -
Add a
switcharm inAppErrorExtensions.ToProblem(skip this and it falls through to 500):PaymentFailedError e => TypedResults.Problem(e.Message, statusCode: 402, title: "Payment Required", extensions: CodeExt(e)), -
Return it from the handler as you would any other
AppError:if (!charge.Succeeded) return new PaymentFailedError(charge.FailureReason); -
Add the i18n key (camelCase of the code) to both
frontend/src/shared/i18n/locales/en.jsonandnl.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>(orResult), never throws for a business failure. - Failure cases return a fitting
AppErrorsubtype; reuse before adding a new one. - New subtype carries a stable
ErrorCodeand has aswitcharm inAppErrorExtensions.ToProblem. - Endpoint maps with
ToOkOrProblem/ToCreatedOrProblem/ToNoContentOrProblemand declares every.ProducesProblem(...). - Request-shape rules live in FluentValidation; domain rules return
ValidationErrorfrom the handler. - New error code has
errors.<camelCase>keys in bothen.jsonandnl.json. -
dotnet test api/tests/Slicekit.Unit.Testsandcd frontend && pnpm testboth pass.