Flutter Clean Architecture: A Practical Guide That Actually Scales
Most Flutter apps start clean and end up as a pile of StatefulWidgets calling Firebase directly from initState. It works, until it doesn't: a screen needs the same data twice, a test suite needs a fake API, or a junior dev breaks checkout while fixing a typo in the profile screen. Clean Architecture, adapted from Robert C. Martin's original concept, gives Flutter teams a way to grow past that point without a rewrite.
This isn't a theoretical exercise. It's the structure I reach for on any Flutter codebase expected to live longer than a hackathon weekend.
The three layers
Clean Architecture in Flutter usually collapses into three practical layers instead of the four or five you see in backend diagrams.
The presentation layer holds widgets and state management — Bloc, Riverpod, or Provider, whichever your team prefers. It knows how to render state and dispatch events, and nothing else.
The domain layer is pure Dart. No package:flutter, no HTTP client, no SDK imports. It contains entities (plain data classes), use cases (single-purpose classes like GetUserUseCase), and repository interfaces — abstract contracts that describe what data operations exist without saying how they're implemented.
The data layer implements those repository interfaces. It talks to REST APIs, GraphQL endpoints, local databases, or third-party SDKs, and maps raw responses into the entities the domain layer defined.
The rule that makes this work: dependencies point inward. Presentation depends on domain. Data depends on domain. Domain depends on nothing. This is what makes the domain layer unit-testable without a single mock of Flutter itself, and what lets you swap Firebase for a REST backend without touching a single widget.
A concrete example
Say you're building a "get current user" feature.
// domain/entities/user_entity.dart
class UserEntity {
final String id;
final String name;
final String email;
const UserEntity({required this.id, required this.name, required this.email});
}
// domain/repositories/user_repository.dart
abstract class UserRepository {
Future<UserEntity> getUser(String id);
}
// domain/usecases/get_user_usecase.dart
class GetUserUseCase {
final UserRepository repository;
GetUserUseCase(this.repository);
Future<UserEntity> call(String id) => repository.getUser(id);
}The data layer implements the contract:
// data/models/user_model.dart
class UserModel extends UserEntity {
UserModel({required super.id, required super.name, required super.email});
factory UserModel.fromJson(Map<String, dynamic> json) => UserModel(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
// data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
final ApiClient client;
UserRepositoryImpl(this.client);
@override
Future<UserEntity> getUser(String id) async {
final response = await client.get('/users/$id');
return UserModel.fromJson(response.data);
}
}And the presentation layer only ever talks to the use case, never the repository or the API client directly:
class UserCubit extends Cubit<UserState> {
final GetUserUseCase getUserUseCase;
UserCubit(this.getUserUseCase) : super(UserInitial());
Future<void> loadUser(String id) async {
emit(UserLoading());
try {
final user = await getUserUseCase(id);
emit(UserLoaded(user));
} catch (e) {
emit(UserError(e.toString()));
}
}
}Notice what's missing: the Cubit has never heard of ApiClient, JSON parsing, or HTTP status codes. Swap the backend from a REST API to Supabase and only user_repository_impl.dart changes.
Dependency injection ties it together
None of this works without a DI mechanism to wire concrete implementations into abstract interfaces at runtime. get_it combined with injectable is the most common pairing in production Flutter codebases:
final sl = GetIt.instance;
void setupLocator() {
sl.registerLazySingleton<ApiClient>(() => ApiClient());
sl.registerLazySingleton<UserRepository>(() => UserRepositoryImpl(sl()));
sl.registerFactory(() => GetUserUseCase(sl()));
sl.registerFactory(() => UserCubit(sl()));
}Now UserCubit is created with a real UserRepositoryImpl in production and a FakeUserRepository in tests, with zero changes to the Cubit itself.
Where teams overdo it
Clean Architecture has a real failure mode: applying it uniformly to every feature, including the ones that will never need it. A settings screen that toggles dark mode does not need an entity, a use case, and a repository interface. Treat the layering as a tool for genuine complexity — features with business logic, multiple data sources, or a need for test coverage — not a checklist to satisfy on every file.
The other common mistake is putting business logic in the presentation layer "just this once." Once one Cubit calls an API client directly, the boundary is gone, and the next developer will copy that pattern because it's now precedent.
Is it worth the boilerplate?
For a prototype or a solo weekend project, no — a Provider and a service class will get you there faster. For a team of three or more engineers working on the same codebase for over six months, the extra files pay for themselves the first time someone needs to write a widget test without spinning up a real network call, or the first time the backend team changes an API shape and you only need to touch one file to absorb it.
The pattern isn't about following Uncle Bob's diagram exactly. It's about making sure business rules don't know or care where the data came from — and that discipline is what keeps a Flutter codebase maintainable past the six-month mark.