Arquitectura de Apps Flutter que Escala Más Allá del Tutorial
Patrones de arquitectura Flutter para producción — desde gestión de estado hasta navegación, inyección de dependencias y estructura de proyecto por funcionalidades.

Las apps Flutter de tutorial funcionan genial hasta que alcanzan unas veinte pantallas. Entonces el archivo único main.dart con todas las rutas se vuelve inmanejable, las llamadas a setState crean condiciones de carrera, el enfoque de "simplemente pásalo por el constructor" produce árboles de widgets con diez parámetros, y cada nueva funcionalidad toca cinco archivos existentes. He visto esto suceder en múltiples proyectos, incluidos los míos, y el patrón es siempre el mismo — el equipo golpea una pared alrededor del tercer mes y tiene que reestructurar o aceptar una pérdida creciente de velocidad de desarrollo.
La arquitectura que describo aquí es la que uso para apps en producción. No es el enfoque más simple posible, y ese es el punto. El enfoque más simple es lo que enseñan los tutoriales, y deja de funcionar en el momento en que tu app tiene requisitos reales.
Por Qué Falla la Arquitectura de Tutorial
La mayoría de tutoriales y cursos de Flutter enseñan una estructura de proyecto plana:
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
Esto funciona para una app de tareas pendientes. Falla para una app real por razones predecibles:
Los archivos crecen sin límites naturales. Cuando home_screen.dart tiene el diseño de la pantalla, la gestión de estado, las llamadas a la API y la lógica de negocio, alcanza 500+ líneas rápidamente. No hay guía estructural sobre qué debería extraerse y dónde debería ir.
Las dependencias se vuelven implícitas. Cuando home_screen.dart instancia directamente ApiService(), no hay forma de sustituirlo por un mock en las pruebas, no hay forma de configurarlo diferente por entorno, y no hay forma de compartir una única instancia entre pantallas que necesitan los mismos datos.
La navegación se convierte en una red de rutas hardcodeadas. Deep links, guardias de autenticación y flujos condicionales se atornillan a una lista plana de rutas. Para cuando tienes treinta rutas con guardias y redirecciones, el código de navegación es la parte más frágil de la app.
Las pruebas se vuelven impracticables. Sin inyección de dependencias, las pruebas de widgets requieren hacer mocking a nivel HTTP en lugar de a nivel de servicio. Sin una separación clara de la lógica de negocio de la UI, terminas probando detalles de implementación en lugar de comportamiento.
Estructura de Proyecto por Funcionalidades
La decisión arquitectónica más impactante es la estructura de carpetas. Uso un enfoque de funcionalidad primero donde cada funcionalidad posee sus pantallas, widgets, estado y modelos:
lib/
├── app/
│ ├── app.dart # Configuración de MaterialApp
│ ├── router.dart # Configuración de GoRouter
│ └── theme.dart # Datos del tema
├── core/
│ ├── constants/
│ │ ├── api_constants.dart
│ │ └── app_constants.dart
│ ├── errors/
│ │ ├── app_exception.dart
│ │ └── error_handler.dart
│ ├── network/
│ │ ├── api_client.dart
│ │ ├── interceptors/
│ │ └── network_info.dart
│ └── utils/
│ ├── date_utils.dart
│ └── string_utils.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── auth_remote_source.dart
│ │ ├── domain/
│ │ │ ├── user_model.dart
│ │ │ └── auth_state.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── login_screen.dart
│ │ │ │ └── register_screen.dart
│ │ │ ├── widgets/
│ │ │ │ ├── login_form.dart
│ │ │ │ └── social_login_buttons.dart
│ │ │ └── providers/
│ │ │ └── auth_provider.dart
│ │ └── auth.dart # Barrel file para API pública
│ ├── home/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── home.dart
│ └── settings/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ └── settings.dart
└── shared/
├── widgets/
│ ├── app_button.dart
│ ├── app_text_field.dart
│ ├── loading_overlay.dart
│ └── error_view.dart
├── models/
│ └── paginated_response.dart
└── providers/
└── connectivity_provider.dart
Las Reglas
Las funcionalidades nunca importan de otras funcionalidades. Si la funcionalidad A necesita datos de la funcionalidad B, esos datos viven en un modelo compartido o un servicio compartido, no en la carpeta de la funcionalidad B. Esto previene dependencias circulares y mantiene las funcionalidades desplegables independientemente (útil si alguna vez migras a un monorepo modular).
El directorio core/ contiene infraestructura. Red, manejo de errores, constantes, utilidades — cosas que cualquier funcionalidad podría necesitar pero que no pertenecen a ninguna funcionalidad individual.
El directorio shared/ contiene UI reutilizable y preocupaciones transversales. Widgets compartidos, modelos compartidos y providers de los que dependen múltiples funcionalidades.
Los barrel files exponen la API pública de cada funcionalidad. Otras partes de la app importan desde features/auth/auth.dart, nunca desde archivos internos. Esto te permite refactorizar la estructura interna de una funcionalidad sin romper a los consumidores externos.
// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// Don't export internal implementation details
Gestión de Estado: Riverpod
He usado Provider, Bloc y Riverpod en proyectos en producción. Cada uno tiene contrapartidas, y la elección depende más de la familiaridad del equipo que de la superioridad técnica. Dicho esto, uso Riverpod en nuevos proyectos por razones específicas.
Por Qué Riverpod Sobre Bloc
Bloc es excelente para equipos que vienen de un background empresarial. El patrón evento/estado es familiar para desarrolladores que han trabajado con Redux o MVI. Impone un flujo de datos unidireccional estricto y genera máquinas de estado predecibles y testeables. La desventaja es la verbosidad — una funcionalidad simple requiere una clase de evento, una clase de estado y una clase de bloc, a menudo en tres archivos. Para apps con lógica de negocio compleja y múltiples desarrolladores, esta ceremonia vale la pena. Para equipos más pequeños moviéndose rápido, puede sentirse como sobrecarga.
Provider (el paquete original) funciona bien para inyección de dependencias simple pero tiene dificultades con estado complejo. Depende del árbol de widgets para el alcance, lo que significa que el ciclo de vida de tu estado está atado a tu estructura de UI. Refactorizar el árbol de widgets puede romper la gestión de estado de formas sutiles.
Riverpod resuelve las limitaciones fundamentales de Provider. Los providers son globales (no dependientes del árbol de widgets), seguros en tiempo de compilación y soportan patrones avanzados como auto-disposición, family providers y estado asíncrono. También genera menos boilerplate que Bloc manteniendo la testabilidad.
Riverpod en Práctica
Así es como estructuro la gestión de estado para una funcionalidad típica:
// features/auth/domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_repository.dart';
import '../../domain/auth_state.dart';
part 'auth_provider.g.dart';
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
_checkAuthStatus();
return const AuthState.initial();
}
Future<void> _checkAuthStatus() async {
final repository = ref.read(authRepositoryProvider);
final user = await repository.getCurrentUser();
state = user != null
? AuthState.authenticated(user)
: const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
final repository = ref.read(authRepositoryProvider);
try {
final user = await repository.signIn(email, password);
state = AuthState.authenticated(user);
} on AuthException catch (e) {
state = AuthState.error(e.message);
}
}
Future<void> signOut() async {
final repository = ref.read(authRepositoryProvider);
await repository.signOut();
state = const AuthState.unauthenticated();
}
}
Usar la anotación @riverpod con generación de código elimina el boilerplate de declaración manual de providers. Ejecuta dart run build_runner build para generar el código del provider.
Cuándo Bloc Es Mejor
A pesar de mi preferencia por Riverpod, hay escenarios donde Bloc es la opción más fuerte:
- Equipos grandes donde los patrones impuestos previenen inconsistencias.
- Máquinas de estado complejas con muchas transiciones — el sistema de eventos de Bloc las hace explícitas y testeables.
- Cuando necesitas funcionalidad de replay/deshacer — el registro de eventos de Bloc lo hace sencillo.
- Cuando el equipo ya conoce Bloc. La consistencia en el codebase importa más que cualquier diferencia técnica entre los dos.
Navegación con GoRouter
GoRouter se ha convertido en el estándar para la navegación en Flutter, y por buenas razones. Soporta enrutamiento declarativo, deep links, redirecciones y navegación anidada listas para usar.
// app/router.dart
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';
@riverpod
GoRouter router(RouterRef ref) {
final authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState is Authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) return '/auth/login';
if (isAuthenticated && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ItemDetailScreen(itemId: id);
},
),
],
),
],
);
}
Guardias de Navegación
El callback redirect maneja guardias de autenticación globalmente. Pero podrías necesitar lógica más compleja — acceso basado en roles, flujos de onboarding, feature flags. Manejo estos con una cadena de redirecciones:
redirect: (context, state) {
// Authentication check
final isAuthenticated = authState is Authenticated;
if (!isAuthenticated && !state.matchedLocation.startsWith('/auth')) {
return '/auth/login';
}
// Onboarding check
if (isAuthenticated && !hasCompletedOnboarding && state.matchedLocation != '/onboarding') {
return '/onboarding';
}
// Already authenticated, redirect away from auth screens
if (isAuthenticated && state.matchedLocation.startsWith('/auth')) {
return '/';
}
return null; // No redirect
},
El Patrón Repository
Los repositories se sitúan entre tu lógica de negocio y las fuentes de datos. Abstraen si los datos vienen de una API REST, una base de datos local, una caché o una combinación de las tres.
// features/auth/data/auth_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user_model.dart';
part 'auth_repository.g.dart';
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepository(
remoteSource: ref.read(authRemoteSourceProvider),
localSource: ref.read(authLocalSourceProvider),
);
}
class AuthRepository {
final AuthRemoteSource _remoteSource;
final AuthLocalSource _localSource;
AuthRepository({
required AuthRemoteSource remoteSource,
required AuthLocalSource localSource,
}) : _remoteSource = remoteSource,
_localSource = localSource;
Future<User?> getCurrentUser() async {
// Try local cache first
final cachedUser = await _localSource.getCachedUser();
if (cachedUser != null) return cachedUser;
// Fall back to remote
try {
final user = await _remoteSource.fetchCurrentUser();
if (user != null) await _localSource.cacheUser(user);
return user;
} catch (_) {
return null;
}
}
Future<User> signIn(String email, String password) async {
final user = await _remoteSource.signIn(email, password);
await _localSource.cacheUser(user);
await _localSource.saveToken(user.token);
return user;
}
Future<void> signOut() async {
await _remoteSource.signOut();
await _localSource.clearCache();
}
}
Por Qué No Llamar a la API Directamente
Las llamadas directas a la API desde el código de gestión de estado crean varios problemas:
- La lógica de caché se mezcla con la lógica de negocio. ¿Verificas la caché antes de la llamada a la API? ¿Cuánto tiempo es válida la caché? Esta es una preocupación de la capa de datos, no de gestión de estado.
- El soporte offline se convierte en una reescritura. Si tu Bloc llama directamente a
http.get, agregar soporte offline significa modificar cada Bloc. Con un repository, agregas lógica de caché y fallback en un solo lugar. - Las pruebas requieren mocking HTTP. Hacer mocking de un repository es un mock por prueba. Hacer mocking de HTTP requiere configurar cuerpos de respuesta, códigos de estado y encabezados para cada caso de prueba.
Capa de Servicios
Para operaciones que abarcan múltiples repositories o involucran lógica de negocio compleja, uso una capa de servicios:
class OrderService {
final OrderRepository _orderRepo;
final PaymentRepository _paymentRepo;
final NotificationRepository _notificationRepo;
OrderService({
required OrderRepository orderRepo,
required PaymentRepository paymentRepo,
required NotificationRepository notificationRepo,
}) : _orderRepo = orderRepo,
_paymentRepo = paymentRepo,
_notificationRepo = notificationRepo;
Future<Order> placeOrder(Cart cart, PaymentMethod method) async {
// Validate stock
await _orderRepo.validateStock(cart.items);
// Process payment
final payment = await _paymentRepo.charge(method, cart.total);
// Create order
final order = await _orderRepo.create(
items: cart.items,
paymentId: payment.id,
);
// Send confirmation (fire and forget)
_notificationRepo.sendOrderConfirmation(order).ignore();
return order;
}
}
Los servicios contienen lógica de negocio que no pertenece a la UI ni a ningún repository individual. La capa de gestión de estado (Riverpod notifier, Bloc) llama al servicio, y el servicio coordina entre repositories.
Manejo de Errores
El manejo consistente de errores es uno de los aspectos más descuidados de la arquitectura Flutter. Uso una combinación de excepciones tipadas y un tipo de resultado.
// core/errors/app_exception.dart
sealed class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
}
class NetworkException extends AppException {
const NetworkException([String message = 'Network error']) : super(message);
}
class ServerException extends AppException {
final int statusCode;
const ServerException(this.statusCode, String message) : super(message);
}
class AuthException extends AppException {
const AuthException([String message = 'Authentication failed']) : super(message);
}
class ValidationException extends AppException {
final Map<String, String> fieldErrors;
const ValidationException(this.fieldErrors) : super('Validation failed');
}
El cliente API captura errores HTTP y los convierte a excepciones tipadas:
// core/network/api_client.dart
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<T> get<T>(String path, {T Function(dynamic)? fromJson}) async {
try {
final response = await _dio.get(path);
return fromJson != null ? fromJson(response.data) : response.data as T;
} on DioException catch (e) {
throw _mapError(e);
}
}
AppException _mapError(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return const NetworkException('Connection timed out');
}
final statusCode = error.response?.statusCode;
if (statusCode == null) return const NetworkException();
return switch (statusCode) {
401 => const AuthException('Session expired'),
403 => const AuthException('Access denied'),
422 => ValidationException(
_parseValidationErrors(error.response?.data),
),
_ => ServerException(statusCode, 'Server error'),
};
}
}
Arquitectura de Pruebas
Una buena arquitectura hace que las pruebas sean sencillas. Cada capa tiene su propia estrategia de pruebas:
Pruebas de Repository
void main() {
late AuthRepository repository;
late MockAuthRemoteSource mockRemote;
late MockAuthLocalSource mockLocal;
setUp(() {
mockRemote = MockAuthRemoteSource();
mockLocal = MockAuthLocalSource();
repository = AuthRepository(
remoteSource: mockRemote,
localSource: mockLocal,
);
});
group('getCurrentUser', () {
test('returns cached user when available', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => user);
final result = await repository.getCurrentUser();
expect(result, equals(user));
verifyNever(() => mockRemote.fetchCurrentUser());
});
test('fetches from remote when cache is empty', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => null);
when(() => mockRemote.fetchCurrentUser()).thenAnswer((_) async => user);
when(() => mockLocal.cacheUser(any())).thenAnswer((_) async {});
final result = await repository.getCurrentUser();
expect(result, equals(user));
verify(() => mockLocal.cacheUser(user)).called(1);
});
});
}
Pruebas de Provider con Riverpod
void main() {
test('signIn updates state to authenticated', () async {
final user = User(id: '1', name: 'Test');
final mockRepository = MockAuthRepository();
when(() => mockRepository.signIn(any(), any()))
.thenAnswer((_) async => user);
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.signIn('test@test.com', 'password');
final state = container.read(authNotifierProvider);
expect(state, AuthState.authenticated(user));
});
}
El ProviderContainer de Riverpod y el mecanismo de override hacen que las pruebas sean limpias — haces override de dependencias a nivel de contenedor en lugar de a nivel de árbol de widgets.
Contrapartidas Prácticas
Esta arquitectura no es gratuita. Hay costos reales:
Más archivos. Una funcionalidad que sería un solo archivo en una app de tutorial podría ser cinco a ocho archivos en esta estructura. Para apps pequeñas (menos de diez pantallas), esto puede sentirse excesivo.
Curva de aprendizaje. Un desarrollador nuevo en el proyecto necesita entender los límites de las capas y dónde poner las cosas. La documentación ayuda, pero aún hay tiempo de adaptación.
Generación de código. El sistema de anotaciones de Riverpod, Freezed para modelos inmutables y serialización JSON requieren build_runner. La generación de código agrega un paso al desarrollo y puede ser lenta en codebases grandes.
Tentación de sobre-abstraer. Cuando tienes una arquitectura limpia, es fácil agregar abstracciones "por si acaso." Una interfaz de repository para una fuente de datos que solo tendrá una implementación agrega complejidad sin valor. Creo abstracciones cuando tengo una segunda implementación concreta, no antes.
La arquitectura vale la pena cuando tu app tiene más de unas diez pantallas, más de un desarrollador, o un tiempo de vida más allá de unos pocos meses. Para un proyecto de hackathon o una prueba de concepto, usa lo más simple que funcione. Pero cuando sabes que la app necesita crecer, invertir en estructura temprano ahorra múltiplos del costo después.
Danil Ulmashev
Full Stack Developer
Interesado en trabajar juntos?