DEV Community 👩‍💻👨‍💻

Jhin Lee
Jhin Lee

Posted on

Journey to the clean architecture for my flutter app

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

MVVM with data layer

(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
Enter fullscreen mode Exit fullscreen mode

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

From cleancoder blog

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Initialize services and repositories(Services are private)

// data_provider.dart
final _serviceAProvider = Provider<ServiceA>((_) => ServiceAImpl());
final repositoryAProvider = Provider<RepositoryA>(
    (ref) => RepositoryAImpl(ref.watch(_serviceAProvider)),
);
Enter fullscreen mode Exit fullscreen mode

Domain entity that application uses

// a_entity.dart
class EntityA {
    EntityA({this.name});
    final name;
}
Enter fullscreen mode Exit fullscreen mode

Interfacing between domain and data layer

// a_repository.dart
abstract class RepositoryA {
    EntityA get(String id);
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)),
);
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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}'),
    );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

References

Top comments (0)

You can see total article reactions, views, and listing information by heading over to your dashboard.