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
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
- Separation of Concerns: Each layer has a specific responsibility
- Dependency Rule: Dependencies always point inward (domain ← data ← presentation)
- Abstraction: High-level modules don't depend on low-level implementations
- Testability: Each component can be tested in isolation
- Modularity: Features are encapsulated and independent
- Immutability: State is immutable, enforced with Freezed
- Reactive Programming: Leveraging BLoC pattern with flutter_bloc
- Dependency Injection: Clean inversion of control with injectable and get_it
The core layer contains project-wide utilities, configurations, and base implementations.
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
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
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
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
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)
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)
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
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
Each feature follows the same internal structure based on Clean Architecture.
features/auth/
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
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
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
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
-
Naming Conventions
- Files: snake_case
- Classes: PascalCase
- Variables and methods: camelCase
- Constants: UPPER_CASE or kConstantName
-
File Organization
- One class per file for maintainability
- Group related files in appropriate directories
- Import order: Dart SDK → Flutter → External packages → Project imports
-
Testing Strategy
- Unit tests for domain and data layers
- Widget tests for UI components
- Integration tests for feature flows
- Golden tests for UI verification
-
Error Handling
- Use Result or Either pattern for error handling
- Implement proper error reporting
- Graceful degradation of features
-
State Management
- Consistent use of BLoC pattern
- Clear events and states
- Immutable state objects
-
Code Quality
- Use linting (flutter_lints or very_good_analysis)
- Implement CI/CD for code quality checks
- Regular code reviews
- Documentation for public APIs
// 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'));
}
}
}
// 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;
}
// 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...
}
// 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'),
),
],
),
),
);
}
}
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/
-
Define Domain Layer:
- Create entity classes with pure Dart (no dependencies)
- Define repository interfaces with clear method contracts
- Implement use cases for business logic
-
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
-
Create Presentation Layer:
- Define events and states with Freezed
- Implement BLoC classes with flutter_bloc
- Create UI components and pages
- Connect UI to BLoC
-
Register Dependencies:
- Add @injectable annotations to new classes
- Run build_runner to generate dependency injection code
-
Add Routes:
- Update the router configuration in config/routes.dart
- Add @RoutePage() annotations to page classes if using auto_route
-
Run Code Generation:
flutter pub run build_runner build --delete-conflicting-outputs
This architecture relies heavily on code generation. The general workflow is:
- Write class definitions with appropriate annotations
- Run build_runner to generate code
- 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
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.
nice work, thanks!