Clean Architecture in Flutter: Building Scalable Applications
# Clean Architecture in Flutter: Building Scalable Applications
Clean Architecture is more than a folder structure. It is a mindset that keeps your business logic independent from UI frameworks, databases, and third-party services. In Flutter projects that grow beyond a handful of screens, adopting Clean Architecture pays for itself within weeks.
Why Clean Architecture?
Every Flutter developer has experienced the moment when a "small change" cascades through dozens of files. A new API field breaks a widget. A state management swap forces you to rewrite half the app. Clean Architecture prevents these situations by enforcing strict boundaries between layers.
The Three Core Layers
1. Domain Layer (Innermost)
This is the heart of your application. It contains entities, use cases, and repository contracts. It has zero dependencies on Flutter, packages, or any external library. Pure Dart only.
2. Data Layer
This layer implements the repository contracts from the domain layer. It knows how to fetch data from APIs, read from local databases, and map raw responses into domain entities.
3. Presentation Layer (Outermost)
Widgets, pages, state management (BLoC, Riverpod, Cubit, etc.), and anything Flutter-specific lives here. This layer depends on the domain layer but never directly on the data layer.
Project Folder Structure
Here is the folder structure I use in production Flutter projects. Each feature gets its own self-contained directory:
lib/
core/
error/
exceptions.dart
failures.dart
network/
network_info.dart
usecases/
usecase.dart
di/
injection_container.dart
features/
authentication/
data/
datasources/
auth_remote_data_source.dart
auth_local_data_source.dart
models/
user_model.dart
repositories/
auth_repository_impl.dart
domain/
entities/
user.dart
repositories/
auth_repository.dart
usecases/
login_user.dart
register_user.dart
get_current_user.dart
presentation/
bloc/
auth_bloc.dart
auth_event.dart
auth_state.dart
pages/
login_page.dart
register_page.dart
widgets/
auth_form.dart
products/
data/
...
domain/
...
presentation/
...The `core/` directory holds shared utilities. Each `features/` subdirectory mirrors the three-layer structure. This keeps related code close together and makes it easy to delete or extract an entire feature.
Code Examples for Each Layer
Entity (Domain Layer)
Entities are plain Dart classes. No annotations, no framework dependencies.
class User {
final String id;
final String email;
final String displayName;
final DateTime createdAt;
const User({
required this.id,
required this.email,
required this.displayName,
required this.createdAt,
});
bool get isProfileComplete => displayName.isNotEmpty;
}Repository Contract (Domain Layer)
An abstract class that defines what operations exist without saying how they work. Notice the return type uses `Either` from the dartz package to handle errors explicitly.
import class="code-string">'package:dartz/dartz.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, User>> register(String email, String password, String name);
Future<Either<Failure, User>> getCurrentUser();
Future<Either<Failure, void>> logout();
}Use Case (Domain Layer)
Each use case does exactly one thing. This makes testing trivial and keeps business rules discoverable.
class LoginUser {
final AuthRepository repository;
LoginUser(this.repository);
Future<Either<Failure, User>> call(LoginParams params) {
return repository.login(params.email, params.password);
}
}
class LoginParams {
final String email;
final String password;
const LoginParams({required this.email, required this.password});
}Model (Data Layer)
Models extend or map to entities and handle JSON serialization. They are the bridge between raw API data and clean domain objects.
class UserModel {
final String id;
final String email;
final String displayName;
final DateTime createdAt;
const UserModel({
required this.id,
required this.email,
required this.displayName,
required this.createdAt,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json[class="code-string">'id'] as String,
email: json[class="code-string">'email'] as String,
displayName: json[class="code-string">'display_name'] as String,
createdAt: DateTime.parse(json[class="code-string">'created_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
class="code-string">'id': id,
class="code-string">'email': email,
class="code-string">'display_name': displayName,
class="code-string">'created_at': createdAt.toIso8601String(),
};
}
User toEntity() {
return User(
id: id,
email: email,
displayName: displayName,
createdAt: createdAt,
);
}
}Data Source (Data Layer)
Data sources handle the raw I/O. Remote sources talk to APIs. Local sources talk to databases or caches.
abstract class AuthRemoteDataSource {
Future<UserModel> login(String email, String password);
Future<UserModel> register(String email, String password, String name);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final http.Client client;
AuthRemoteDataSourceImpl({required this.client});
@override
Future<UserModel> login(String email, String password) async {
final response = await client.post(
Uri.parse(class="code-string">'$baseUrl/auth/login'),
body: jsonEncode({class="code-string">'email': email, class="code-string">'password': password}),
headers: {class="code-string">'Content-Type': class="code-string">'application/json'},
);
if (response.statusCode == class="code-number">200) {
return UserModel.fromJson(jsonDecode(response.body));
} else {
throw ServerException(message: class="code-string">'Login failed');
}
}
@override
Future<UserModel> register(String email, String password, String name) async {
final response = await client.post(
Uri.parse(class="code-string">'$baseUrl/auth/register'),
body: jsonEncode({class="code-string">'email': email, class="code-string">'password': password, class="code-string">'name': name}),
headers: {class="code-string">'Content-Type': class="code-string">'application/json'},
);
if (response.statusCode == class="code-number">201) {
return UserModel.fromJson(jsonDecode(response.body));
} else {
throw ServerException(message: class="code-string">'Registration failed');
}
}
}Repository Implementation (Data Layer)
The repository implementation catches exceptions from data sources and converts them into typed failures. This is where you also coordinate between remote and local sources for caching strategies.
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
final NetworkInfo networkInfo;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, User>> login(String email, String password) async {
if (await networkInfo.isConnected) {
try {
final userModel = await remoteDataSource.login(email, password);
await localDataSource.cacheUser(userModel);
return Right(userModel.toEntity());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
} else {
return const Left(NetworkFailure(class="code-string">'No internet connection'));
}
}
}BLoC (Presentation Layer)
The presentation layer consumes use cases. It never talks to data sources or repositories directly.
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUser loginUser;
final GetCurrentUser getCurrentUser;
AuthBloc({
required this.loginUser,
required this.getCurrentUser,
}) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
final result = await loginUser(
LoginParams(email: event.email, password: event.password),
);
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(AuthAuthenticated(user)),
);
}
}Dependency Injection Setup
Dependency injection is the glue that connects all the layers without creating hard dependencies. I use `get_it` for service location and `injectable` for code generation, but the manual setup below illustrates the concept clearly.
import class="code-string">'package:get_it/get_it.dart';
final sl = GetIt.instance;
Future<void> initDependencies() async {
class=class="code-string">"code-comment">// External
sl.registerLazySingleton(() => http.Client());
sl.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl());
class=class="code-string">"code-comment">// Data Sources
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(client: sl()),
);
sl.registerLazySingleton<AuthLocalDataSource>(
() => AuthLocalDataSourceImpl(sharedPreferences: sl()),
);
class=class="code-string">"code-comment">// Repositories
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
networkInfo: sl(),
),
);
class=class="code-string">"code-comment">// Use Cases
sl.registerLazySingleton(() => LoginUser(sl()));
sl.registerLazySingleton(() => RegisterUser(sl()));
sl.registerLazySingleton(() => GetCurrentUser(sl()));
class=class="code-string">"code-comment">// BLoCs
sl.registerFactory(
() => AuthBloc(loginUser: sl(), getCurrentUser: sl()),
);
}Notice the registration order: externals first, then data sources, repositories, use cases, and finally BLoCs. BLoCs are registered as factories so each screen gets a fresh instance.
When Clean Architecture is Overkill
Not every project needs Clean Architecture. Here is an honest assessment.
Skip it when:
Adopt it when:
In my experience with production Flutter apps, the break-even point is around the third or fourth feature. Before that, the boilerplate feels excessive. After that, you start seeing returns every time you add a new feature because the patterns are already established and new code practically writes itself.
Common Architecture Mistakes
1. Leaking Framework Dependencies into the Domain Layer
Your domain layer should be pure Dart. The moment you import `package:flutter/material.dart` or any Flutter-specific package in an entity or use case, you have broken the architecture. This includes annotations from packages like `json_serializable` -- those belong on models in the data layer, not on entities.
2. Skipping the Use Case Layer
It is tempting to call repositories directly from BLoCs. This works until you need to compose multiple repository calls, add validation, or enforce business rules. Use cases are cheap to write and save you from scattering logic across presentation code.
3. Fat Repositories
A repository that handles caching, retries, logging, analytics, and error mapping is doing too much. Keep repositories focused on data coordination. Extract cross-cutting concerns into decorators or middleware.
4. One BLoC to Rule Them All
Creating a single massive BLoC per feature leads to state management nightmares. Split by screen or by responsibility. An `AuthBloc` that handles login, registration, password reset, and profile updates is four BLoCs wearing a trench coat.
5. Ignoring the Dependency Rule
Dependencies should only point inward. Presentation depends on domain. Data depends on domain. Domain depends on nothing. When your entity imports from the data layer, the architecture collapses. This is the single most important rule.
Practical Migration Guide
If you have an existing Flutter app and want to adopt Clean Architecture, here is a step-by-step approach that I have used successfully on several projects.
Step 1: Audit Your Current Code
List all your features. Identify which ones have the most business logic and the most bugs. Start with a feature that is important but not mission-critical -- you want to learn the patterns without high-stakes pressure.
Step 2: Create the Folder Structure
Set up the `core/` and `features/` directories. Create the `data/`, `domain/`, and `presentation/` subdirectories for your chosen feature. Do not move any code yet.
Step 3: Extract Entities
Find the data classes your feature uses. Create clean entity versions in the domain layer. Strip out serialization logic, annotations, and framework imports. Move serialization concerns to model classes in the data layer.
Step 4: Define Repository Contracts
Look at how your feature fetches and stores data. Write an abstract repository class in the domain layer that captures these operations. Use `Either
Step 5: Write Use Cases
For each action your feature performs, create a use case class. Even if the use case just delegates to the repository, having it there establishes the pattern and gives you a place to add business rules later.
Step 6: Implement the Repository
Create the concrete repository in the data layer. Move your existing API calls and database operations into data source classes. The repository coordinates between them.
Step 7: Wire Up Dependency Injection
Register your new classes in the DI container. Replace direct instantiation in your widgets with injected dependencies.
Step 8: Refactor the Presentation Layer
Update your widgets and state management to use the new use cases instead of calling APIs directly. This is usually the smallest change because the interface stays similar.
Step 9: Write Tests
Now that each layer is isolated, write unit tests for use cases (pure logic), repository implementations (mock the data sources), and BLoCs (mock the use cases). You will immediately notice how much easier testing has become.
Step 10: Repeat for the Next Feature
Take what you learned and apply it to the next feature. Each migration gets faster as you internalize the patterns.
In my experience with production Flutter apps, a full migration for a medium-sized app takes about 2-4 weeks when done incrementally alongside regular feature work. The key is to never stop delivering value while refactoring. Each migrated feature should ship in a working state.
Conclusion
Clean Architecture is an investment. The initial setup costs time, and the boilerplate can feel heavy at first. But for any Flutter project that will live longer than a few months, the returns compound. Testability goes up, onboarding new developers gets faster, and the fear of changing code fades away.
Start with one feature, prove the value, and expand from there. The architecture should serve your team, not the other way around.
Reach out if you want a step-by-step architecture migration plan tailored to your project.
Related Articles
Flutter State Management: Riverpod, Provider, and Bloc Comparison
Compare state management approaches in Flutter. Understand Riverpod, Provider, and Bloc with clear decision criteria for each scenario.
Dart Best Practices: Writing Clean and Maintainable Code
Learn practical Dart best practices for readability, testability, and long-term maintainability.
Flutter Testing Guide: Unit, Widget, and Integration Tests
Build a practical Flutter testing strategy using unit, widget, and integration tests with clear responsibilities.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch