DEV Community

Jhin Lee
Jhin Lee

Posted on

5

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

AWS GenAI LIVE image

Real challenges. Real solutions. Real talk.

From technical discussions to philosophical debates, AWS and AWS Partners examine the impact and evolution of gen AI.

Learn more

Top comments (1)

Collapse
 
deladourig profile image
deladourig

Well done!

AWS Security LIVE!

Join us for AWS Security LIVE!

Discover the future of cloud security. Tune in live for trends, tips, and solutions from AWS and AWS Partners.

Learn More

Instrument, monitor, fix: a hands-on debugging session

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

Tune in to the full event

DEV is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️