All Articles
CategoryFlutter
Reading Time
25 min read
Published
2025-07-25
Word Count
1,895words

Grab a coffee — this one is a deep dive!

Flutter Clean Architecture: A Layered Architecture Guide

Summary

Applying Clean Architecture in Flutter projects. Domain, Data, and Presentation layers, Dependency Injection, the Repository pattern, and testing strategies.

  • Clean Architecture splits into Domain/Data/Presentation layers; inner layers stay unaware of outer ones.
  • The domain layer is pure Dart code; the Repository interface lives in domain, its implementation in data.
  • UseCase classes each manage a single piece of business logic using the Either<Failure, Type> pattern (dartz package).
  • Dependency Injection is done with get_it; the test pyramid covers unit, widget, and integration layers.
Flutter Clean Architecture: A Layered Architecture Guide

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


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

swift
1lib/
2 core/
3 error/
4 exceptions.dart
5 failures.dart
6 network/
7 network_info.dart
8 usecases/
9 usecase.dart
10 utils/
11 input_converter.dart
12 features/
13 auth/
14 domain/
15 entities/
16 user.dart
17 repositories/
18 auth_repository.dart
19 usecases/
20 login.dart
21 register.dart
22 logout.dart
23 data/
24 models/
25 user_model.dart
26 datasources/
27 auth_remote_datasource.dart
28 auth_local_datasource.dart
29 repositories/
30 auth_repository_impl.dart
31 presentation/
32 providers/
33 auth_provider.dart
34 pages/
35 login_page.dart
36 register_page.dart
37 widgets/
38 login_form.dart

3. Domain Layer

The domain layer is the heart of the application. It has no outer dependencies — it is pure Dart code.

Entity

dart
1// domain/entities/user.dart
2class 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)

dart
1// domain/repositories/auth_repository.dart
2import '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

dart
1// core/usecases/usecase.dart
2import '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.dart
13class LoginUseCase implements UseCase<User, LoginParams> {
14 final AuthRepository repository;
15 
16 LoginUseCase(this.repository);
17 
18 @override
19 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)

dart
1// data/models/user_model.dart
2class 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

dart
1// data/datasources/auth_remote_datasource.dart
2abstract 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 @override
14 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 @override
28 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 @override
42 Future<void> logout() async {
43 await client.post('/auth/logout');
44 }
45}

Repository Implementation

dart
1// data/repositories/auth_repository_impl.dart
2import '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 @override
16 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 @override
31 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 @override
43 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 @override
59 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 @override
70 Stream<User?> get authStateChanges => localDataSource.watchUser();
71}

5. Presentation Layer

dart
1// presentation/providers/auth_provider.dart
2import '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

dart
1// injection_container.dart
2import 'package:get_it/get_it.dart';
3 
4final sl = GetIt.instance;
5 
6Future<void> initDependencies() async {
7 // Core
8 sl.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl());
9 sl.registerLazySingleton<HttpClient>(() => DioHttpClient());
10 
11 // Auth Feature
12 // Data Sources
13 sl.registerLazySingleton<AuthRemoteDataSource>(
14 () => AuthRemoteDataSourceImpl(client: sl()),
15 );
16 sl.registerLazySingleton<AuthLocalDataSource>(
17 () => AuthLocalDataSourceImpl(),
18 );
19 
20 // Repository
21 sl.registerLazySingleton<AuthRepository>(
22 () => AuthRepositoryImpl(
23 remoteDataSource: sl(),
24 localDataSource: sl(),
25 networkInfo: sl(),
26 ),
27 );
28 
29 // Use Cases
30 sl.registerLazySingleton(() => LoginUseCase(sl()));
31 sl.registerLazySingleton(() => LogoutUseCase(sl()));
32 sl.registerLazySingleton(() => RegisterUseCase(sl()));
33}

7. Error Handling Strategy

dart
1// core/error/failures.dart
2abstract 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.dart
25class 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
dart
1// test/features/auth/domain/usecases/login_test.dart
2import '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

#Flutter#Clean Architecture#SOLID#Dart#Design Patterns#Testing
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

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.

Share