As a Flutter project grows, without a proper architecture the codebase becomes complex and unmanageable. You try to add a feature, but every change breaks something else. Tests don't get written because the code is tightly coupled. Clean Architecture turns this chaos into an organized, testable structure.
In this guide, we'll adapt Robert C. Martin's (Uncle Bob) Clean Architecture principles to the Flutter world. With real-world examples and production-ready patterns, you'll discover the power of layered architecture.
Note: The architecture in this guide has been used successfully in many large-scale Flutter projects (100K+ users).
Table of Contents
- What Is Clean Architecture?
- Layers and Responsibilities
- Domain Layer
- Data Layer
- Presentation Layer
- Dependency Injection
- Error Handling Strategy
- Project Structure
- Real-World Example
- Testing Strategies
- Best Practices
- Conclusion and Recommendations
1. What Is Clean Architecture?
Clean Architecture is an architectural approach that separates software systems into independent layers. Its core principle: inner layers must remain unaware of outer layers.
Layer Comparison
Layer | Responsibility | Dependency | Change Frequency |
|---|---|---|---|
Domain | Business rules, entities | Nothing | Rare |
Data | Data access, API, cache | Domain | Moderate |
Presentation | UI, state management | Domain | Frequent |
Core | Shared utilities | Nothing | Rare |
SOLID Principles
- Single Responsibility: Every class has a single responsibility
- Open/Closed: Open for extension, closed for modification
- Liskov Substitution: Subclasses must be substitutable for their base class
- Interface Segregation: Small, specific interfaces
- Dependency Inversion: Depend on abstractions, not on concretions
2. Layers and Responsibilities
1lib/2 core/3 error/4 exceptions.dart5 failures.dart6 network/7 network_info.dart8 usecases/9 usecase.dart10 utils/11 input_converter.dart12 features/13 auth/14 domain/15 entities/16 user.dart17 repositories/18 auth_repository.dart19 usecases/20 login.dart21 register.dart22 logout.dart23 data/24 models/25 user_model.dart26 datasources/27 auth_remote_datasource.dart28 auth_local_datasource.dart29 repositories/30 auth_repository_impl.dart31 presentation/32 providers/33 auth_provider.dart34 pages/35 login_page.dart36 register_page.dart37 widgets/38 login_form.dart3. Domain Layer
The domain layer is the heart of the application. It has no outer dependencies — it is pure Dart code.
Entity
1// domain/entities/user.dart2class User {3 final String id;4 final String name;5 final String email;6 final DateTime createdAt;7 final UserRole role;8 9 const User({10 required this.id,11 required this.name,12 required this.email,13 required this.createdAt,14 this.role = UserRole.user,15 });16}17 18enum UserRole { user, admin, moderator }Repository Interface (Abstraction)
1// domain/repositories/auth_repository.dart2import 'package:dartz/dartz.dart';3 4abstract class AuthRepository {5 Future<Either<Failure, User>> login(String email, String password);6 Future<Either<Failure, User>> register(String name, String email, String password);7 Future<Either<Failure, void>> logout();8 Future<Either<Failure, User>> getCurrentUser();9 Stream<User?> get authStateChanges;10}Use Case
1// core/usecases/usecase.dart2import 'package:dartz/dartz.dart';3 4abstract class UseCase<Type, Params> {5 Future<Either<Failure, Type>> call(Params params);6}7 8class NoParams {9 const NoParams();10}11 12// domain/usecases/login.dart13class LoginUseCase implements UseCase<User, LoginParams> {14 final AuthRepository repository;15 16 LoginUseCase(this.repository);17 18 @override19 Future<Either<Failure, User>> call(LoginParams params) {20 return repository.login(params.email, params.password);21 }22}23 24class LoginParams {25 final String email;26 final String password;27 28 const LoginParams({required this.email, required this.password});29}Easter Egg
Gizli bir bilgi buldun!
Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?
4. Data Layer
The data layer makes the abstractions in the domain layer concrete:
Model (JSON conversion of the Entity)
1// data/models/user_model.dart2class UserModel extends User {3 const UserModel({4 required super.id,5 required super.name,6 required super.email,7 required super.createdAt,8 super.role,9 });10 11 factory UserModel.fromJson(Map<String, dynamic> json) {12 return UserModel(13 id: json['id'] as String,14 name: json['name'] as String,15 email: json['email'] as String,16 createdAt: DateTime.parse(json['created_at'] as String),17 role: UserRole.values.firstWhere(18 (r) => r.name == json['role'],19 orElse: () => UserRole.user,20 ),21 );22 }23 24 Map<String, dynamic> toJson() {25 return {26 'id': id,27 'name': name,28 'email': email,29 'created_at': createdAt.toIso8601String(),30 'role': role.name,31 };32 }33}DataSource
1// data/datasources/auth_remote_datasource.dart2abstract class AuthRemoteDataSource {3 Future<UserModel> login(String email, String password);4 Future<UserModel> register(String name, String email, String password);5 Future<void> logout();6}7 8class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {9 final HttpClient client;10 11 AuthRemoteDataSourceImpl({required this.client});12 13 @override14 Future<UserModel> login(String email, String password) async {15 final response = await client.post(16 '/auth/login',17 body: {'email': email, 'password': password},18 );19 20 if (response.statusCode == 200) {21 return UserModel.fromJson(response.data);22 } else {23 throw ServerException(message: response.message);24 }25 }26 27 @override28 Future<UserModel> register(String name, String email, String password) async {29 final response = await client.post(30 '/auth/register',31 body: {'name': name, 'email': email, 'password': password},32 );33 34 if (response.statusCode == 201) {35 return UserModel.fromJson(response.data);36 } else {37 throw ServerException(message: response.message);38 }39 }40 41 @override42 Future<void> logout() async {43 await client.post('/auth/logout');44 }45}Repository Implementation
1// data/repositories/auth_repository_impl.dart2import 'package:dartz/dartz.dart';3 4class AuthRepositoryImpl implements AuthRepository {5 final AuthRemoteDataSource remoteDataSource;6 final AuthLocalDataSource localDataSource;7 final NetworkInfo networkInfo;8 9 AuthRepositoryImpl({10 required this.remoteDataSource,11 required this.localDataSource,12 required this.networkInfo,13 });14 15 @override16 Future<Either<Failure, User>> login(String email, String password) async {17 if (await networkInfo.isConnected) {18 try {19 final user = await remoteDataSource.login(email, password);20 await localDataSource.cacheUser(user);21 return Right(user);22 } on ServerException catch (e) {23 return Left(ServerFailure(message: e.message));24 }25 } else {26 return const Left(NetworkFailure(message: 'No internet connection'));27 }28 }29 30 @override31 Future<Either<Failure, User>> getCurrentUser() async {32 try {33 final cachedUser = await localDataSource.getCachedUser();34 if (cachedUser != null) return Right(cachedUser);35 return const Left(CacheFailure(message: 'No cached user found'));36 } on CacheException catch (e) {37 return Left(CacheFailure(message: e.message));38 }39 }40 41 // other methods...42 @override43 Future<Either<Failure, User>> register(44 String name, String email, String password) async {45 if (await networkInfo.isConnected) {46 try {47 final user = await remoteDataSource.register(name, email, password);48 await localDataSource.cacheUser(user);49 return Right(user);50 } on ServerException catch (e) {51 return Left(ServerFailure(message: e.message));52 }53 } else {54 return const Left(NetworkFailure(message: 'No internet connection'));55 }56 }57 58 @override59 Future<Either<Failure, void>> logout() async {60 try {61 await remoteDataSource.logout();62 await localDataSource.clearCache();63 return const Right(null);64 } catch (e) {65 return Left(ServerFailure(message: e.toString()));66 }67 }68 69 @override70 Stream<User?> get authStateChanges => localDataSource.watchUser();71}5. Presentation Layer
1// presentation/providers/auth_provider.dart2import 'package:flutter_riverpod/flutter_riverpod.dart';3 4class AuthState {5 final User? user;6 final bool isLoading;7 final String? errorMessage;8 9 const AuthState({this.user, this.isLoading = false, this.errorMessage});10 11 AuthState copyWith({User? user, bool? isLoading, String? errorMessage}) {12 return AuthState(13 user: user ?? this.user,14 isLoading: isLoading ?? this.isLoading,15 errorMessage: errorMessage,16 );17 }18}19 20class AuthNotifier extends StateNotifier<AuthState> {21 final LoginUseCase _loginUseCase;22 final LogoutUseCase _logoutUseCase;23 24 AuthNotifier({25 required LoginUseCase loginUseCase,26 required LogoutUseCase logoutUseCase,27 }) : _loginUseCase = loginUseCase,28 _logoutUseCase = logoutUseCase,29 super(const AuthState());30 31 Future<void> login(String email, String password) async {32 state = state.copyWith(isLoading: true, errorMessage: null);33 34 final result = await _loginUseCase(35 LoginParams(email: email, password: password),36 );37 38 result.fold(39 (failure) => state = state.copyWith(40 isLoading: false,41 errorMessage: failure.message,42 ),43 (user) => state = state.copyWith(44 isLoading: false,45 user: user,46 ),47 );48 }49 50 Future<void> logout() async {51 await _logoutUseCase(const NoParams());52 state = const AuthState();53 }54}6. Dependency Injection
1// injection_container.dart2import 'package:get_it/get_it.dart';3 4final sl = GetIt.instance;5 6Future<void> initDependencies() async {7 // Core8 sl.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl());9 sl.registerLazySingleton<HttpClient>(() => DioHttpClient());10 11 // Auth Feature12 // Data Sources13 sl.registerLazySingleton<AuthRemoteDataSource>(14 () => AuthRemoteDataSourceImpl(client: sl()),15 );16 sl.registerLazySingleton<AuthLocalDataSource>(17 () => AuthLocalDataSourceImpl(),18 );19 20 // Repository21 sl.registerLazySingleton<AuthRepository>(22 () => AuthRepositoryImpl(23 remoteDataSource: sl(),24 localDataSource: sl(),25 networkInfo: sl(),26 ),27 );28 29 // Use Cases30 sl.registerLazySingleton(() => LoginUseCase(sl()));31 sl.registerLazySingleton(() => LogoutUseCase(sl()));32 sl.registerLazySingleton(() => RegisterUseCase(sl()));33}7. Error Handling Strategy
1// core/error/failures.dart2abstract class Failure {3 final String message;4 const Failure({required this.message});5}6 7class ServerFailure extends Failure {8 const ServerFailure({required super.message});9}10 11class NetworkFailure extends Failure {12 const NetworkFailure({required super.message});13}14 15class CacheFailure extends Failure {16 const CacheFailure({required super.message});17}18 19class ValidationFailure extends Failure {20 final Map<String, String> fieldErrors;21 const ValidationFailure({required super.message, this.fieldErrors = const {}});22}23 24// core/error/exceptions.dart25class ServerException implements Exception {26 final String message;27 final int? statusCode;28 const ServerException({required this.message, this.statusCode});29}30 31class CacheException implements Exception {32 final String message;33 const CacheException({required this.message});34}8. Testing Strategies
Clean Architecture Test Pyramid
Test Type | Layer | Tools | Speed |
|---|---|---|---|
Unit Test | Domain (UseCase) | mockito, dartz | Very Fast |
Unit Test | Data (Repository) | mockito, fake | Fast |
Widget Test | Presentation | flutter_test | Moderate |
Integration Test | All layers | integration_test | Slow |
1// test/features/auth/domain/usecases/login_test.dart2import 'package:flutter_test/flutter_test.dart';3import 'package:mockito/mockito.dart';4import 'package:dartz/dartz.dart';5 6class MockAuthRepository extends Mock implements AuthRepository {}7 8void main() {9 late LoginUseCase useCase;10 late MockAuthRepository mockRepository;11 12 setUp(() {13 mockRepository = MockAuthRepository();14 useCase = LoginUseCase(mockRepository);15 });16 17 const testUser = User(18 id: '1',19 name: 'Test User',20 email: '[email protected]',21 createdAt: DateTime(2024, 1, 1),22 );23 24 test('should return the user when login succeeds', () async {25 when(mockRepository.login('[email protected]', 'password123'))26 .thenAnswer((_) async => const Right(testUser));27 28 final result = await useCase(29 const LoginParams(email: '[email protected]', password: 'password123'),30 );31 32 expect(result, const Right(testUser));33 verify(mockRepository.login('[email protected]', 'password123'));34 verifyNoMoreInteractions(mockRepository);35 });36 37 test('should return a failure when login fails', () async {38 when(mockRepository.login('[email protected]', 'wrong'))39 .thenAnswer((_) async => const Left(40 ServerFailure(message: 'Wrong password'),41 ));42 43 final result = await useCase(44 const LoginParams(email: '[email protected]', password: 'wrong'),45 );46 47 expect(result, isA<Left>());48 });49}9. Best Practices
- Use a feature-first folder structure (not layer-first)
- Each feature should have its own domain/data/presentation folders
- Handle errors with the Either type (instead of throwing exceptions)
- A UseCase should always do exactly one thing
- The Repository interface belongs in domain, the implementation in data
Conclusion and Recommendations
Clean Architecture delivers long-term maintainability and testability in Flutter projects. It means writing more code up front, but that investment pays for itself many times over as the project grows.
ALTIN İPUCU
Bu yazının en değerli bilgisi
Bu ipucu, yazının en önemli çıkarımını içeriyor.
Okuyucu Ödülü
Congratulations! Since you read this article all the way through, I have something special for you:
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

