Multi-packages Clean Architecture in Flutter — Orchestrating everything 🎻
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.1Then 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: anyUsing 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 dependenciesNow 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/screensThis 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.






