ADR-0045: Error modeling — sealed Result<T> + AppFailure over Either/exceptions
Status
Accepted (pending FieldForce Phase 1 implementation)Tags
fieldforce, digital-worker, mobile, flutter, error-handling, result, sealed-class, dart, type-safetyDecision
Operations that can meaningfully fail return a sealedResult<T> (Success | Failure) carrying a sealed AppFailure hierarchy. Both live in core_models. No dartz/fpdart/Either; no exceptions propagating past the data layer.
- Repositories return
Result<T>for fallible operations. - Exceptions are caught and mapped to a typed
AppFailureonly at the data-layer boundary (ADR-0039). Domain and presentation nevertry/catchorthrow; they pattern-match exhaustively. Resultapplies to the write path and non-synced API calls; PowerSync reactive reads surface asStream<List<T>>and are not wrapped where there is no real failure mode (ADR-0040).AppFailure.messageis diagnostic only (logs, crash reports — English, developer-facing). It is never shown to users. User-facing text is derived in the presentation layer by matching the failure type to a slang translation (ADR-0046); payloads likeValidationFailure.fieldErrorsare semantic data, not display strings. This keepscore_modelsfree of anycore_i18ndependency (DAG, ADR-0039).
Why
The project’s overriding priority is fewer bugs and less debugging under a vibe-coded workflow (ADR-0037). Exceptions are invisible in type signatures, so the compiler cannot force callers to handle failure — unhandled failures become production crashes. Making failure part of the return type forces handling at compile time. Between the two type-based options, sealedResult beats Either for this project: Dart 3 sealed classes give compiler-enforced exhaustiveness with self-documenting names (Success/Failure, not Left/Right), no functional-programming dependency, and no Left/Right convention to memorize. It is the modern idiomatic Dart approach (the community has shifted off dartz, which is semi-abandoned), and the LLM generates clean sealed-class switch code more reliably than dartz combinator chains. The compile-time exhaustiveness is the same class of safety as null safety, applied to errors.
The must-record nuance is where Result does and doesn’t apply. Forcing Result onto PowerSync reactive reads is ceremony — those reads come from local SQLite that is simply present, with no request/response failure mode; they belong as Stream. Result earns its place on the write path (mutations the Go backend can reject) and non-synced calls (auth, one-off commands). A future implementer should not wrap every reactive stream in Result.
A typed AppFailure hierarchy (not stringly-typed errors) lets the UI pattern-match on failure type to choose behavior — NetworkFailure → offline retry; ValidationFailure → highlight fields; AuthFailure → route to login.
Rejected alternatives:
Either<L, R>viadartz/fpdart. Powerful FP combinators, but opaqueLeft/Rightsemantics, an FP dependency, anddartzis semi-abandoned. Rejected — sealedResultis more idiomatic and LLM-friendly.- Exceptions everywhere with
try/catchat call sites. Invisible in signatures; compiler can’t force handling; the crash-prone default. Rejected. - Stringly-typed error codes. No exhaustiveness, no type safety. Rejected — sealed
AppFailure. - Wrapping all reactive reads in
Result. Ceremony with no benefit. Rejected — reactive reads areStream.
Consequences
- The data layer is the single place exceptions are caught and converted — predictable, one-directional error flow.
- Callers must pattern-match exhaustively; a missing case is a compile error (the intended safety).
core_modelsownsResult+AppFailure(it is shared domain vocabulary; pure Dart).- Adding a new
AppFailurevariant forces every exhaustiveswitchto be revisited — a feature, not a bug.
Rules for agents
- Repositories return
Result<T>for fallible operations;try/catchONLY in the data layer, converting to typedAppFailure. - Domain and presentation never
throw/catch; pattern-matchResultexhaustively. - Do NOT use
Either/dartz/fpdart. Do NOT use stringly-typed errors. - Do NOT wrap PowerSync reactive reads in
Result— expose them asStream. UseResultfor the write path and non-synced API calls. - Define
ResultandAppFailureincore_models(pure Dart). They stay hand-written sealed classes — do NOT convert them to Freezed; it adds nothing there. - NEVER display
AppFailure.messageto users; presentation maps failure types to slang strings.core_modelsmust not depend oncore_i18n.