Estructura una app de Flutter en capas estancas con el patrón repository y resultados sellados tipados.
Abrir esta lección en KodokonUna app de Flutter construida para durar separa tres responsabilidades: la presentación (widgets, BuildContext), la lógica (estado, casos de uso) y los datos (API, base de datos local). La regla cardinal es la dirección de las dependencias: la presentación depende de la lógica, la lógica depende de las abstracciones de datos, nunca al revés. En concreto, ningún BuildContext ni ningún import de material.dart debe aparecer por debajo de la capa de presentación: un controlador que muestra su propio SnackBar es un controlador que no se puede testear. Otro punto que a menudo se pasa por alto: los errores forman parte del contrato entre capas. En lugar de dejar que se filtren excepciones sin tipar, modela el resultado con una clase sealed: el compilador te obligará a tratar cada caso.
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error);
final Object error;
}
class Course {
const Course({required this.id, required this.title});
final String id;
final String title;
}El repository es la frontera entre el dominio y el mundo exterior. Su papel exacto: exponer entidades de dominio (nunca los DTO crudos de la API), ocultar la estrategia de acceso (red, caché, base de datos local) y centralizar la política de frescura de los datos. En Dart 3, declara el contrato con abstract interface class: este modificador solo permite implements desde fuera de la librería, así no puedes heredar por accidente un trozo de implementación y el contrato se mantiene puro. El mapeo de JSON a entidad vive en la implementación: si mañana la API renombra un campo, solo cambia la capa de datos.
abstract interface class CourseApi {
Future<List<Map<String, Object?>>> fetchRaw();
}
abstract interface class CourseRepository {
Future<Result<List<Course>>> getAll();
void invalidate();
}
class ApiCourseRepository implements CourseRepository {
ApiCourseRepository(this._api);
final CourseApi _api;
List<Course>? _cache;
@override
void invalidate() => _cache = null;
@override
Future<Result<List<Course>>> getAll() async {
final cached = _cache;
if (cached != null) {
return Success(cached);
}
try {
final rows = await _api.fetchRaw();
final courses = [
for (final row in rows)
Course(
id: row['id'] as String,
title: row['title'] as String,
),
];
_cache = courses;
return Success(courses);
} on Exception catch (error) {
return Failure(error);
}
}
}En el lado de la presentación, un ChangeNotifier inyectado a través del constructor basta muy a menudo: no hace falta recurrir a un framework. Evita los singletons globales: congelan el grafo de dependencias y hacen dolorosa la sustitución en los tests. ListenableBuilder solo reconstruye su builder, y el switch exhaustivo sobre el Result blinda la UI: añade un tercer estado al tipo sellado y la compilación falla en todos los sitios donde no se trata. En eso reside todo el valor del patrón: un olvido se convierte en un error de compilación, no en un bug en producción.
import 'package:flutter/material.dart';
class CourseListController extends ChangeNotifier {
CourseListController(this._repository);
final CourseRepository _repository;
Result<List<Course>>? state;
Future<void> load() async {
state = null;
notifyListeners();
state = await _repository.getAll();
notifyListeners();
}
}
class CourseListScreen extends StatelessWidget {
const CourseListScreen({
super.key,
required this.controller,
});
final CourseListController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return switch (controller.state) {
null => const Center(
child: CircularProgressIndicator(),
),
Failure() => const Center(
child: Text('Loading error'),
),
Success(value: final courses) =>
ListView.builder(
itemCount: courses.length,
itemBuilder: (context, i) => ListTile(
title: Text(courses[i].title),
),
),
};
},
);
}
}