Motivation
When I started my flutter project two years ago, Android Jetpack architecture inspired me a lot. DataBinding between ViewModel and View using LiveData that comes from the data layer passing through the repository and so on, as I mentioned in another posting
(I cannot find this diagram anymore from the official google document. They added the domain layer as an optional recommendation: link)
My project folder structure was something like this:
- lib
- repositories
- a_repository.dart
- b_repository.dart
- models
- a_model.dart
- b_model.dart
- services
- a_service.dart
- b_service.dart
- ui
- pages
- pageA
- a_page.dart
- a_viewmodel.dart
- pageB
- b_page.dart
- b_viewmodel.dart
- widgets
- a_widget.dart
- b_widget.dart
- main.dart
- DI.dart
DI.dart
wasn't truly doing the dependency injection. Still, I was initializing all global dependencies as singletons to inject them into the page-scoped ViewModels when initializing the page.
The problem with this approach was that the ViewModel held too much business logic. If I needed to use similar logic for another page, I had to reimplement the logic in the ViewModel. Eventually, I started adding ViewModels in a shared folder and reusing them directly from the widgets, creating another instance of the ViewModel. It reduced the code duplication, but it made it look messy. Whenever I tried to add a new feature, it was confusing where I should put the new codes.
Clean architecture
After some research, I decided to apply the clean architecture I've used for a golang backend project. The clean architecture was quite famous for the flutter project, too, since it's a recommended architecture by BLoC, a popular state management library in dart.
Layer-First structure
I started to restructure the directory based on the layer. (At this moment, I also decided to use riverpod instead of provider)
- lib
- data
- remote
- repositories
- a_repository_impl.dart
- b_repository_impl.dart
- models
- a_model.dart
- b_model.dart
- services
- a_service.dart
- b_service.dart
- data_provider.dart
- domain
- entities
- a_entity.dart
- b_entity.dart
- repositories
- a_repository.dart
- b_repository.dart
- usecases
- featureA
- get.dart
- get_many.dart
- featureB
- get.dart
- get_by_filter.dart
- providers
- feature_a_usecase_provider.dart
- feature_b_usecase_provider.dart
- presentation
- states
- a_state_provider.dart
- b_state_provider.dart
- pages
- pageA
- a_page.dart
- pageB
- b_page.dart
- widgets
- a_widget.dart
- b_widget.dart
- main.dart
The repositories in the domain layer are abstract classes that interface between the domain and the data layer. The data_provider.dart initialize the service and repository providers. The providers in the domain layer are initializing the usecases using repository providers defined in data_provider.dart. The states in the presentation layer only access entities and usecase providers in the domain layer.
The model is a service-specific model that holds entity getter(mapper)
// a_model.dart
class ModelA {
ModelA({this.name});
final name;
EntityA get entity => Entity(name: name);
}
The service can locally or remotely access the raw data source
// a_service.dart
abstract class ServiceA {
ModelA get(String a);
}
class ServiceAImpl implements ServiceA {
ModelA get(String a) {
return ModelA(name: "test");
}
}
The implementation of the repository that accesses the service
// a_repository_impl.dart
class RepositoryAImpl implements RepositoryA {
RepositoryAImpl(this.service);
final ServiceA service;
EntityA get(String id) {
return service.get(id).entity;
}
}
Initialize services and repositories(Services are private)
// data_provider.dart
final _serviceAProvider = Provider<ServiceA>((_) => ServiceAImpl());
final repositoryAProvider = Provider<RepositoryA>(
(ref) => RepositoryAImpl(ref.watch(_serviceAProvider)),
);
Domain entity that application uses
// a_entity.dart
class EntityA {
EntityA({this.name});
final name;
}
Interfacing between domain and data layer
// a_repository.dart
abstract class RepositoryA {
EntityA get(String id);
}
The usecase can access the repository
// featureA/get.dart
abstract class GetA {
EntityA execute(String id);
}
class GetAImpl implements GetA {
GetAImpl(this.repo)
final RepositoryA repo;
EntityA execute(String id) {
return repo.get(id)
}
}
The states in the presentation layer access the usecase through the provider
// feature_a_usecase_provider.dart
final getAProvider = Provider<GetA>((ref) =>
GetImpl(ref.watch(repositoryAProvider)),
);
States are globally shared and accessible from widgets
// a_state_provider.dart
final stateAProvider = StateNotifierProvider<EntityA>((ref){
return StateANotifier(EntityA(), ref);
});
class StateANotifier extends StateNotifier<EntityA> {
StateANotifier(super.state, this.ref)
final Ref ref;
void fetch(String id) {
state = ref.watch(getAProvider).execute(id);
}
}
Widget accesses the states
// a_widget.dart
class WidgetA extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final stateA = ref.watch(stateAProvider);
return InkWell(
onTap: () => ref.read(stateAProvider.notifier).fetch(),
child: Text('${stateA.name}'),
);
}
After the massive refactoring of the architecture (Even though it looks like there are a lot of boilerplate codes), I was pretty satisfied. I don't need to think twice about where to put which logic. Every business logic goes to the usecase and entity. If the view needs to update the state, I create the StateNotifierProvider. The Widgets only focus on the viewing part. The separation gives better testability and lets the codes keep the single responsibility rule.
Feature-First structure
After the refactoring, the only problem I had was that it was not easy to navigate the source code. When implementing a feature, I wished all related codes were grouped. Since the codes are split based on the layer, It was pretty easy to lose my focus.
So, I decided to do another refactoring for a feature-first structure.
- lib
- features
- featureA
- domain
- entity
- a_entity.dart
- repository
- a_repository.dart
- usecases
- get.dart
- get_many.dart
- data
- repositories
- a_repository_impl.dart
- models
- a_model.dart
- services
- a_service.dart
- data_provider.dart
- providers
- feature_a_usecase_provider.dart
- featureB
- domain
- entity
- b_entity.dart
- repository
- b_repository.dart
- usecases
- get.dart
- get_many.dart
- data
- repositories
- b_repository_impl.dart
- models
- b_model.dart
- services
- b_service.dart
- data_provider.dart
- providers
- feature_b_usecase_provider.dart
- presentation
- states
- a_state_provider.dart
- b_state_provider.dart
- pages
- pageA
- a_page.dart
- pageB
- b_page.dart
- widgets
- a_widget.dart
- b_widget.dart
- main.dart
For the code-wise, nothing changed. Some might wonder why I didn't put the presentation in the feature directory. I still don't have a conclusion on this, but I thought the UI should use different domains, and I didn't want to cross-reference between features. I didn't specify above, but I put the shared codes in the core feature. It's the only exception that other features can reference.
Conclusion
I don't have any good recommendations for a small project or team if the clean architecture is a good choice or not since it requires some boilerplate codes. But It gives us a good separation between layers and domains, making the code more testable. It can take time to write the pattern every time adding a new feature, but the time can be easily compensated by focusing on implementing a single domain feature. There's no perfect architecture or solution. One day, we need to refactor our code base. A library we've been using probably doesn't fit our needs anymore or introduces security vulnerabilities. The well-structured architecture allows quickly switching a library or refactoring a part of your code.
Top comments (1)
Well done!