# Error handling

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

## Two values, never an exception

Slicekit handlers do not throw to signal a business failure. They return a `Result`: 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` and the non-generic `Result`.
- `AppError.cs`: the abstract `AppError` record and its concrete subtypes.

## The `Result` type

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

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

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:

```csharp
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](/docs/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:

```csharp
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](/docs/vertical-slices)). They invoke the handler
over Wolverine and map the result. For the common shapes there are extension shortcuts on `Result`:

```csharp
private static async Task<Results<Ok, ProblemHttpResult>> HandleAsync(
    Guid id, IMessageBus bus, CancellationToken ct)
{
    var result = await bus.InvokeAsync<Result>(new GetUserQuery(id), ct);
    return result.ToOkOrProblem();
}
```

```csharp
return result.ToCreatedOrProblem($"/users/{result.Value.Id}");  // Results<Created, 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:

```csharp
.Produces()
.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` 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`
   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](/docs/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:

   ```csharp
   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):

   ```csharp
   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`:

   ```csharp
   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`:

   ```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` (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.
