A comprehensive guide to building scalable and maintainable Flutter applications using Clean Architecture and SOLID Principles.
- Flutter Clean Architecture & SOLID Principles 🚀
This project demonstrates how to structure Flutter applications using Clean Architecture and SOLID Principles. The goal is to create modular, testable, and maintainable codebases that scale with your application's growth.
Clean Architecture is a software design philosophy that promotes separation of concerns through clearly defined layers. Each layer has a specific responsibility, making the codebase modular, testable, and easier to maintain.
-
Presentation Layer
- Contains UI and state management (e.g., Cubits, Widgets, Pages).
- Responsible for displaying data and handling user interactions.
- May import domain/use-cases and core, but must NOT import feature data directly — always use repository interfaces / use-cases.
-
Domain Layer
- The heart of the application. Contains Entities, UseCases, and Repositories.
- Focuses purely on business logic, independent of frameworks.
- Must not import from data or presentation.
-
Data Layer
- Manages data sources (e.g., APIs, local databases).
- Implements repositories to provide data to the domain layer.
- May import domain to implement repository interfaces.
- Keep imports flowing inward only — Presentation -> Domain -> Data.
- Core is reusable library and should be independent of other layers but domain, data, presentation depends on core.
- Shared UI is reusable UI related library and It might depend on core but not other layers. Presentation layer depends on Shared UI.
- Framework Independence: Decouples business logic from frameworks, UI, and data sources.
- Modularity: Enables easier testing and maintenance.
- Scalability: Supports flexible and future-proof feature additions.
SOLID Principles complement Clean Architecture by providing guidelines for writing clean, maintainable, and extensible code:
-
Single Responsibility Principle (SRP)
Each class should have only one reason to change. -
Open/Closed Principle (OCP)
Classes should be open for extension but closed for modification. -
Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering program correctness. -
Interface Segregation Principle (ISP)
Classes should not be forced to implement interfaces they do not use. -
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules; both should depend on abstractions.
For more detailed information and real-world examples, see the SOLID Principles documentation.
- 🛡️ SOLID Principles: Ensures scalable, maintainable, and testable code.
- 🏗️ Clean Architecture: Divides code into layers (Data, Domain, Presentation) for clear separation of concerns.
- 🍴 Build Flavors: Supports Development, Staging, and Production environments.
- đź”§ Robust Error Handling: Comprehensive API and internal error management.
- 🔄 Automated Request/Response Handling: Includes token refreshing and request inspection.
- 📡 Core Services: Navigation, Internet, Local Database, Toast Messages, and User Credential management.
- 🎨 Reusable UI Components: Customizable themes and reusable widgets.
- ⚙️ Utilities: Screen size handling, extensions, mixins, generics, and form validation utilities.
Follow these steps to get the project up and running on your local machine.
- Flutter SDK (version 3.x.x or higher)
- An editor like VS Code or Android Studio
- An emulator or a physical device
-
Clone the repository:
git clone https://github.com/gaurishankars/Flutter-Clean-Architecture-SOLID-Principle.git cd Flutter-Clean-Architecture-SOLID-Principle -
Install dependencies:
flutter pub get
This project is configured with multiple build flavors (Development, Staging, Production). You can run them using the following commands:
- Development:
flutter run --flavor dev -t lib/main_dev.dart - Staging:
flutter run --flavor stg -t lib/main_stg.dart - Production:
flutter run --flavor prod -t lib/main.dart
lib/
├── config/
│ ├── injector/
│ └── app_config.dart
├── core/
│ ├── constants/
│ ├── data/
│ │ └── models/
│ ├── data_handling/
│ ├── data_states/
│ ├── domain/
│ │ ├── entities/
│ │ └── use_cases/
│ ├── services/
│ │ ├── api/
│ │ ├── database/
│ │ ├── image_picker/
│ │ ├── internet/
│ │ ├── navigation/
│ │ └── session/
│ ├── utils/
│ │ ├── extensions/
│ │ └── .....
│ └── app_initializer.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ ├── dashboard/
│ └── ...
├── routing/
├── shared_ui/
│ ├── cubits/
│ ├── models/
│ ├── themes/
│ ├── ui/
│ ├── utils/
│ └── application.dart
├── main_dev.dart
├── main.dart
├── main_stg.dart
This project uses Mason to generate feature templates for consistent and efficient development.
-
Activate the
mason_cliglobally:dart pub global activate mason_cli
-
Fetch the bricks for the project:
mason get
-
Generate a new feature using the
cubit_featurebrick:mason make cubit_feature -c config.json
-
Generate a new cubit and page using the
cubit_pagebrick:mason make cubit_page -c config.json
-
cubit_feature: Generates a feature template following Clean Architecture, including:- Data Layer: Data Sources, Models, Repositories
- Domain Layer: Entities, Repositories, Use Cases
- Presentation Layer: Cubits, Pages, Widgets
-
cubit_page: Generates a cubit and page template inside the specified feature's presentation layer.
The generation process relies on a config.json file, which includes details such as feature, cubit, and page names, as well as entity names and their variable types. Ensure that config.json is correctly defined before running the generation command.
This diagram highlights the modular and scalable structure of Clean Architecture, aligning with SOLID principles to ensure best development practices.
graph TD
UI -->|calls| Cubit
Cubit -->|calls| UseCase
UseCase -->|calls| Repository
Repository -->|calls| RemoteDataSource
Repository -->|calls| LocalDataSource
Repository -->|uses| InternetService
Repository -->|wrapped by| DataHandler
Repository -->|handles| DataState
RemoteDataSource -->|uses| ApiService
RemoteDataSource -->|wrapped by| DataHandler
ApiService -->|sends| API
LocalDataSource -->|uses| LocalDatabaseService
LocalDataSource -->|wrapped by| ErrorHandler
LocalDatabaseService -->|sends| LocalDB
- UI calls Cubit, which calls UseCase
- UseCase calls Repository
- Repository checks Internet availability using
InternetService - If online:
- Calls
RemoteDataSource RemoteDataSourceusesApiServiceto make HTTP requests- Response handling is wrapped with
DataHandler.safeApiCall - Errors are caught via
ErrorHandler
- Calls
- If offline:
- Optionally falls back to
localCallbackusingLocalDataSource
- Optionally falls back to
- Repository may also call
LocalDataSourcedirectly - All outcomes are returned as
DataState<T>:SuccessState, orFailureState
- Acts as the single source of truth for the domain layer.
- Decides when to fetch from remote or local sources.
- Uses
fetchWithFallback()fromDataHandlerfor connectivity handling.
- Contains remote API methods.
- Makes network calls via
ApiService.
- Abstracts over Dio for HTTP requests.
- Simplifies request methods and adds debugging/interception.
- Intercepts and modifies requests/responses.
- Appends access tokens and handles token refresh on 401 responses.
- Manages local data using
LocalDatabaseService. - Used for fallback or offline storage.
- Wraps remote calls in
safeApiCall(). - Validates and parses API responses.
- Handles
SuccessState,FailureState, and JSON parsing.
- Catches various error types and converts them into
FailureStatewith meaningful messages.
- Represents UI state as a sealed class:
SuccessState<T>FailureState<T>LoadingState<T>
state.when(
success: (data) => print("Got data"),
failure: (msg, type) => print("Error: $msg"),
loading: () => print("Loading..."),
);@injectable
class LoginCubit extends BaseCubit<LoginState> {
final LoginCubitUseCases _useCases;
LoginCubit({
required LoginCubitUseCases useCases,
}) : _useCases = useCases,
super(const LoginState.initial());
Future<void> login({required String username, required String password}) async {
final dataState = await _useCases.login.call(
LoginRequest(username: "", password: ""),
);
dataState.when(
success: (user) => print("Login success"),
failure: (msg, type) => print("Login failed: $msg"),
loading: () => print("Logging in..."),
);
if (dataState.hasData) {
saveUserData(dataState.data!);
} else if (dataState.hasError) {
// Handle error
}
}
Future<void> saveUserData(UserData userData) async {
final dataState = await _useCases.saveUserData.call(userData);
if (dataState.hasData) {
// Handle success
} else if (dataState.hasError) {
// Handle error
}
}
}graph TD
A[UI] --> B[LoginCubit]
B --> C[LoginUseCase]
B --> D[SaveUserDataUseCase]
C --> E[AuthRepository]
D --> E[AuthRepository]
%% Remote Data Flow
E -->|check internet| F[InternetService]
E -->|calls| G[AuthRemoteDataSource.login]
G --> H[Request API via ApiService]
H --> I[Handles API Response]
I -->|success| J[SuccessState]
I -->|failure| K[FailureState]
%% Local Data Flow
E -->|calls| L[AuthLocalDataSource.saveUserData]
L --> M[Request LocalDB via LocalDatabaseService]
M --> N[Handles LocalDB Response]
N -->|success| O[SuccessState]
N -->|failure| P[FailureState]
- Decoupled Layers: Easier testing and maintenance.
- Unified Error Handling: All API and type errors are gracefully caught.
- Clean Network Management: Internet checks, retries, and fallback handled centrally.
- Consistent UI State: Always returns
DataStatefor safe rendering.
- Alice integrated into
ApiServicefor easy request/response inspection.
This project uses a multi-layered testing strategy to ensure robustness and maintainability.
-
Mocking with
mocktail: Dependencies are mocked using themocktailpackage. This allows for testing each layer in isolation. For example, when testing aRepository, theRemoteDataSourceandLocalDataSourceare mocked. The tests demonstrate how to mock dependencies and stub method calls to return specific data or states. -
Widget Testing with
patrol_finders: Widget tests usepatrol_finders(part of the Patrol framework) to provide a more intuitive and powerful way to find and interact with widgets, making UI tests cleaner and more readable. -
Integration Testing with
patrol: End-to-end tests are written usingpatrol, which extendsflutter_testwith features for controlling native UI elements (like permission dialogs).Note: Patrol requires a one-time setup for both native Android (
build.gradle) and iOS (Podfile,RunnerUITests.m) projects. For detailed instructions, please refer to the official Patrol setup documentation. -
Isolating Layers: The architecture makes it easy to test components independently:
- When testing a
UseCase, theRepositoryis mocked. - When testing a
Cubit, theUseCases are mocked. - When testing a
DataSource, theApiServiceorLocalDatabaseServiceis mocked.
- When testing a
The project includes a suite of automated tests to ensure code quality and functionality.
-
Run all tests:
flutter test -
Run a specific test file:
flutter test path/to/your/test_file.dart
Ensure an emulator or physical device is running before executing these tests.
- Run all integration tests:
patrol test - Run a specific integration test:
patrol test --target path/to/your/integration_test.dart
For more details on specific commands and guidelines, refer to the following documents:
- Docker Commands: Essential Docker and Docker Compose commands for development environments.
- Flutter Commands Cheat Sheet: A collection of essential and frequently used Flutter commands to boost your productivity.
- Flutter Configuration Guidelines: Guidelines for setting up the Flutter environment, including activating pub commands, configuring Firebase CLI, and managing the Java SDK location.
- Git Commands Cheat Sheet: A collection of essential and frequently used git commands to boost your productivity.
