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.