Implementing Clean Architecture in Flutter Apps
Discover how to structure your Flutter applications using Clean Architecture principles. Build maintainable, testable, and scalable mobile apps that stand the test of time.
ποΈ What is Clean Architecture?
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that emphasizes separation of concerns and dependency inversion. It creates systems that are independent of frameworks, UI, databases, and external agencies.
π― Core Principles
π Dependency Rule
Source code dependencies can only point inwards. Inner layers cannot know about outer layers.
π― Single Responsibility
Each module should have one reason to change and one responsibility.
π Independence
Business logic is independent of UI, database, and external services.
π§ͺ Testability
Business rules can be tested without UI, database, or external elements.
π Architecture Layers
1. Domain Layer (Entities & Use Cases)
The innermost layer containing business entities and use cases. This layer contains the most general and high-level rules.
// Domain Entity
class User {
final String id;
final String name;
final String email;
final DateTime createdAt;
const User({
required this.id,
required this.name,
required this.email,
required this.createdAt,
});
}
// Use Case
class GetUserUseCase {
final UserRepository repository;
GetUserUseCase(this.repository);
Future<Either<Failure, User>> call(String userId) {
return repository.getUser(userId);
}
}
2. Data Layer
Contains repositories implementations, data sources, and models. This layer is responsible for data retrieval and storage.
// Repository Implementation
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
final UserLocalDataSource localDataSource;
UserRepositoryImpl(this.remoteDataSource, this.localDataSource);
@override
Future<Either<Failure, User>> getUser(String userId) async {
try {
final userModel = await remoteDataSource.getUser(userId);
await localDataSource.cacheUser(userModel);
return Right(userModel.toEntity());
} catch (e) {
return Left(ServerFailure());
}
}
}
3. Presentation Layer
Contains UI components, state management (BLoC), and presentation logic. This is where Flutter widgets live.
// BLoC (Presentation Logic)
class UserBloc extends Bloc<UserEvent, UserState> {
final GetUserUseCase getUserUseCase;
UserBloc(this.getUserUseCase) : super(UserInitial()) {
on<GetUserRequested>(_onGetUserRequested);
}
void _onGetUserRequested(GetUserRequested event, Emitter<UserState> emit) async {
emit(UserLoading());
final result = await getUserUseCase(event.userId);
result.fold(
(failure) => emit(UserError(failure.message)),
(user) => emit(UserLoaded(user)),
);
}
}
π Project Structure
Here's how to organize your Flutter project following Clean Architecture:
lib/
βββ core/
β βββ error/
β β βββ exceptions.dart
β β βββ failures.dart
β βββ network/
β β βββ network_info.dart
β βββ usecases/
β β βββ usecase.dart
β βββ utils/
β βββ constants.dart
β βββ input_validator.dart
βββ features/
β βββ user/
β βββ data/
β β βββ datasources/
β β β βββ user_local_data_source.dart
β β β βββ user_remote_data_source.dart
β β βββ models/
β β β βββ user_model.dart
β β βββ repositories/
β β βββ user_repository_impl.dart
β βββ domain/
β β βββ entities/
β β β βββ user.dart
β β βββ repositories/
β β β βββ user_repository.dart
β β βββ usecases/
β β βββ get_user.dart
β βββ presentation/
β βββ bloc/
β β βββ user_bloc.dart
β β βββ user_event.dart
β β βββ user_state.dart
β βββ pages/
β β βββ user_page.dart
β βββ widgets/
β βββ user_widget.dart
βββ injection_container.dart
π§ Implementation Steps
Step 1: Define Domain Entities
Start with your business entities - these are pure Dart classes with no dependencies:
// lib/features/user/domain/entities/user.dart
import 'package:equatable/equatable.dart';
class User extends Equatable {
final String id;
final String name;
final String email;
final String profilePicture;
const User({
required this.id,
required this.name,
required this.email,
required this.profilePicture,
});
@override
List<Object?> get props => [id, name, email, profilePicture];
}
Step 2: Create Repository Contracts
Define abstract repositories in the domain layer:
// lib/features/user/domain/repositories/user_repository.dart
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/user.dart';
abstract class UserRepository {
Future<Either<Failure, User>> getUser(String userId);
Future<Either<Failure, List<User>>> getUsers();
Future<Either<Failure, void>> updateUser(User user);
}
Step 3: Implement Use Cases
Create specific use cases for each business operation:
// lib/features/user/domain/usecases/get_user.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/user.dart';
import '../repositories/user_repository.dart';
class GetUser implements UseCase<User, GetUserParams> {
final UserRepository repository;
GetUser(this.repository);
@override
Future<Either<Failure, User>> call(GetUserParams params) async {
return await repository.getUser(params.userId);
}
}
class GetUserParams extends Equatable {
final String userId;
const GetUserParams({required this.userId});
@override
List<Object> get props => [userId];
}
Step 4: Create Data Models
Implement data models that extend domain entities:
// lib/features/user/data/models/user_model.dart
import '../../domain/entities/user.dart';
class UserModel extends User {
const UserModel({
required super.id,
required super.name,
required super.email,
required super.profilePicture,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
name: json['name'],
email: json['email'],
profilePicture: json['profile_picture'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'profile_picture': profilePicture,
};
}
}
π§ͺ Testing Strategy
Clean Architecture makes testing much easier. Here's how to test each layer:
Unit Tests for Use Cases
void main() {
late GetUser usecase;
late MockUserRepository mockUserRepository;
setUp(() {
mockUserRepository = MockUserRepository();
usecase = GetUser(mockUserRepository);
});
test('should get user from repository', () async {
// arrange
const testUser = User(id: '1', name: 'Test', email: 'test@example.com');
when(mockUserRepository.getUser(any))
.thenAnswer((_) async => const Right(testUser));
// act
final result = await usecase(const GetUserParams(userId: '1'));
// assert
expect(result, const Right(testUser));
verify(mockUserRepository.getUser('1'));
});
}
π¦ Dependency Injection
Use a service locator pattern (like GetIt) to manage dependencies:
// lib/injection_container.dart
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
final sl = GetIt.instance;
Future<void> init() async {
// Features
// Bloc
sl.registerFactory(() => UserBloc(sl()));
// Use cases
sl.registerLazySingleton(() => GetUser(sl()));
// Repository
sl.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(sl(), sl()),
);
// Data sources
sl.registerLazySingleton<UserRemoteDataSource>(
() => UserRemoteDataSourceImpl(sl()),
);
// External
sl.registerLazySingleton(() => http.Client());
}
β Benefits of Clean Architecture
π§ͺ Testability
Easy to write unit tests for business logic without external dependencies.
π§ Maintainability
Clear separation of concerns makes code easier to understand and modify.
π Scalability
Easy to add new features without affecting existing code.
π Flexibility
Easy to swap implementations (e.g., change from REST API to GraphQL).
β οΈ Common Pitfalls
- Over-engineering: Don't use Clean Architecture for simple apps
- Too many layers: Keep it simple, don't add unnecessary abstraction
- Mixing concerns: Keep business logic out of presentation layer
- Ignoring the dependency rule: Always respect the inward-pointing dependencies
π Conclusion
Clean Architecture provides a solid foundation for building maintainable, testable Flutter applications. While it requires more initial setup, the long-term benefits in terms of code quality, testability, and maintainability are significant.
Start small and gradually adopt Clean Architecture principles as your application grows. Focus on separation of concerns and dependency inversion, and you'll build apps that can evolve with changing requirements.