avatarOlivier Revial

Summary

The provided content outlines a method for implementing a Clean Architecture in Flutter using multi-packages, with a focus on dependency injection, navigation, and managing complexity through tools like a core package, shared dependencies, and Melos.

Abstract

The article delves into the intricacies of orchestrating a multi-package Clean Architecture in Flutter applications. It emphasizes the importance of dependency injection (DI) using the get_it service locator and the injectable package to manage dependencies across different packages. The author explains how the app package acts as a central orchestrator, configuring dependencies for the core, data, domain, and presentation layers. The navigation within the app is handled externally, using go_router for declaring routes and interfaces for implementing navigation between flows. To address the complexity of managing separate packages, the article suggests using a core package for shared helpers without coupling layers, defining shared dependencies to maintain consistent versions, and employing Melos for executing scripts across multiple packages. The article concludes by summarizing the series on Clean Architecture implementation in Flutter and provides a link to the demo code on GitHub.

Opinions

  • The author advocates for the use of dependency injection to promote loose coupling and ease testing and flexibility.
  • The core package is recommended for reusing code without coupling specific layers of the architecture.
  • Using a shared_dependencies package is presented as a solution to avoid version duplication and potential conflicts.
  • Melos is highly recommended for streamlining the management of multi-package projects, particularly for running common scripts.
  • The author expresses enthusiasm about the Clean Architecture approach, suggesting it leads to a well-organized and maintainable Flutter codebase.
  • The use of external navigation and the separation of concerns in the presentation layer are highlighted as best practices.
  • The article implies that while managing a package-separated Clean Architecture can be complex, the right tools and practices can mitigate these challenges.

Multi-packages Clean Architecture in Flutter — Orchestrating everything 🎻

Photo by Samuel Sianipar on Unsplash

This article is part of a series on implementing a Clean Architecture with separated packages.

In the previous articles, we’ve dived into the details of the presentation layer, the domain layer and the data layer. Let’s now see how we can make all of them work together in perfect harmony (or, at least, we will try 🤞).

Dependency Injection

When talking about “making everything work together”, the essential notion is dependency injection. I won’t go too much into details but basically dependency injection (often called DI) is:

a design pattern in which the dependencies of a class or module are provided externally rather than created internally, promoting loose coupling and facilitating easier testing and flexibility

DI in each package

You might have noticed in packages code that we used annotations to help with DI, e.g.:

@Injectable(as: CoursesRepository)
class CoursesRepositoryImpl implements CoursesRepository {}

Basically this annotations tells somebody “Hey, you can use me as an implementation of CoursesRepository interface”.

The “somebody” in question is get_it service locator that we need to instantiate in each package so all package implementations are actually registered. Let’s see an example from the data package:

@InjectableInit()
void configureDependencies({
  required String env,
  bool isTest = false,
}) {
  getIt.init(environment: env);
  if (isTest) {
    getIt.allowReassignment = true;
  }
}

Oh yes, we also use injectable to make our life easier with DI.

Note that we have a condition that allow reassignment of injected instances when we are in a test context, which is essential when we want to replace the real implementation with our mocked instance.

DI orchestration

Our app package will act as the general orchestrator and as such it will call each of our packages to configure their dependencies:

import 'package:core/core.dart';
import 'package:core/di/di.dart' as core_di;
import 'package:data/di/di.dart' as data_di;
import 'package:domain/di/di.dart' as domain_di;
import 'package:flutter_clean_architecture_demo/di/di.config.dart';
import 'package:flutter_clean_architecture_demo/di/di.dart' as app_di;
import 'package:injectable/injectable.dart';
import 'package:screens/common/di/di.dart' as screens_di;

@InjectableInit()
void configureDependencies({
  required String env,
  bool isTest = false,
}) {
  getIt.init(environment: env);
  if (isTest) {
    getIt.allowReassignment = true;
  }
}

void configureAllPackagesDependencies({
  required String envId,
  bool isTest = false,
}) {
  core_di.configureDependencies(env: envId, isTest: isTest);
  data_di.configureDependencies(env: envId, isTest: isTest);
  domain_di.configureDependencies(env: envId, isTest: isTest);
  app_di.configureDependencies(env: envId, isTest: isTest);
  screens_di.configureDependencies(env: envId, isTest: isTest);
}

Last step, we have to call this initializations from our main.dart :

configureAllPackagesDependencies(envId: String.fromEnvironment('ENV_ID'));

Note how we can pass a specific environment (e.g. dev/uat/prod) that will be used in all the registered instances to choose what exact implementation needs to be registered depending on the environment. E.g.:

@module
abstract class LoggerModule {
  @dev
  @prod
  @uat
  Logger get logger => Logger(
        printer: PrettyPrinter(),
      );

  @test
  Logger get testLogger => TestLogger();
}

And that’s it, after these calls, every implementation is available in our app, how great ✨

Navigation

Remember when we talked about external navigation vs internal navigation ? Well, the app package will now implement the external navigation, in two steps.

Declaring the routes

As many we will use go_router for routing, which means we need to declare a router that will be passed to our MaterialApp widget:

final _rootNavigatorKey = GlobalKey<NavigatorState>();

@lazySingleton
class AppRouter {
  final goRouter = GoRouter(
    initialLocation: getIt<CoursesFlow>().startingRoutePath,
    navigatorKey: _rootNavigatorKey,
    routes: [
      getIt<CoursesFlow>().routes(_rootNavigatorKey),
      getIt<SettingsFlow>().routes(_rootNavigatorKey),
    ],
  );
}

😮 Did you see ?

  • We only know about each of our app’s flows, the details of each flow’s routes is given by the flow itself: we only define external navigation!
  • We use dependency injection to get everything, as explained above ⬆️

Implementing the navigation

We’ve already seen it in the presentation layer, but when a flow asks to navigate to another flow, it does it through an interface that the app package implements, e.g.:

@LazySingleton(as: CoursesNavigation)
class CoursesNavigationImpl implements CoursesNavigation {
  @override
  void openSettings(BuildContext context) {
    context.push(getIt<SettingsFlow>().startingRoutePath);
  }
}

Dealing with complexity

Let’s face it, managing a package-separated Clean Architecture is not that easy… at least without the proper tooling. Let’s now see a few things that will help us deal with complexity.

Core package

Sometimes you will write code in a package and feel like you already wrote it in another package so why not reuse it ?

We use a “core” package for that (could also be named commons if you prefer) but with extra care: we should never use this package to share components that belong to a specific layer, that would strongly couple our packages which is what we want to avoid at all cost.

If you look at the core package from the demo app, you will see that we mostly defined very simple extensions or helper methods, e.g.:

final GetIt getIt = GetIt.instance;

or:

extension NullableIterableExtensions<T> on Iterable<T>? {
  bool get isNullOrEmpty => this == null || this!.isEmpty;

  bool get isNotNullOrEmpty => !isNullOrEmpty;
}

And that’s pretty much it. Again, make sure you don’t rely on the core package in your developments, that would probably be a big code smell.

Shared dependencies

We use an extra package called shared_dependencies whose only goal is to define base versions of packages that are used in multiple packages, e.g.:

dependencies:
  freezed_annotation: ^2.2.0
  get_it: ^7.6.0
  injectable: ^2.1.1

Then we can define our dependencies in each package, say app using a specific version keyword: any. E.g.:

dependencies:
  freezed_annotation: any
  get_it: any
  injectable: any

Using any keyword will basically tell pubspec system to resolve any dependency version that it can find, unconstrained. While this can seem very dangerous, it’s actually okay because pubspec will go up the dependency tree to find for a version set for each dependency, and it will always find a fixed version in our shared_dependencies.

Not the greatest syntax but that’s the best I’ve found to avoid duplicating the version of each dependency and avoid my brain exploding on each library upgrade 🤯

Melos

Melos is a wonderful CLI tool that helps manage multi-packages projects, which is just what we need. Say you updated the dependencies in multiple packages: you now need to run a flutter pub get in each and every package of your app. How cumbersome 😳

But melos then comes to the rescue and allows us to define scripts in a melos.yaml file, e.g.:

scripts:
  build:pub_get:all:
    run: flutter pub get
    description: Install all dependencies

Now instead of manually running the command in each package, you just run a simple melos command:

melos run build:pub_get:all 

And there you go, your script has been executed in all the packages automatically, it’s like magic 🪄

Of course you have to define the list of packages you want in your scripts:

packages:
  - app
  - packages/shared_dependencies
  - packages/core
  - packages/data
  - packages/domain
  - packages/presentation/design_system
  - packages/presentation/screens

This articles wraps up this article series on implementing a multi-packages Clean Architecture in Flutter. I hope you enjoyed it, feel free to leave any comment here 💬

🧑‍💻 Happy coding with Flutter! 🧑‍💻

🔗 Code featured in this article series can be found in this Github repository.

Flutter
Clean Architecture
Recommended from ReadMedium