Kodokon kodokon.com

Arquitectura de una app: UI, lógica, datos

Estructura una app de Flutter en capas estancas con el patrón repository y resultados sellados tipados.

11 min · 3 preguntas

Abrir esta lección en Kodokon

Una 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.

DART
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;
}
Un tipo sellado hace explícito el fallo en la firma.

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.

DART
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);
    }
  }
}
El repository mapea, cachea y convierte los errores en un Result.

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.

DART
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),
              ),
            ),
        };
      },
    );
  }
}
null = cargando, y el switch cubre todos los casos del tipo sellado.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. ¿Por qué la UI depende de la interfaz CourseRepository en lugar de ApiCourseRepository?
    • Para inyectar una implementación falsa en los tests y cambiar la fuente de datos sin tocar los widgets
    • Porque las clases abstractas se instancian más rápido
    • Porque Dart prohíbe instanciar una clase concreta desde un widget
  2. ¿Qué ventaja aporta un tipo sellado como Result frente a una excepción?
    • El compilador comprueba que el switch es exhaustivo: no puedes olvidar el caso de error
    • Hace más rápido el código asíncrono
    • Evita cualquier asignación de objeto en el heap
    • También captura los errores de los isolates
  3. ¿Dónde debe vivir el mapeo de JSON a la entidad Course?
    • En la capa de datos, para que el dominio ignore el formato de la API
    • En el widget, lo más cerca posible de la visualización
    • En main(), antes de runApp