Email copiado — support@tuurt.com
Cargando experiencia
flutter · 15 de agosto de 2026 · 6 min

Clean Architecture en Flutter: cómo organizamos data, domain y presentation con get_it y Cubit

Cómo estructuramos proyectos Flutter medianos y grandes por feature, con Cubit para el estado e inyección de dependencias con get_it. Con código, criterios y los errores que rompen la separación en la práctica.

Por Equipo Tuurt

Clean Architecture en Flutter: cómo organizamos data, domain y presentation con get_it y Cubit

Cuando un proyecto Flutter pasa de dos pantallas a quince, la pregunta de "dónde va este código" deja de tener una respuesta obvia. Sin una estructura explícita, la lógica de negocio termina dentro de los StatefulWidget, las llamadas a la API se mezclan con la construcción del árbol de widgets, y cualquier cambio en el backend obliga a tocar código de presentación que no debería saber que ese backend existe. Clean Architecture, aplicada con disciplina y sin dogmatismo, es la forma en que evitamos ese punto de inflexión.

Esto no es una recomendación universal. En un prototipo de una semana o una app de una sola pantalla, montar tres capas por feature es sobrecoste puro. La regla que usamos es simple: si el proyecto va a vivir más de unos pocos meses, va a tener más de un desarrollador tocándolo, o va a necesitar tests automatizados serios, la separación paga su costo casi de inmediato. Si no, no la impongas.

La estructura de carpetas

Organizamos por feature, no por tipo de archivo. Cada feature es una unidad autocontenida con sus tres capas:

lib/
  features/
    auth/
      data/
        datasources/
          auth_remote_datasource.dart
        models/
          user_model.dart
        repositories/
          auth_repository_impl.dart
      domain/
        entities/
          user.dart
        repositories/
          auth_repository.dart
        usecases/
          login_usecase.dart
      presentation/
        cubit/
          auth_cubit.dart
          auth_state.dart
        pages/
          login_page.dart
        widgets/
          login_form.dart
  core/
    di/
      injection_container.dart
    error/
      failures.dart
    network/
      api_client.dart

La razón de organizar por feature y no por capa (lib/models/, lib/repositories/, lib/screens/ a nivel raíz) es que casi ningún cambio real toca todas las features a la vez. Cuando alguien modifica el flujo de login, todo lo relevante está en features/auth/. La alternativa por capa obliga a saltar entre carpetas lejanas para seguir un solo flujo de negocio, y en un equipo de más de dos personas eso se traduce en conflictos de merge que no tienen nada que ver entre sí conviviendo en el mismo archivo.

Domain: el centro que no depende de nada

La capa domain es la única que no importa nada de Flutter ni de ningún paquete externo salvo utilidades puras (dartz para Either, por ejemplo, si se usa manejo funcional de errores). Contiene:

Entidades: clases planas que representan el modelo de negocio, sin anotaciones de serialización.

class User {
  final String id;
  final String email;
  final String displayName;

  const User({
    required this.id,
    required this.email,
    required this.displayName,
  });
}

Interfaces de repositorio: el contrato que domain espera, sin decir cómo se cumple.

abstract class AuthRepository {
  Future<Either<Failure, User>> login(String email, String password);
  Future<Either<Failure, void>> logout();
  Stream<User?> get authStateChanges;
}

Esta interfaz es la pieza que hace que domain no sepa si los datos vienen de REST, GraphQL, Firebase o una base de datos local. Esa decisión vive un nivel más afuera, en data.

Casos de uso: cuando la lógica de negocio de una acción tiene más de una línea, la aislamos en su propia clase en vez de dejarla suelta en el Cubit.

class LoginUseCase {
  final AuthRepository repository;
  const LoginUseCase(this.repository);

  Future<Either<Failure, User>> call(String email, String password) {
    return repository.login(email, password);
  }
}

En casos donde el caso de uso es un simple passthrough al repositorio, como el de arriba, es razonable discutir si vale la pena la capa extra. Nuestro criterio: si el caso de uso hoy es trivial pero es razonable esperar que crezca (validaciones, orquestación de más de un repositorio, reglas de negocio propias), lo dejamos. Si es y va a seguir siendo un passthrough puro, el Cubit puede llamar al repositorio directamente y ahorramos una clase.

Data: la implementación concreta

data implementa las interfaces que domain definió. Un modelo extiende la entidad y añade lo que domain no necesita saber: serialización.

class UserModel extends User {
  const UserModel({
    required super.id,
    required super.email,
    required super.displayName,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      email: json['email'] as String,
      displayName: json['display_name'] as String,
    );
  }
}

La implementación del repositorio traduce entre la fuente de datos y el contrato de domain, y es el único lugar donde las excepciones de red se convierten en Failure del dominio:

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  const AuthRepositoryImpl(this.remoteDataSource);

  @override
  Future<Either<Failure, User>> login(String email, String password) async {
    try {
      final user = await remoteDataSource.login(email, password);
      return Right(user);
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    } on SocketException {
      return Left(NetworkFailure());
    }
  }

  @override
  Stream<User?> get authStateChanges => remoteDataSource.authStateChanges;

  @override
  Future<Either<Failure, void>> logout() async {
    await remoteDataSource.logout();
    return const Right(null);
  }
}

Ese try/catch es la frontera. A partir de aquí, ninguna capa superior vuelve a ver una SocketException: solo ve un Failure, que el Cubit sabe interpretar.

Presentation: Cubit y widgets

Elegimos Cubit sobre Bloc completo para la mayoría de features porque casi ninguna necesita el patrón evento-a-evento explícito de Bloc; con emit directo desde métodos alcanza y el código queda más corto sin perder testabilidad.

class AuthCubit extends Cubit<AuthState> {
  final LoginUseCase loginUseCase;
  AuthCubit(this.loginUseCase) : super(AuthInitial());

  Future<void> login(String email, String password) async {
    emit(AuthLoading());
    final result = await loginUseCase(email, password);
    result.fold(
      (failure) => emit(AuthError(failure.message)),
      (user) => emit(AuthAuthenticated(user)),
    );
  }
}

El widget solo consume estado, sin conocer cómo se obtuvo:

BlocProvider(
  create: (_) => sl<AuthCubit>(),
  child: BlocBuilder<AuthCubit, AuthState>(
    builder: (context, state) {
      if (state is AuthLoading) return const CircularProgressIndicator();
      if (state is AuthError) return ErrorBanner(message: state.message);
      return LoginForm(
        onSubmit: (email, pass) => context.read<AuthCubit>().login(email, pass),
      );
    },
  ),
)

Inyección de dependencias con get_it

Usamos get_it como service locator, con un único punto de registro por feature, invocado desde main():

final sl = GetIt.instance;

Future<void> initAuthFeature() async {
  // Presentation
  sl.registerFactory(() => AuthCubit(sl()));

  // Domain
  sl.registerLazySingleton(() => LoginUseCase(sl()));

  // Data
  sl.registerLazySingleton<AuthRepository>(() => AuthRepositoryImpl(sl()));
  sl.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(sl()),
  );
}

La distinción entre registerFactory y registerLazySingleton importa: los Cubits se registran como factory porque cada BlocProvider necesita su propia instancia con su propio ciclo de vida, mientras que repositorios y datasources son singletons porque no tienen estado de UI y no hay razón para recrearlos.

Dónde falla si se hace mal

El error más común que hemos visto no es no tener capas: es tenerlas de nombre pero romper la dirección de la dependencia. Un import de Flutter Material dentro de un archivo en domain/, un Cubit que construye directamente un objeto http.Client en vez de recibir el repositorio por inyección, un modelo de data que domain importa directamente en vez de trabajar contra la entidad. Cada una de esas grietas parece inofensiva la primera vez y hace que, seis meses después, cambiar el backend de REST a GraphQL implique tocar quince archivos de presentation que nunca deberían haberse enterado del cambio.

La disciplina real no está en el diagrama de tres capas. Está en revisar, en cada pull request, si alguna importación cruzó en la dirección equivocada.

flutter clean-architecture cubit mobile
← Volver al blog