Building a multi-package project with flutter

Hi, my name is Rodrigo Martins, I’m a senior software engineer at EBANX, and I’m here to share a little bit about the latest project that I’m working on. The main idea of this project (speaking about engineering goals) is to be able to share features between apps, in other words, I need to have for example the wallet feature in a separate package so I can import it into application A and also in application B. To accomplish this we need to design an app architecture that supports multiple packages, and to do that we end up with some complexities in the project, like, how we’re gonna manage package dependency, and dependency injection between packages, and so on…

We spend some time thinking and creating POC’s (prove of concept) about how the architecture must be to accomplish that goal, and in the process, we ask ourselves questions like:

  • Are the responsibilities of the major areas well defined and appropriate?
  • Is the collaboration between major components well defined?
  • Is coupling minimized?
  • Can you identify potential sources of duplication?
  • Are interface definitions and constraints acceptable?

You can find those questions on pragmatic programmer

Explaining folder structures

The folder base structure of the app can be resumed in a mono repo with:

- app
- appOne
- appTwo
- ...
- features
- wallet
- shopping
- profile
- ...
- core
- injection
- network
- shared
- storage
- tool_kit

The folder app, why do we need multiple apps? The main idea is to have a separate app for each country that we launch the product because each country will have some different business logic like signup, in country A you may request 5 documents, in country B, 10 documents, and in country C, 7 documents, so to avoid having a code that has validations to know which business logic to execute we wanted to have separate apps, with shared features, and each app will have only the code that belongs to him, like signUp, and signIn features that can’t be shared between apps. And he will use the other features as a dependency as it needs.

All the shared features go here, the idea of a feature is that she will be completed by herself, the idea is to depend only on packages like, design_system, _shared, network, storage … solid packages that will keep the reasons to the feature package change low, and when we try to use in a different app we only need to import it in the pubspec.yaml file and it will be ready to use. Each feature can have its architecture and pattern, the one that fits the team that is working with it, but we have some guidelines to help the developers in the process.

The folder core contains all the base packages to use in the apps and feature packages, like network, have one interface that abstract the network logic, so we only call an interface to do a request, or the storage package to persist local data and we access only the interface, we have also the shared package where we put shared classes, like models, use_case interfaces that gonna be used in different packages and so on … The injection module contains interfaces that define contract to packages register his dependencies and the tool_kit package contains util classes.

Explaining the architecture

- app
- di (Dependency Injection)
- injectionComponent
- modules
- features (Features that can't be shared)
- signIn
- signUp
- ...
- environments (Production / Staging)
- localization (locale delegate)
- router
- routes (App routes and declaration of feature routes)
- routerComponent
- featureA
- di
- modules
- provider
- ui
- router (Map with route -> screen)
- localization (locale delegate)
...

The apps are the ones that centralize every feature that they need and initialize them. In the app, we have every provider, injection modules, and router modules from each feature that it depends on, so when we are initializing the application we can iterate through his modules and set up each one of them. To accomplish this each feature needs to export its modules (provider, injection, and router), and when we import the feature in the app we have access to them.

The workflow can be resumed as:

- App 
- component (injection / router)
- module (injection / router)
App start
- call injection component
- iterate through every injection module declared
- calling the method registerDependencies (this method well get every declaration of factories and singletons and register inside the service locator, to be used later)
- after the injection component run, we will get the router component instance that is register inside of our service locator, and iterate through every declared route, to create one single map to provide to materialApp routes.

Each package (feature / app) contains this structure where we can bootstrap each one of them. This image shows a generic view of the package, where:

Where we declare the dependencies that this module needs from other ones for example the feature package of Wallet depends on the Firebase instance to send some analytics logs, but the firebase instance is created in the main application, so we can centralize third-party dependencies, the feature Wallet only needs to create an interface where she declares with methods every outside dependency that she needs, and in the app, we’re gonna implement this interface providing the proper instance for it. For example:

abstract class WalletDepedencyProvider {    FutureOr<Firebase> providesFirebase()}

With this approach we can avoid problems like having multiple instances of third-party libraries and we can centralized the ways that we provide instances for packages, so if some day we need to change something we know that we need to change only the providers.

The injection module is where each feature declares its injectable classes, like, for example, the wallet feature has, data_sources, repository, use_cases, and soo on, so we create the class WalletInjectionModule and define those dependencies there, so when we initialize the application our main component will iterate through them registering each one inside ou dependency provider.

class WalletInjectionModule implements InjectionModule {    @override    FutureOr<void> registerDependencies({        required AppInjector injector,        required BuildConfig config,    }) {      injector.registerSingleton<WalletRepository>(AppWalletRepository())
}
}

We receive in this class the instance of our dependency provider and the buildConfig instance that is different for each environment (staging/production). With the injector instance we can register, singletons, factories, lazySingletons and get some instances too.

The router module is where we define the routes of the package, so later we group every routes map and declare in the main app, without this we can't navigate to a screens.

class WalletRouterModule implements RouterModule {    @override    Map<String, WidgetBuilder> routes() => {        transactionsPath: (_) => TransactionsScreen(),    };}

As we will have multiple countries to deploy the apps we need to support i18n in the project, the way that we are handling this is with intl package so every feature package or app has its assets/i18n folder with its translations, we decide that letting the packages have everything related to him in him will be better because helps us decrease the package dependency, and make easier to maintain the code because you know that everything is there. Also, each one of the features must declare a localization delegate which is a class that gets the location files in assets/i18n folder and provides methods that you can translate keys in the view. The last step is to add the delegate of the feature in our main app.

final walletLocalizationDelegate = WalletLocalizationDelegate(getPathFunction: (locale) =>‘packages/go_wallet/assets/i18n/${locale.languageCode}-${locale.countryCode}.json’,supportedLocales: supportedLanguages,);

app.dart

localizationsDelegates: [  appLocalizationDelegate,  walletLocalizationDelegate,],

Main application (apps)

Modules

This is a simple class with different lists of modules used in the application, basically every injection module of feature packages or the app they are registered in here, so in the application component, we iterate through them to initialize and register our dependencies. When we create a new feature package like for example Shopping, we will create the injection module in it, define the injectable dependencies, create the injection provider to declare outside dependencies in case that it needs one, and after just add it to the list Features modules.

final List<InjectionModule> appInjectionModules = <InjectionModule>[  _libraryModule,  …_coreModules,  …_providerModules,  …_injectionModules,];
// Libraries modulefinal InjectionModule _libraryModule = AppLibraryModule();
// Core modulesfinal List<InjectionModule> _coreModules = <InjectionModule>[ StorageModule(), NetworkInjectionModule(),];
// Provider modulesfinal List<InjectionModule> _providerModules = <InjectionModule>[ AppProviderModule(),];
// Features modulesfinal List<InjectionModule> _injectionModules = <InjectionModule>[ WalletInjectionModule()];

This class is where we instantiate every provider of our features, the Provider Injection is the class that the feature that declare the outside dependencies that she needs, for example:

Feature Wallet create the interface WalletDepedencyProvider

abstract class WalletDependencyProvider {    FutureOr<Firebase> providesFirebase();}

and in the app package we implement this interface providing the dependencies requests in the interface.

class AppWalletDependencyProvider implements WalletDependencyProvider {final Firebase _firebse;const AppWalletDependencyProvider(this._firebse);@overrideFutureOr<Firebase> providesFirebase() => _firebse;}

and the last step is to add the AppWalletDependencyProvider in the Injection Provider class to be iterate together with others modules in the app startup.

The app module is the injection Module of the application, each feature packages has its injection module as a said before, and in the app goes the same, here we declare every injectable class that is inside the app, for example, to the features that can’t be shared they have specific code of the country we will declare it in the app module so like the other injection modules it will be initialized on the start of the application.

This class is where we register our third-party libraries like for example Firebase, this helps us centralized the outside instances and register inside our dependency provider, so when someone needs it through the provider or asking directly on the AppInjector it will be ready to use. It is like any other InjectionModule but responsible to register only instances from third-party packages.

This architecture was inspired by Dagger, a dependency provider for Android. In our AppComponent we have some logic to initialize every dependency module declared in the Modules class, it is a for that iterate through the modules calling the method to initialize the declared dependencies.

for (final module in appInjectionModules) {    module.registerDependencies(      injector: AppInjector.I,      config: config,    );}

You can read more about dagger here.

Does the same as injection component but with router modules, he will iterate through every router module declared on the app router module and provide a list of every router of the app and his packages to the mainApp, to the application be able to find the routes when we try to navigate.

More depth in the architecture

The architecture of features follows the clean architecture patterns so we have our data_sources layer that handles data manipulation remote and locally, our repository that handles cache policies like, should I get from local our remote? And use_cases with business logic. Every feature implements its classes so the Wallet feature will have his data_sources, repositories, use_cases … And the same goes for the Shopping feature, the main goal is to have the independent and with minimum dependency from others modules.

You can read more about clean architecture here

Currently, we are using the flutter_bloc library to manage state in our views, but we are limiting to use only cubits due to the more code that we need to generate or write using bloc, with events and states, with cubit we have only states. But as I said before each feature is free to implement what makes more sense to the team and the feature.

To provide instances in our application we are using getIt that is a service locator where we register our dependencies in the Injection modules and Provider modules. One of the downsides of getIt is that we can not declare depends on for instances, so if class A depends on class B, you need to declare class B before A, so if someone changes the order of the declared dependencies you will have problems, of course, this happens in the current version, the injectable package provides some sort of depends on for singletons which are async but only for those cases.

You can read how to use getIt in here

As we have multiple packages and the goal is to have much more, we needed something to help us control the dependencies in our packages/apps, at first we were using scripts that iterate through the apps, packages and core folders running commands like flutter clean or flutter pub get, but this solution does not scale, so we hear about melos a tool that helps you manage monorepo with multiple packages, I will not explain how it’s work but is quite simples, you just need o activate in your dart global and configure your melos files, with your commands and configurations.

You can read the melos documentation in here

In our application we need to have flavors on Android or targets in iOS for test purposes, so we have created one structure BuildConfig that abstract some environment configurations for each build type that we are running, production, or staging, this class is provided in our injection modules so we can configure each dependency as we want. We can define things like serverUrl … On it.

We have one class that receives the build config and start the application.

app_start.dart

abstract class AppStart {  final BuildConfig buildConfig;  AppStart(this.buildConfig)  Future<void> startApp() async {    WidgetsFlutterBinding.ensureInitialized();    try {        await AppInjectionComponent.instance.registerModules(buildConfig);    } catch (error) {}    await runZonedGuarded<Future<void>>(() async {        runApp(AppConfig(child: MyApp()));    }, (error, stackTrace) async {    });  }}

production_main.dart

class ProductionApp extends AppStart {ProductionApp() : super(ProductionBuildConfig());}run | debugFuture<void> main() => ProductionApp().startApp();

In this article you can read, how to implement flavors in a flutter application.

In our project we decided to follow the trunk base development so we have only one branch that we push code every day, through pull requests, and with the help of the melos, fastlane and bitrise, we have created some pipelines to deploy the version through the week automatically, staging builds to be distributed internally to QA purposes and production builds that are uploaded directly to stores (play store and app store).

Photo by Fran Jacquier on Unsplash

Conclusion

That’s it, I hope it helps you at least a little bit in your flutter projects, this solution is not final and it is not perfect, we are working every day to improve it and we have already found some drawbacks of this approach like:

  • The complexity of creating a new package (we have some basic structure to create every time, to declare injectable dependencies, routes, localization …)

Feel free to comment and give suggestions positive or negative about the solution. Thanks.

Be brave and boldly go where no man has gone before. Let's discover the future!