Sample: Error-Contract
Ich will einen Handler schreiben der sauber mit Fehlern umgeht — ohne HTTP-Codes, ohne JSON-Bodies, ohne try/catch-Ketten.
Was dieses Sample zeigt
Jede Kumiko-Error-Klasse im realen Handler-Kontext. Ein einzelnes Feature orders-lite, vier Handler, 7 Testfaelle die je eine typische Fehler-Situation zeigen.
Das Rezept in 3 Saetzen
- Handler wirft oder returnt einen
KumikoErrorueberwriteFailure(...)oderfailNotFound(...)/failUnprocessable(...). - Der Dispatcher uebersetzt ihn in HTTP-Status + Wire-Format — immer
{ code, i18nKey, message, details?, requestId?, timestamp }. - Der Client liest
error.code(stabile Kategorie) odererror.details.reason(Feature-spezifischer Subtyp).
Die Klassen — wann nutze ich welche?
| Klasse | HTTP | Benutzung im Handler |
|---|---|---|
ValidationError | 400 | Automatisch aus Zod. Nie manuell werfen, nur fuer Validation-Hook-Fehler. |
AccessDeniedError | 403 | ”Du darfst das nicht” — Ownership, Role-Check, Field-Lock. |
NotFoundError | 404 | Entity existiert nicht. Automatisch via failNotFound(entity, id). |
ConflictError | 409 | State-Kollision ohne Version (z.B. “paid orders can’t be cancelled”). |
VersionConflictError | 409 | Optimistic Lock — kommt aus CrudExecutor automatisch. Du wirfst sie nie. |
UnprocessableError | 422 | Business-Regel verletzt. Der Reason-String beschreibt was. |
InternalError | 500 | Wirfst du nicht selbst. Das Framework wrappt unerwartete Throws automatisch. |
Convenience-Helper
Statt
return { isSuccess: false, error: toWriteErrorInfo(new NotFoundError("order", id)) };schreib
return failNotFound("order", id);Analog: failUnprocessable("reason", details?) und writeFailure(new AnyKumikoError(...)).
Reason-Codes — die Konvention
Wenn dein Feature eine eigene Differenzierung braucht (z.B. already_paid vs. already_cancelled), nimm die UnprocessableError oder ConflictError und setze details.reason:
export const OrdersLiteReasons = { alreadyPaid: "already_paid", alreadyCancelled: "already_cancelled",} as const;
return failUnprocessable(OrdersLiteReasons.alreadyPaid, { orderId });Regeln:
snake_case, keine Leerzeichen- Ein
<Feature>Reasonsconst-Object pro Feature - Framework-Reasons (
stale_state,invalid_transition,field_access_denied,delete_restricted) kommen ausFrameworkReasons— wiederverwenden, nicht duplizieren
Throw vs. writeFailure
Beide enden im selben Wire-Format. Faustregel:
- Handler-Top-Level →
return writeFailure(new X())oder diefailX(...)-Helper. Der Rueckgabetyp ist explizit. - Tief in einer Helper-Funktion →
throw new KumikoError(...). Sonst muesstest duWriteResultdurch jede Funktionssignatur schleifen.
Cause-Chain
Wenn du einen KumikoError wirfst der einen anderen Error als Ursache hat:
try { await externalApi.call();} catch (e) { throw new ConflictError({ message: "upstream rejected the sync", i18nKey: "orders-lite.errors.upstreamReject", details: { reason: "upstream_reject" }, cause: e instanceof Error ? e : undefined, });}Die Kette landet im Log (fuer Forensik), aber nicht im Response an den Client. Kein manueller Filter noetig.
Was du nicht machen sollst
throw new Error("string")— wird zuInternalError(500), der Client sieht keinen hilfreichen Fehlerreturn { isSuccess: false, error: "string" }— kein gueltigerWriteErrorInfo, TypeScript blockt es aber es ist ein typisches Muster aus Pre-v1-Code- Eigene
class MyError extends Error— auch das wird zuInternalError. NutzeUnprocessableError+details.reasonfuer Feature-Subtypen - Reason-Strings wie
"userNotAllowedToEditRecord"(camelCase) oder mit Leerzeichen — die Konvention istsnake_case
Weiterfuehrend
- Komplette Klassen-Definition:
packages/framework/src/errors/classes.ts - Goldstandard-Integration-Test:
packages/framework/src/__tests__/error-contract.integration.ts - Architekturplan:
docs/plans/architecture/error-contract.md
Source-Pfad: samples/recipes/error-contract/README.md