Flutter: Clean Architecture with Riverpod
Clean architecture provides a clear separation of concerns, encourages interfaces and abstractions, and facilitates changes in the dependencies without affecting the application’s core logic.
Layers of Clean Architecture
Data
The data layer is the outermost layer of the application and is responsible for communicating with the server-side or a local database and data management logic. It also contains repository implementations.
a. Data Source
Describes the process of acquiring and updating the data. Consist of remote and local Data Sources. Remote Data Source will perform HTTP requests on the API. At the same time, local Data sources will cache or persist data.
b. Repository
The bridge between the Data layer and the Domain layer. Actual implementations of the repositories in the Domain layer. Repositories are responsible for coordinating data from the different Data Sources.
Domain
The domain layer is responsible for all the business logic. It is written purely in Dart without flutter elements because the domain should only be concerned with the business logic of the application, not with the implementation details.
a. Providers
Describes the logic processing required for the application. Communicates directly with the repositories.
b. Repositories
Abstract classes that define the expected functionality of outer layers.
Presentation
The presentation layer is the most framework-dependent layer. It is responsible for all the UI and handling the events in the UI. It does not contain any business logic.
a. Widget (Screens/Views)
Widgets notify the events and listen to the states emitted from the StateNotifierProvider
.
b. Providers
Describes the logic processing required for the presentation. Communicates directly with the Providers
from the domain layer.
Project Description
main.dart
file has services initialization code and wraps the rootMyApp
with aProviderScope
main/app.dart
has the rootMaterialApp
and initializesAppRouter
to handle the route throughout the application andAppTheme
to provide a theme.services
abstract app-level services with their implementations.- The
shared
folder contains code shared across features. theme
contains general styles (colors, themes & text styles)model
contains all the Data models needed in the application.http
is implemented with Dio.storage
is implemented with SharedPreferences.- Service locator patterns and Riverpod are used to abstract services when used in other layers.
Functional Programming
Clean Architecture should not be full of surprises, so we are implementing functional programming.
The core idea of the architecture:
The abstract <DataSource>
is accessed from the repository implementation. Then the abstract <Repository>
is accessed from the <StateNotifier>
and the implementation of <StateNotifier>
is accessed from the widget and how each of these layers achieves separation and scalability by providing the ability to switch implementation and make changes and test each layer separately.
For example:
final storageServiceProvider = Provider((ref) {
return SharedPrefsService();
});
// Usage:
// ref.watch(storageServiceProvider);
- The
features
folder: the pattern decouples the logic required to access data sources from the domain layer. For example, theDashboardRepository
abstracts and centralizes the functions required to fetch theProduct
from the remote.
abstract class DashboardRepository {
Future<Either<AppException, PaginatedResponse>> fetchProducts({required int skip});
Future<Either<AppException, PaginatedResponse>> searchProducts({required int skip, required String query});
}
The repository implementation with the DashboardDatasource
:
class DashboardRepositoryImpl extends DashboardRepository {
final DashboardDatasource dashboardDatasource;
DashboardRepositoryImpl(this.dashboardDatasource);
@override
Future<Either<AppException, PaginatedResponse>> fetchProducts(
{required int skip}) {
return dashboardDatasource.fetchPaginatedProducts(skip: skip);
}
@override
Future<Either<AppException, PaginatedResponse>> searchProducts(
{required int skip, required String query}) {
return dashboardDatasource.searchPaginatedProducts(
skip: skip, query: query);
}
}
Using Riverpod Provider
to access this implementation:
final dashboardRepositoryProvider = Provider<DashboardRepository>((ref) {
final datasource = ref.watch(dashboardDatasourceProvider(networkService));
return DashboardRepositoryImpl(datasource);
});
And finally, accessing the repository implementation from the Presentation layer using a Riverpod StateNotifierProvider
:
final dashboardNotifierProvider =
StateNotifierProvider<DashboardNotifier, DashboardState>((ref) {
final repository = ref.watch(dashboardRepositoryProvider);
return DashboardNotifier(repository)..fetchProducts();
});da
Notice how the abstract NetworkService
is accessed from the repository implementation. Then the abstract DashboardRepository
is accessed from the DashboardNotifier
and how each layer achieves separation and scalability by providing the ability to switch implementation, make changes, and test each layer separately.
Testing
The test
folder mirrors the lib
folder in addition to some test utilities.
state_notifier_test
is used to test the StateNotifier
and mock Notifier
.
mocktail
is used to mock dependencies.
Additionally, With clean architecture, you can replace the SharedPreferences with Hive without affecting the application's core logic. You need to modify the data access layer responsible for interacting with the data storage system.
By swapping out the SharedPreferences implementation with the Hive implementation, you can change the data storage system without affecting the rest of the application.
In this PR, we changed the implementation and test files to change the data storage system without affecting the application's core logic.