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.
// ══ 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.
| Failure | Cause | What the UI should do | Retry? |
|---|---|---|---|
| `NetworkFailure` | No connectivity | "No connection" plus a retry button | Yes |
| `TimeoutFailure` | The server did not answer | "This took too long" plus retry | Yes |
| `ServerFailure` | A 5xx response | "The service has a problem" plus retry; the status code goes to logs | Yes |
| `AuthFailure` | The token expired or is invalid | Redirect to the login screen | No |
| `ForbiddenFailure` | Signed in, but not permitted | "You do not have permission" — do not redirect to login | No |
| `NotFoundFailure` | The object was deleted or never existed | "Not found" plus a way back | No |
| `ValidationFailure` | Server-side validation | Show the errors under the fields | No |
| `UnexpectedFailure` | Unknown | A generic message plus retry; file a report | Yes |
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
- Error handling with Result objectsofficialdocs.flutter.dev
`Result.error` carrying an `Exception` — the reason behind `Failure implements Exception`.
- Dart: class modifiersofficialdart.dev
The `sealed` plus `final` combination: the hierarchy is closed and `switch` is checked.
- Flutter: internationalizationofficialdocs.flutter.dev
The official mechanism for keeping error messages in `AppLocalizations` rather than in code.
- Dart: the `Exception` classofficialapi.dart.dev
What the `Exception` interface is — the basis for having `Failure` implement it.