Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ahmedyehya92/0257809d6fbd3047e408869f3d747a2c to your computer and use it in GitHub Desktop.
Save ahmedyehya92/0257809d6fbd3047e408869f3d747a2c to your computer and use it in GitHub Desktop.

Flutter Clean Architecture Implementation Guide

Overview

This document provides comprehensive guidelines for implementing a Flutter project following Clean Architecture principles. The project structure follows a modular approach with clear separation of concerns, making the codebase maintainable, testable, and scalable.

lib/
├── core/
│   ├── database/             
│   │   ├── app_database.dart 
│   │   ├── migrations/       
│   │   └── daos/              
│   ├── widgets/
│   │   └── common_widgets.dart
│   ├── error/
│   │   ├── error_handling.dart
│   │   └── exceptions.dart
│   ├── theme/
│   │   └── app_theme.dart
│   ├── loaders/
│   │   └── loading_widget.dart
│   ├── network/
│   │   ├── dio_client.dart
│   │   ├── api_endpoints.dart
│   │   ├── interceptors/
│   │   │   ├── auth_interceptor.dart
│   │   │   └── error_interceptor.dart
│   │   └── base_urls.dart
│   ├── di/
│   │   ├── dependency_injection.dart
│   │   └── service_locator.dart
│   └── bloc/
│       ├── app_bloc_observer.dart
│       └── bloc_providers.dart
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   ├── repositories/
│   │   │   │   └── auth_repository_impl.dart
│   │   │   ├── datasources/
│   │   │   │   ├── remote/
│   │   │   │   │   ├── auth_remote_datasource.dart   
│   │   │   │   │   └── auth_end_points.dart
│   │   │   │   ├── local/
│   │   │   │   │   ├── auth_local_datasource.dart
│   │   │   │   │   └── auth_daos/
│   │   │   └── models/
│   │   │       ├── user_model.dart
│   │   │       └── auth_response_model.dart
│   │   ├── domain/
│   │   │   ├── repositories/
│   │   │   │   └── auth_repository.dart
│   │   │   ├── entities/
│   │   │   │   └── user.dart
│   │   │   └── usecases/
│   │   │       ├── login_usecase.dart
│   │   │       └── register_usecase.dart
│   │   └── presentation/
│   │       ├── pages/
│   │       │   ├── login_page.dart
│   │       │   └── register_page.dart
│   │       ├── bloc/
│   │       │   ├── auth_bloc.dart
│   │       │   ├── auth_event.dart
│   │       │   └── auth_state.dart
│   │       └── widgets/
│   │           └── auth_form.dart
├── main.dart
└── config/
    ├── routes.dart
    ├── env.dart
    └── app_config.dart

Required Libraries

dependencies:
  flutter:
    sdk: flutter
  # State Management
  flutter_bloc: ^8.1.3  # BLoC pattern implementation
  
  # Networking
  dio: ^5.3.3  # HTTP client
  
  # Dependency Injection
  get_it: ^7.6.4  # Service locator
  injectable: ^2.3.2  # Code generation for dependency injection
  
  # Routing
  go_router: ^12.1.1  # Declarative routing
  
  # Code Generation
  freezed: ^2.4.5  # Immutable models, union types, and pattern matching
  freezed_annotation: ^2.4.1
  
  # Local Storage
  floor: ^1.4.2  # SQLite abstraction
  sqflite: ^2.3.0  # SQLite database

dev_dependencies:
  flutter_test:
    sdk: flutter
  # Code Generation Tools
  build_runner: ^2.4.6  # Code generation runner
  injectable_generator: ^2.4.1  # Injectable code generator
  freezed_generator: ^2.4.5  # Freezed code generator
  floor_generator: ^1.4.2  # Floor code generator

Core Architecture Principles

  1. Separation of Concerns: Each layer has a specific responsibility
  2. Dependency Rule: Dependencies always point inward (domain ← data ← presentation)
  3. Abstraction: High-level modules don't depend on low-level implementations
  4. Testability: Each component can be tested in isolation
  5. Modularity: Features are encapsulated and independent
  6. Immutability: State is immutable, enforced with Freezed
  7. Reactive Programming: Leveraging BLoC pattern with flutter_bloc
  8. Dependency Injection: Clean inversion of control with injectable and get_it

Project Structure Breakdown

1. Core Layer

The core layer contains project-wide utilities, configurations, and base implementations.

1.1 Database

core/database/
  • app_database.dart: Contains the database configuration, initialization, and instance management
  • migrations/: Contains database schema migration files
  • daos/: Data Access Objects for database operations

Implementation Guidelines:

  • Use Floor for type-safe SQLite database operations with code generation
  • Define entities with @entity annotations and DAOs with @dao annotations
  • Create a centralized AppDatabase class with @Database annotation
  • Implement migrations as separate classes with version numbers
  • Use callback methods for database initialization and migrations
  • Database initialization should be done at app startup via dependency injection
  • Register database as a singleton in the dependency injection container

1.2 Widgets

core/widgets/
  • common_widgets.dart: Reusable widgets used across the application

Implementation Guidelines:

  • Extract commonly used widgets to avoid duplication
  • Each widget should have proper documentation and named parameters
  • Make widgets customizable with sensible defaults

1.3 Error Handling

core/error/
  • error_handling.dart: Global error handling utilities
  • exceptions.dart: Custom exception classes

Implementation Guidelines:

  • Create domain-specific exceptions that extend a base exception
  • Implement a central error handler that converts exceptions to user-friendly messages
  • Define error codes and messages for consistent error reporting

1.4 Theme

core/theme/
  • app_theme.dart: Application theme configuration

Implementation Guidelines:

  • Define a consistent color palette, typography, and component styles
  • Use ThemeData and ThemeExtensions for custom theme properties
  • Create light/dark theme variations

1.5 Loaders

core/loaders/
  • loading_widget.dart: Loading indicators and placeholders

Implementation Guidelines:

  • Create consistent loading indicators with animation
  • Consider shimmer effects for content placeholders
  • Implement stateful loading widget with different states (loading, error, empty)

1.6 Network

core/network/
  • dio_client.dart: HTTP client configuration
  • api_endpoints.dart: Central API endpoint definitions
  • interceptors/: Network request/response interceptors
  • base_urls.dart: Environment-specific API base URLs

Implementation Guidelines:

  • Configure Dio with appropriate timeouts, headers, and error handling
  • Implement interceptors for authentication, logging, and error handling
  • Use environment variables for different base URLs (dev, staging, prod)

1.7 Dependency Injection

core/di/
  • dependency_injection.dart: DI configuration
  • service_locator.dart: Service locator implementation

Implementation Guidelines:

  • Use GetIt with Injectable for automated service location and dependency injection
  • Create injectable modules for different layers or features
  • Use annotations (@injectable, @lazySingleton, @singleton) for clear registration
  • Organize registrations by feature or layer
  • Implement lazy initialization where appropriate
  • Use environment-specific configurations with @Environment annotations

1.8 Bloc

core/bloc/
  • app_bloc_observer.dart: Global BLoC event observation
  • bloc_providers.dart: Global BLoC providers

Implementation Guidelines:

  • Create a custom BlocObserver for logging and analytics
  • Centralize global BLoC providers for app-wide state
  • Follow consistent naming conventions for events, states, and blocs

2. Features Layer

Each feature follows the same internal structure based on Clean Architecture.

Example: Authentication Feature

features/auth/
2.1 Data Layer
features/auth/data/
  • repositories/: Implements domain repositories
  • datasources/: Remote and local data sources
  • models/: Data models (DTOs)

Implementation Guidelines:

  • Use Freezed for immutable data models with copyWith, equality, and toString methods
  • Generate JSON serialization/deserialization with Freezed annotations
  • Data models should extend or implement domain entities
  • Repositories should implement interfaces defined in the domain layer
  • Remote data sources should use Dio for API calls
  • Local data sources should use Floor for database operations
  • Clear separation between remote and local data sources
  • Transform data models to domain entities before returning from repositories
2.2 Domain Layer
features/auth/domain/
  • repositories/: Repository interfaces
  • entities/: Domain entities (business objects)
  • usecases/: Business logic units

Implementation Guidelines:

  • Entities should be pure Dart classes without dependencies
  • Use cases should follow single responsibility principle
  • Repository interfaces should define clear contracts
  • Domain layer should have no dependencies on Flutter or external packages
  • Use Either<Failure, Success> return type for error handling
2.3 Presentation Layer
features/auth/presentation/
  • pages/: UI screens
  • bloc/: State management
  • widgets/: Feature-specific widgets

Implementation Guidelines:

  • Pages should be thin and delegate logic to BLoCs
  • BLoCs should communicate with domain layer via use cases
  • Follow a consistent pattern for BLoC events and states
  • Extract reusable widgets within the feature
  • Use dependency injection to provide dependencies

3. Application Layer

lib/
  • main.dart: Application entry point
  • config/: Application configuration

Implementation Guidelines:

  • Keep main.dart minimal, delegating to a proper App widget
  • Use go_router for declarative routing with typed routes and path parameters
  • Create a central router configuration in config/routes.dart
  • Implement environment-specific configurations
  • Use Injectable.init() to initialize global services and dependencies before runApp()
  • Configure different environments (dev, staging, prod) in app_config.dart

Best Practices

  1. Naming Conventions

    • Files: snake_case
    • Classes: PascalCase
    • Variables and methods: camelCase
    • Constants: UPPER_CASE or kConstantName
  2. File Organization

    • One class per file for maintainability
    • Group related files in appropriate directories
    • Import order: Dart SDK → Flutter → External packages → Project imports
  3. Testing Strategy

    • Unit tests for domain and data layers
    • Widget tests for UI components
    • Integration tests for feature flows
    • Golden tests for UI verification
  4. Error Handling

    • Use Result or Either pattern for error handling
    • Implement proper error reporting
    • Graceful degradation of features
  5. State Management

    • Consistent use of BLoC pattern
    • Clear events and states
    • Immutable state objects
  6. Code Quality

    • Use linting (flutter_lints or very_good_analysis)
    • Implement CI/CD for code quality checks
    • Regular code reviews
    • Documentation for public APIs

Implementation Example: Authentication Feature

Repository Implementation

// features/auth/data/repositories/auth_repository_impl.dart
@Injectable(as: AuthRepository)
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));
      } on DioException catch (e) {
        return Left(ServerFailure(e.message ?? 'Server error occurred'));
      }
    } else {
      return Left(NetworkFailure('No internet connection'));
    }
  }
}

UseCase Implementation

// features/auth/domain/usecases/login_usecase.dart
@injectable
class LoginUseCase implements UseCase<User, LoginParams> {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  @override
  Future<Either<Failure, User>> call(LoginParams params) {
    return repository.login(params.email, params.password);
  }
}

@freezed
class LoginParams with _$LoginParams {
  const factory LoginParams({
    required String email,
    required String password,
  }) = _LoginParams;
}

BLoC Implementation

// features/auth/presentation/bloc/auth_event.dart
@freezed
class AuthEvent with _$AuthEvent {
  const factory AuthEvent.loginRequested({
    required String email,
    required String password,
  }) = LoginRequested;
  
  const factory AuthEvent.registerRequested({
    required String email,
    required String password,
    required String name,
  }) = RegisterRequested;
  
  const factory AuthEvent.logoutRequested() = LogoutRequested;
}

// features/auth/presentation/bloc/auth_state.dart
@freezed
class AuthState with _$AuthState {
  const factory AuthState.initial() = AuthInitial;
  const factory AuthState.loading() = AuthLoading;
  const factory AuthState.authenticated(User user) = AuthAuthenticated;
  const factory AuthState.error(String message) = AuthError;
}

// features/auth/presentation/bloc/auth_bloc.dart
@injectable
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase _loginUseCase;
  final RegisterUseCase _registerUseCase;
  final LogoutUseCase _logoutUseCase;

  AuthBloc({
    required LoginUseCase loginUseCase,
    required RegisterUseCase registerUseCase,
    required LogoutUseCase logoutUseCase,
  })  : _loginUseCase = loginUseCase,
        _registerUseCase = registerUseCase,
        _logoutUseCase = logoutUseCase,
        super(const AuthState.initial()) {
    on<LoginRequested>(_onLoginRequested);
    on<RegisterRequested>(_onRegisterRequested);
    on<LogoutRequested>(_onLogoutRequested);
  }

  Future<void> _onLoginRequested(
    LoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthState.loading());
    
    final result = await _loginUseCase(
      LoginParams(email: event.email, password: event.password),
    );
    
    emit(result.fold(
      (failure) => AuthState.error(failure.message),
      (user) => AuthState.authenticated(user),
    ));
  }
  
  // Other event handlers...
}

UI Implementation

// features/auth/presentation/pages/login_page.dart
@RoutePage()
class LoginPage extends StatelessWidget {
  const LoginPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: BlocProvider(
        create: (context) => getIt<AuthBloc>(),
        child: BlocConsumer<AuthBloc, AuthState>(
          listener: (context, state) {
            state.maybeWhen(
              authenticated: (user) {
                // Using GoRouter for navigation
                context.goNamed('home');
              },
              error: (message) {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text(message)),
                );
              },
              orElse: () {}, // Do nothing for other states
            );
          },
          builder: (context, state) {
            return state.maybeWhen(
              loading: () => const Center(child: LoadingWidget()),
              orElse: () => const LoginForm(),
            );
          },
        ),
      ),
    );
  }
}

// features/auth/presentation/widgets/login_form.dart
class LoginForm extends StatefulWidget {
  const LoginForm({Key? key}) : super(key: key);

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextFormField(
              controller: _emailController,
              decoration: const InputDecoration(labelText: 'Email'),
              keyboardType: TextInputType.emailAddress,
              validator: (value) {
                if (value == null || value.isEmpty) {
                  return 'Please enter your email';
                }
                return null;
              },
            ),
            const SizedBox(height: 16),
            TextFormField(
              controller: _passwordController,
              decoration: const InputDecoration(labelText: 'Password'),
              obscureText: true,
              validator: (value) {
                if (value == null || value.isEmpty) {
                  return 'Please enter your password';
                }
                return null;
              },
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                if (_formKey.currentState!.validate()) {
                  context.read<AuthBloc>().add(
                    AuthEvent.loginRequested(
                      email: _emailController.text,
                      password: _passwordController.text,
                    ),
                  );
                }
              },
              child: const Text('Login'),
            ),
          ],
        ),
      ),
    );
  }
}

Dependency Injection Setup

Using Injectable for automated dependency registration:

// core/di/dependency_injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';

import 'dependency_injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit(
  initializerName: 'init', // default
  preferRelativeImports: true, // default
  asExtension: false, // default
)
Future<void> configureDependencies() => init(getIt);
// core/di/injectable_module.dart
@module
abstract class InjectableModule {
  // External dependencies that can't be annotated
  @lazySingleton
  Dio get dio => _configureDio();
  
  @singleton
  @preResolve
  Future<AppDatabase> get database => _initializeDatabase();
  
  @lazySingleton
  InternetConnectionChecker get internetConnectionChecker => 
      InternetConnectionChecker();
      
  // Private helper methods
  Dio _configureDio() {
    final dio = Dio(BaseOptions(
      baseUrl: AppConfig.instance.apiBaseUrl,
      connectTimeout: const Duration(seconds: 5),
      receiveTimeout: const Duration(seconds: 3),
    ));
    
    dio.interceptors.addAll([
      AuthInterceptor(),
      ErrorInterceptor(),
      LogInterceptor(requestBody: true, responseBody: true),
    ]);
    
    return dio;
  }
  
  Future<AppDatabase> _initializeDatabase() async {
    return await $FloorAppDatabase
        .databaseBuilder('app_database.db')
        .addMigrations([
          Migration(1, 2, (database) async {
            // Migration implementation
          }),
        ])
        .build();
  }
}

And the generated code will handle the rest of the registrations based on annotations:

// Some examples of annotated classes for auto-registration

// Repositories
@Injectable(as: AuthRepository)
class AuthRepositoryImpl implements AuthRepository { ... }

// Data sources
@LazySingleton(as: AuthRemoteDataSource)
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { ... }

@LazySingleton(as: AuthLocalDataSource)
class AuthLocalDataSourceImpl implements AuthLocalDataSource { ... }

// Use cases
@injectable
class LoginUseCase { ... }

@injectable
class RegisterUseCase { ... }

// BLoCs
@injectable
class AuthBloc extends Bloc<AuthEvent, AuthState> { ... }

In main.dart:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Initialize dependency injection
  await configureDependencies();
  
  runApp(MyApp());
}

## Adding New Features

When adding a new feature to the application, follow these steps:

1. **Create Feature Structure**:
   features/feature_name/
   ├── data/
   │   ├── repositories/
   │   ├── datasources/
   │   └── models/
   ├── domain/
   │   ├── repositories/
   │   ├── entities/
   │   └── usecases/
   └── presentation/
       ├── pages/
       ├── bloc/
       └── widgets/
  1. Define Domain Layer:

    • Create entity classes with pure Dart (no dependencies)
    • Define repository interfaces with clear method contracts
    • Implement use cases for business logic
  2. Implement Data Layer:

    • Create data models with Freezed (@freezed annotation)
    • Implement repository interfaces
    • Set up remote data sources with Dio
    • Set up local data sources with Floor
  3. Create Presentation Layer:

    • Define events and states with Freezed
    • Implement BLoC classes with flutter_bloc
    • Create UI components and pages
    • Connect UI to BLoC
  4. Register Dependencies:

    • Add @injectable annotations to new classes
    • Run build_runner to generate dependency injection code
  5. Add Routes:

    • Update the router configuration in config/routes.dart
    • Add @RoutePage() annotations to page classes if using auto_route
  6. Run Code Generation:

    flutter pub run build_runner build --delete-conflicting-outputs

Code Generation Workflow

This architecture relies heavily on code generation. The general workflow is:

  1. Write class definitions with appropriate annotations
  2. Run build_runner to generate code
  3. Import and use the generated code
# Run once
flutter pub run build_runner build --delete-conflicting-outputs

# Watch for changes
flutter pub run build_runner watch --delete-conflicting-outputs

Conclusion

This architecture provides a solid foundation for building scalable Flutter applications using modern libraries and best practices. By following these guidelines, developers can create maintainable codebases that are easy to test and extend.

The key benefits of this approach are:

  • Scalability: New features can be added without affecting existing code
  • Testability: Each component can be tested in isolation
  • Maintainability: Clear separation of concerns makes the codebase easier to understand
  • Productivity: Code generation reduces boilerplate and potential errors

Remember that architecture should serve the project's needs, so adapt these guidelines as necessary for your specific requirements. The key is consistency in implementation across the codebase.

@Jujubalandia
Copy link

nice work, thanks!

@adamsmaka
Copy link

👏👏👏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment