ADR-014 — Result<T> sealed + jerarquía Failure como error handling¶
| Campo | Valor |
|---|---|
| Estado | Aceptado |
| Fecha | 25 de febrero de 2026 |
| Decisores | Equipo Custodiam (Marcos Val Sanz, Rodrigo Mulero García) |
Contexto¶
El cliente Flutter consume APIs HTTP, BD local, almacenamiento seguro y servicios externos. Cada uno puede fallar por razones distintas (red caída, token expirado, conflicto 409, error de validación, BD corrupta, permiso denegado). Hay que decidir cómo se propagan los errores desde la capa data hacia la capa presentation.
Dos modelos compiten:
- Excepciones: cualquier capa lanza, la capa superior captura con
try/catch. Es el modelo idiomático Dart. - Tipo de retorno explícito: la función devuelve un valor que representa éxito o fracaso (
Either,Result, etc.). Pattern matching exhaustivo en el consumidor.
Decisión¶
Result<T> propio como sealed class con dos casos (Success<T> y Fail<T>) + jerarquía Failure con subclases por tipo de error (AuthFailure, NetworkFailure, ValidationFailure, etc.).
sealed class Result<T> {
const Result();
}
final class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
final class Fail<T> extends Result<T> {
final Failure failure;
const Fail(this.failure);
}
Todos los métodos de repository devuelven Result<T>. Las excepciones siguen existiendo pero quedan contenidas dentro de la capa data: el repository las captura, las traduce a Failure y devuelve Fail(failure). Ninguna excepción atraviesa el límite hacia el ViewModel.
Consumo desde ViewModel con pattern matching:
return switch (result) {
Success(:final value) => AsyncData(value),
Fail(:final failure) => AsyncError(failure, StackTrace.current),
};
Prohibido en el código del proyecto: usar fpdart, Either, left(), right(), .match(). Se mantiene Result<T> propio (las 3 líneas de arriba) por simplicidad, testabilidad y cero dependencias externas para un concepto trivial.
Justificación¶
-
Errores como parte del contrato. El tipo de retorno
Future<Result<List<Voluntario>>>deja explícito en la firma que la operación puede fallar y que el llamador debe tratar el caso. Una firmaFuture<List<Voluntario>>que lanza excepciones esconde esto y obliga al llamador a recordar qué excepciones puede recibir. -
Pattern matching exhaustivo gracias a
sealed. Dart 3+ con clasessealedpermite que el compilador verifique que elswitchcubre todos los casos deResult<T>(SuccessyFail). Olvidar uno produce error de compilación. Esto elimina toda una clase de bugs. -
Las capas superiores no necesitan saber qué excepciones existen. El ViewModel hace
switch (result)y trata éxito/fracaso. No necesita conocerSocketException,HttpException,FormatException,KeycloakExceptionni catálogos de excepciones de paquetes externos. La superficie del API de cada repository queda mínima. -
Testabilidad alta. Construir un
Success(value)o unFail(AuthFailure.sessionExpired())en un test es trivial. Stubbear excepciones requierethrowreal en un mock +expectLater(future, throwsA(...)), que es más ruidoso y peor legible. -
Sin dependencias externas para un concepto trivial.
fpdartañade decenas de tipos (Either,Option,IO,Task,State, ...) cuando el proyecto solo necesita el patrón éxito/fracaso. Tres líneas de Dart resuelven el caso de uso con cero peso transitivo.
Alternativas evaluadas y descartadas¶
A. Excepciones cross-layer¶
El modelo idiomático Dart: el repository lanza, el ViewModel captura.
- Pros: idiomático en Dart, menos código en el path feliz.
- Contras: las excepciones son flujo de control invisible — la firma de la función no las refleja; obliga al consumidor a saber qué excepciones puede recibir; el flujo de error no es exhaustivo verificable por el compilador; mezclar paths felices y de error en
try/catchcon variason X catches prolijo y propenso a olvidar casos. - Descartado por: falta de exhaustividad verificada y mezcla de flujos.
B. fpdart Either<L, R>¶
Librería que aporta tipos funcionales tipo Haskell a Dart.
- Pros: ecosistema funcional completo, comunidad activa.
- Contras: arrastra decenas de tipos que el proyecto no usa;
.match(left, right)es menos legible queswitchexhaustivo Dart-nativo; sintaxis de combinadores (flatMap,fold) añade una capa de abstracción que el equipo no necesita. - Descartado por: sobre-dimensionado para el caso.
C. Tuplas (T?, Failure?) o records (value: T?, error: Failure?)¶
Records Dart 3 permiten devolver pares.
- Pros: nativo Dart sin clase auxiliar.
- Contras: no es exhaustivo — el consumidor podría leer
valueaunqueerroresté presente, o viceversa. El compilador no impide el bug "asumí que era éxito sin comprobar". - Descartado por: el
sealed classda garantías que la tupla no.
D. Result<T, E> con dos parámetros genéricos¶
sealed class Result<T, E> {}
final class Success<T, E> extends Result<T, E> { final T value; ... }
final class Fail<T, E> extends Result<T, E> { final E error; ... }
- Pros: el tipo del error queda explícito en la firma.
- Contras: la firma de cada repository es más larga (
Future<Result<List<Voluntario>, Failure>>), y como el tipo del error es siempreFailureen todo el proyecto, el segundo parámetro genérico no aporta. El código del consumidor también se vuelve más ruidoso (Result<List<Voluntario>, Failure>vsResult<List<Voluntario>>). - Descartado por: sobre-genérico para un proyecto con un único tipo de error.
Implicaciones operativas¶
-
Jerarquía
Failuredocumentada enlib/infrastructure/error/:sealed class Failure { const Failure(); } final class AuthFailure extends Failure { const AuthFailure.sessionExpired(); const AuthFailure.invalidCredentials(); ... } final class NetworkFailure extends Failure { const NetworkFailure.noConnection(); const NetworkFailure.serverError(this.statusCode); ... } final class ValidationFailure extends Failure { const ValidationFailure(this.errors); ... } -
Mapeo del
Failurea UI: el ViewModel propaga elFailureal estadoAsyncError; la página consumidora muestra un mensaje en español derivado del tipo deFailure(helperfailureToUserMessage(failure)). -
Tests de repository: cada test verifica el
Failureespecífico devuelto en cada camino de error. La firma exhaustiva deswitch (failure)también aplica aFailure(sealed), por lo que olvidar un caso al añadir un subtipo nuevo causa error de compilación en cualquier consumer que haga el switch. -
Logging del
Fail: elApiClient(ADR-004) loguea cadaFailcondev.log(name: 'API')antes de devolverlo, así DevTools muestra qué falló sin que el ViewModel tenga que volver a loguear.
Referencias¶
- Dart 3 — sealed classes — base del modelo.
- Dart 3 — pattern matching — sintaxis
switchexhaustivo. - ADR-004 Cliente HTTP — el wrapper convierte excepciones a
Result<T>en la capa data. - ADR-012 Riverpod — los ViewModels traducen
Result<T>aAsyncValuepara la UI.