Sparround

A Failure hierarchy: typed errors

A Result tells you a failure happened; a Failure tells you which failure it was. The two work together.

The simplest option is carrying the error as a String message: Result.error(Exception('Network error')). That leaves three problems unsolved:

  • The UI cannot decide based on a message: should it offer a retry, or send the user to the login screen?
  • The message is hardcoded, so localisation is impossible.
  • When a new error kind is added, nobody is told.

The answer is a `sealed` Failure hierarchy. Every error kind is its own type, and because it is sealed, a switch in the UI is checked for exhaustiveness.

An important technical detail: Failure should implement `Exception`. The reason is practical — the official Result class's error variant carries the Exception type, so Result.error(const NetworkFailure()) only compiles when Failure implements Exception. It also allows throwing one, which is occasionally useful.

dart
// ══ lib/domain/failures/failure.dart ══

/// Tətbiqin bütün gözlənilən uğursuzluqları.
/// `sealed` → UI-da switch tam yoxlanılır.
/// `implements Exception` → `Result.error(...)` içində işləyir.
sealed class Failure implements Exception {
  const Failure();
}

// ── Şəbəkə və server ──
final class NetworkFailure extends Failure {
  const NetworkFailure();
}

final class TimeoutFailure extends Failure {
  const TimeoutFailure();
}

final class ServerFailure extends Failure {
  const ServerFailure(this.statusCode);
  final int statusCode;       // diaqnostika üçün saxlanılır
}

// ── Autentifikasiya ──
final class AuthFailure extends Failure {
  const AuthFailure();        // token bitib / etibarsızdır → login
}

final class ForbiddenFailure extends Failure {
  const ForbiddenFailure();   // giriş var, icazə yoxdur → login DEYİL
}

// ── Məlumat ──
final class NotFoundFailure extends Failure {
  const NotFoundFailure();
}

final class InvalidResponseFailure extends Failure {
  const InvalidResponseFailure();   // backend gözlənilməz format göndərdi
}

// ── Biznes qaydaları (sahəyə bağlı ola bilər) ──
final class ValidationFailure extends Failure {
  const ValidationFailure(this.fieldErrors);
  /// sahə adı → xəta açarı (mesaj deyil!)
  final Map<String, String> fieldErrors;
}

final class InsufficientPointsFailure extends Failure {
  const InsufficientPointsFailure({required this.missing});
  final int missing;
}

// ── Bilinməyən: BU VARİANT MƏCBURİDİR ──
final class UnexpectedFailure extends Failure {
  const UnexpectedFailure();
}


// ══ Repository: texniki exception → domain Failure ══
class OrderRepositoryRemote implements OrderRepository {
  @override
  Future<Result<List<Order>>> fetchMine() async {
    try {
      final dtos = await _api.getOrders();
      return Result.ok(dtos.map((d) => d.toDomain()).toList());
    } on SocketException {
      return const Result.error(NetworkFailure());
    } on TimeoutException {
      return const Result.error(TimeoutFailure());
    } on UnauthorizedException {
      return const Result.error(AuthFailure());
    } on ForbiddenException {
      return const Result.error(ForbiddenFailure());
    } on NotFoundException {
      return const Result.error(NotFoundFailure());
    } on ServerException catch (e) {
      return Result.error(ServerFailure(e.statusCode));
    } on FormatException catch (e, st) {
      // Gözlənilən (bizim baqımız deyil), lakin LOQLANMALIDIR.
      _logger.warning('Cavab formatı gözlənilməzdir', e, st);
      return const Result.error(InvalidResponseFailure());
    }
    // `Error` tipləri (RangeError, TypeError) TUTULMUR — onlar baqdır
    // və qlobal xəta tutucusuna qədər qalxmalıdır.
  }
}

The Failure hierarchy and the exception → Failure translation in a repository. Note that an `Unexpected` variant is mandatory — there is always an unknown case.

FailureCauseWhat the UI should doRetry?
`NetworkFailure`No connectivity"No connection" plus a retry buttonYes
`TimeoutFailure`The server did not answer"This took too long" plus retryYes
`ServerFailure`A 5xx response"The service has a problem" plus retry; the status code goes to logsYes
`AuthFailure`The token expired or is invalidRedirect to the login screenNo
`ForbiddenFailure`Signed in, but not permitted"You do not have permission" — do not redirect to loginNo
`NotFoundFailure`The object was deleted or never existed"Not found" plus a way backNo
`ValidationFailure`Server-side validationShow the errors under the fieldsNo
`UnexpectedFailure`UnknownA generic message plus retry; file a reportYes

Messages do not live on a `Failure`. This is the most frequently broken rule. When you write NetworkFailure('No connection'):

  • The text lands in the domain layer and localisation becomes impossible.
  • The same error cannot be presented differently on two screens (a snackbar here, a full-screen state there).
  • Tests start comparing strings — so changing the copy breaks tests even though behaviour did not change.

The right split: a Failure states the kind, and the presentation layer takes the text from localisation (AppLocalizations). A ValidationFailure carries keys rather than messages: {'email': 'invalid_format'} — and the UI translates the key.

Why `UnexpectedFailure` is mandatory. The backend returns a new error code, a library throws a new exception, a FormatException arrives from somewhere unexpected — all of that is normal life. Without a variant for the unknown case, the code either crashes or you add a default to the switch — and a default cancels the whole benefit of sealed: the compiler stops warning you when a new Failure is added.

Logging. UnexpectedFailure and InvalidResponseFailure should always be logged (Crashlytics, Sentry): that is the only way to learn what those "unknown" cases actually are.

Practice. Two steps:

1. Create lib/domain/failures/failure.dart: sealed class Failure implements Exception plus five to seven subtypes that fit your project, plus a mandatory UnexpectedFailure. 2. Move one repository method onto that hierarchy and write a switch in the presentation layer: a different message per Failure, and a retry button only where retrying makes sense.

Then add a new Failure subtype and run flutter analyze — the compiler should point at every non-exhaustive switch. Done means: you have seen that mechanism work, and every message lives in the localisation file rather than in code.

📚 Sources and documentation