Email copiado — support@tuurt.com
Cargando experiencia
flutter · August 15, 2026 · 5 min

Clean Architecture in Flutter: how we split data, domain, and presentation with get_it and Cubit

How we structure mid-to-large Flutter projects by feature, using Cubit for state and get_it for dependency injection. With code, decision criteria, and the mistakes that break the separation in practice.

By Tuurt Team

Clean Architecture in Flutter: how we split data, domain, and presentation with get_it and Cubit

When a Flutter project grows from two screens to fifteen, "where does this code belong" stops having an obvious answer. Without an explicit structure, business logic ends up inside StatefulWidgets, API calls get tangled with widget-tree construction, and a backend change forces you to touch presentation code that had no business knowing that backend existed. Clean Architecture, applied with discipline and without dogma, is how we avoid hitting that wall.

This isn't a universal recommendation. For a one-week prototype or a single-screen app, wiring up three layers per feature is pure overhead. Our rule of thumb: if the project will live longer than a few months, will have more than one developer touching it, or needs serious automated tests, the separation pays for itself almost immediately. If not, skip it.

The folder structure

We organize by feature, not by file type. Each feature is a self-contained unit with its own three layers:

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

The reason to organize by feature instead of by layer (a root-level lib/models/, lib/repositories/, lib/screens/) is that almost no real change touches every feature at once. When someone modifies the login flow, everything relevant lives under features/auth/. Organizing by layer forces you to jump between distant folders to follow a single business flow, and on a team of more than two people that turns into merge conflicts between unrelated pieces of work colliding in the same file.

Domain: the core that depends on nothing

The domain layer is the only one that imports nothing from Flutter and no external packages besides pure utilities (dartz for Either, for instance, if you use functional error handling). It contains:

Entities: plain classes representing the business model, with no serialization annotations.

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

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

Repository interfaces: the contract domain expects, without saying how it gets fulfilled.

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

This interface is what keeps domain ignorant of whether the data comes from REST, GraphQL, Firebase, or a local database. That decision lives one layer out, in data.

Use cases: when the business logic behind an action is more than a one-liner, we isolate it in its own class instead of leaving it loose inside the Cubit.

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

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

In cases where the use case is a plain passthrough to the repository, like the one above, it's fair to question whether the extra layer is worth it. Our criterion: if the use case is trivial today but is reasonably likely to grow (validation, orchestrating more than one repository, its own business rules), we keep it. If it is and will stay a pure passthrough, the Cubit can call the repository directly and we save a class.

Data: the concrete implementation

data implements the interfaces domain defined. A model extends the entity and adds what domain doesn't need to know about: serialization.

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,
    );
  }
}

The repository implementation translates between the data source and domain's contract, and it's the only place where network exceptions get converted into a domain Failure:

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);
  }
}

That try/catch is the boundary. From here on, no upper layer ever sees a SocketException again — only a Failure, which the Cubit knows how to interpret.

Presentation: Cubit and widgets

We pick Cubit over full Bloc for most features because hardly any of them need Bloc's explicit event-to-event pattern; emitting directly from methods gets the job done with less code and no loss of testability.

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)),
    );
  }
}

The widget only consumes state, with no idea how it was obtained:

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),
      );
    },
  ),
)

Dependency injection with get_it

We use get_it as a service locator, with a single registration entry point per feature, invoked from 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()),
  );
}

The distinction between registerFactory and registerLazySingleton matters: Cubits are registered as factories because each BlocProvider needs its own instance with its own lifecycle, while repositories and data sources are singletons because they hold no UI state and there's no reason to recreate them.

Where it breaks if done wrong

The most common mistake we've seen isn't skipping the layers — it's having them in name while breaking the direction of the dependency. A Flutter Material import inside a file under domain/, a Cubit that builds an http.Client directly instead of receiving the repository through injection, a data model that domain imports directly instead of working against the entity. Each of those cracks looks harmless the first time, and six months later, switching the backend from REST to GraphQL means touching fifteen presentation files that should never have known the change was coming.

The real discipline isn't in the three-layer diagram. It's in checking, on every pull request, whether an import crossed in the wrong direction.

flutter clean-architecture cubit mobile
← Back to blog