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-safety

Decision

Operations that can meaningfully fail return a sealed Result<T> (Success | Failure) carrying a sealed AppFailure hierarchy. Both live in core_models. No dartz/fpdart/Either; no exceptions propagating past the data layer.
sealed class Result<T> { const Result(); }
final class Success<T> extends Result<T> { final T value; const Success(this.value); }
final class Failure<T>  extends Result<T> { final AppFailure error; const Failure(this.error); }

sealed class AppFailure { final String message; const AppFailure(this.message); }
final class NetworkFailure    extends AppFailure { ... }
final class AuthFailure       extends AppFailure { ... }
final class ValidationFailure extends AppFailure { final Map<String,String> fieldErrors; ... }
final class NotFoundFailure   extends AppFailure { ... }
final class SyncFailure       extends AppFailure { ... }
final class UnexpectedFailure extends AppFailure { ... }
  • Repositories return Result<T> for fallible operations.
  • Exceptions are caught and mapped to a typed AppFailure only at the data-layer boundary (ADR-0039). Domain and presentation never try/catch or throw; they pattern-match exhaustively.
  • Result applies to the write path and non-synced API calls; PowerSync reactive reads surface as Stream<List<T>> and are not wrapped where there is no real failure mode (ADR-0040).
  • AppFailure.message is 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 like ValidationFailure.fieldErrors are semantic data, not display strings. This keeps core_models free of any core_i18n dependency (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, sealed Result 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> via dartz/fpdart. Powerful FP combinators, but opaque Left/Right semantics, an FP dependency, and dartz is semi-abandoned. Rejected — sealed Result is more idiomatic and LLM-friendly.
  • Exceptions everywhere with try/catch at 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 are Stream.

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_models owns Result + AppFailure (it is shared domain vocabulary; pure Dart).
  • Adding a new AppFailure variant forces every exhaustive switch to be revisited — a feature, not a bug.

Rules for agents

  • Repositories return Result<T> for fallible operations; try/catch ONLY in the data layer, converting to typed AppFailure.
  • Domain and presentation never throw/catch; pattern-match Result exhaustively.
  • Do NOT use Either/dartz/fpdart. Do NOT use stringly-typed errors.
  • Do NOT wrap PowerSync reactive reads in Result — expose them as Stream. Use Result for the write path and non-synced API calls.
  • Define Result and AppFailure in core_models (pure Dart). They stay hand-written sealed classes — do NOT convert them to Freezed; it adds nothing there.
  • NEVER display AppFailure.message to users; presentation maps failure types to slang strings. core_models must not depend on core_i18n.