Clean Architecture in Flutter

Clean Architecture in Flutter

Sufi Aurangzeb Hossain
July 8, 2025
17 min read
Flutter

Clean Architecture is a software design philosophy that emphasizes separation of concerns, testability, and maintainability. This approach organizes your Flutter application into distinct layers with clear boundaries and dependencies flowing inward, creating a robust and scalable codebase.

Implementing Clean Architecture in Flutter

Clean Architecture is a software design philosophy that emphasizes separation of concerns, testability, and maintainability. This approach organizes your Flutter application into distinct layers with clear boundaries and dependencies flowing inward, creating a robust and scalable codebase.

Core Principles of Clean Architecture

Understanding the fundamental principles that guide Clean Architecture implementation.

Key Principles:

  • Framework Independence: Business logic should not depend on any external framework
  • Testability: Each layer should be independently testable without UI or database dependencies
  • UI Independence: Business rules should work without any UI changes
  • Database Independence: Business rules should not depend on database implementation
  • External Agency Independence: Business logic should not depend on external services

Project Structure Organization

Organize your Flutter project into clearly defined layers following Clean Architecture principles.

lib/
├── core/
│   ├── constants/
│   ├── errors/
│   ├── network/
│   ├── use_cases/
│   └── utils/
├── domain/
│   ├── entities/
│   ├── repositories/
│   └── use_cases/
├── data/
│   ├── models/
│   ├── repositories_impl/
│   └── data_sources/
└── presentation/
    ├── pages/
    ├── widgets/
    ├── bloc/
    └── providers/

Domain Layer - The Innermost Circle

The domain layer contains enterprise-wide business rules and entities, completely independent of external concerns.

// entities/user_entity.dart
class UserEntity {
  final String id;
  final String name;
  final String email;
  final DateTime createdAt;
  
  UserEntity({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });
  
  @override
  bool operator ==(Object other) {
    return identical(this, other) ||
        other is UserEntity &&
        runtimeType == other.runtimeType &&
        id == other.id;
  }
  
  @override
  int get hashCode => id.hashCode;
}

// repositories/user_repository.dart
abstract class UserRepository {
  Future<UserEntity> getUserById(String userId);
  Future<List<UserEntity>> getAllUsers();
  Future<void> saveUser(UserEntity user);
  Future<void> deleteUser(String userId);
}

// use_cases/get_user_usecase.dart
class GetUserUseCase {
  final UserRepository repository;
  
  GetUserUseCase(this.repository);
  
  Future<UserEntity> execute(String userId) {
    return repository.getUserById(userId);
  }
}

Data Layer - Implementation Details

The data layer implements the domain contracts and handles data operations from various sources.

// models/user_model.dart
class UserModel {
  final String id;
  final String name;
  final String email;
  final String createdAt;
  
  UserModel({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });
  
  // Convert to Domain Entity
  UserEntity toEntity() {
    return UserEntity(
      id: id,
      name: name,
      email: email,
      createdAt: DateTime.parse(createdAt),
    );
  }
  
  // Convert from JSON
  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'],
      name: json['name'],
      email: json['email'],
      createdAt: json['created_at'],
    );
  }
  
  // Convert to JSON
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'created_at': createdAt,
    };
  }
}

// repositories_impl/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remoteDataSource;
  final UserLocalDataSource localDataSource;
  
  UserRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
  });
  
  @override
  Future<UserEntity> getUserById(String userId) async {
    try {
      // Try local first, then remote
      final localUser = await localDataSource.getUserById(userId);
      return localUser.toEntity();
    } catch (_) {
      final remoteUser = await remoteDataSource.getUserById(userId);
      await localDataSource.saveUser(remoteUser);
      return remoteUser.toEntity();
    }
  }
  
  @override
  Future<List<UserEntity>> getAllUsers() async {
    final users = await remoteDataSource.getAllUsers();
    return users.map((user) => user.toEntity()).toList();
  }
  
  @override
  Future<void> saveUser(UserEntity user) async {
    final userModel = UserModel(
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.createdAt.toIso8601String(),
    );
    await localDataSource.saveUser(userModel);
  }
  
  @override
  Future<void> deleteUser(String userId) async {
    await localDataSource.deleteUser(userId);
  }
}

Presentation Layer - UI and State Management

The presentation layer handles UI components and state management, completely separate from business logic.

// bloc/user_bloc.dart
class UserBloc extends Bloc<UserEvent, UserState> {
  final GetUserUseCase getUserUseCase;
  final GetAllUsersUseCase getAllUsersUseCase;
  
  UserBloc({
    required this.getUserUseCase,
    required this.getAllUsersUseCase,
  }) : super(UserInitial()) {
    on<FetchUserEvent>((event, emit) async {
      emit(UserLoading());
      try {
        final user = await getUserUseCase.execute(event.userId);
        emit(UserLoaded(user: user));
      } catch (e) {
        emit(UserError(message: e.toString()));
      }
    });
    
    on<FetchAllUsersEvent>((event, emit) async {
      emit(UsersLoading());
      try {
        final users = await getAllUsersUseCase.execute();
        emit(UsersLoaded(users: users));
      } catch (e) {
        emit(UserError(message: e.toString()));
      }
    });
  }
}

// states/user_state.dart
abstract class UserState {
  const UserState();
}

class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
  final UserEntity user;
  const UserLoaded({required this.user});
}
class UsersLoading extends UserState {}
class UsersLoaded extends UserState {
  final List<UserEntity> users;
  const UsersLoaded({required this.users});
}
class UserError extends UserState {
  final String message;
  const UserError({required this.message});
}

// events/user_event.dart
abstract class UserEvent {
  const UserEvent();
}

class FetchUserEvent extends UserEvent {
  final String userId;
  const FetchUserEvent(this.userId);
}

class FetchAllUsersEvent extends UserEvent {
  const FetchAllUsersEvent();
}

Dependency Injection Setup

Configure dependency injection to manage dependencies between layers effectively.

// injection_container.dart
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

Future<void> init() async {
  // External
  await initExternalDependencies();
  
  // Data Layer
  initDataLayer();
  
  // Domain Layer
  initDomainLayer();
  
  // Presentation Layer
  initPresentationLayer();
}

Future<void> initExternalDependencies() async {
  // Database, Network, etc.
  final database = await initializeDatabase();
  getIt.registerLazySingleton(() => database);
}

void initDataLayer() {
  // Data Sources
  getIt.registerLazySingleton<UserRemoteDataSource>(
    () => UserRemoteDataSourceImpl(client: getIt()),
  );
  getIt.registerLazySingleton<UserLocalDataSource>(
    () => UserLocalDataSourceImpl(database: getIt()),
  );
  
  // Repository Implementations
  getIt.registerLazySingleton<UserRepository>(
    () => UserRepositoryImpl(
      remoteDataSource: getIt(),
      localDataSource: getIt(),
    ),
  );
}

void initDomainLayer() {
  // Use Cases
  getIt.registerLazySingleton(() => GetUserUseCase(getIt()));
  getIt.registerLazySingleton(() => GetAllUsersUseCase(getIt()));
  getIt.registerLazySingleton(() => SaveUserUseCase(getIt()));
}

void initPresentationLayer() {
  // BLoCs
  getIt.registerFactory(
    () => UserBloc(
      getUserUseCase: getIt(),
      getAllUsersUseCase: getIt(),
    ),
  );
}

Testing Strategy

Implement comprehensive testing for each layer to ensure code quality and reliability.

// test/domain/use_cases/get_user_usecase_test.dart
void main() {
  late GetUserUseCase useCase;
  late MockUserRepository mockUserRepository;
  
  setUp(() {
    mockUserRepository = MockUserRepository();
    useCase = GetUserUseCase(mockUserRepository);
  });
  
  const testUserId = '1';
  const testUser = UserEntity(
    id: '1',
    name: 'Test User',
    email: 'test@example.com',
    createdAt: DateTime(2023, 1, 1),
  );
  
  test('should get user from repository', () async {
    // arrange
    when(mockUserRepository.getUserById(testUserId))
        .thenAnswer((_) async => testUser);
    
    // act
    final result = await useCase.execute(testUserId);
    
    // assert
    expect(result, equals(testUser));
    verify(mockUserRepository.getUserById(testUserId));
    verifyNoMoreInteractions(mockUserRepository);
  });
}

// test/presentation/bloc/user_bloc_test.dart
void main() {
  late UserBloc bloc;
  late MockGetUserUseCase mockGetUserUseCase;
  
  setUp(() {
    mockGetUserUseCase = MockGetUserUseCase();
    bloc = UserBloc(
      getUserUseCase: mockGetUserUseCase,
      getAllUsersUseCase: MockGetAllUsersUseCase(),
    );
  });
  
  test('initial state should be UserInitial', () {
    expect(bloc.state, equals(UserInitial()));
  });
  
  test('should emit [UserLoading, UserLoaded] when data is fetched successfully', () {
    // arrange
    when(mockGetUserUseCase.execute(any))
        .thenAnswer((_) async => const UserEntity(id: '1', name: 'Test', email: 'test@test.com', createdAt: DateTime.now()));
    
    // assert later
    expectLater(
      bloc.stream,
      emitsInOrder([
        UserLoading(),
        UserLoaded(user: const UserEntity(id: '1', name: 'Test', email: 'test@test.com', createdAt: DateTime.now())),
      ]),
    );
    
    // act
    bloc.add(FetchUserEvent('1'));
  });
}

Benefits and Advantages

Understand the significant advantages of implementing Clean Architecture in your Flutter projects.

Development Benefits:

  • Maintainability: Clear separation makes code easier to understand and modify
  • Testability: Each layer can be tested independently
  • Scalability: New features can be added without affecting existing code
  • Team Collaboration: Multiple developers can work on different layers simultaneously
  • Framework Independence: Business logic remains unchanged during framework updates

Business Benefits:

  • Reduced Bugs: Clear boundaries prevent unintended side effects
  • Faster Development: Reusable components accelerate feature development
  • Easier Onboarding: Clear structure helps new team members understand the codebase
  • Long-term Sustainability: Architecture supports application growth and evolution

Common Patterns and Best Practices

Follow these established patterns to ensure successful Clean Architecture implementation.

Architecture Patterns:

  • Use *Repository Pattern* for data abstraction
  • Implement *Use Cases* for business logic encapsulation
  • Apply *Dependency Inversion* principle for loose coupling
  • Follow *Single Responsibility* principle in each layer
  • Use *Data Transfer Objects* (DTOs) for layer communication

Code Organization:

  • Keep domain entities pure and framework-agnostic
  • Use abstract classes for repository contracts
  • Implement proper error handling across layers
  • Maintain consistent naming conventions
  • Document layer boundaries and dependencies

By adopting Clean Architecture in your Flutter applications, you create a foundation for sustainable, testable, and maintainable code that can evolve with your business needs while maintaining high code quality and developer productivity.