Monorepo

monorepo is a repository structure where multiple applications and shared packages coexist in a single repository. This setup offers easy code sharing and dependency management between apps, especially for large codebases where multiple projects rely on the same packages or modules.In a Flutter monorepo:
  • Apps can be managed independently within the same repository.
  • Common packages (e.g., shared UI components or utilities) are accessible to all apps without duplicating code.
MonorepoMonorepo

Monorepo using melos

Melos is a powerful CLI tool specifically designed for managing Dart and Flutter projects in a monorepo. It simplifies dependency management, package linking, and versioning, all while offering efficient commands to manage multiple projects and packages.

Benefits of Using Melos:

  • Simplifies package interdependencies and linking within the monorepo.
  • Provides automated versioning and changelog generation.
  • Supports running commands and tests across multiple packages at once.

Setting Up Monorepo Project Structure:

  • apps
    • app1
      • libs
        • main.dart
      • pubspec.yaml
    • app2
      • libs
        • main.dart
      • pubspec.yaml
  • packages
    • authentication
      • libs
      • pubspec.yaml
    • products
      • libs
      • pubspec.yaml
  • melos.yaml
  • pubspec.yaml
Each app and package should have its own pubspec.yaml file. This layout makes it easy for Melos to manage dependencies across apps and shared packages.

Installing and Configuring Melos:

To start, install Melos via the Dart CLI:
dart pub global activate melos
In your monorepo root directory, create a melos.yaml & pubspec.yaml files. The `melos.yaml` file will define the scope and behavior of Melos within your project.
name: my_flutter_monorepo
packages:
  - "apps/*"
  - "packages/*"
flutter pub add melos
This configuration tells Melos to include all projects in the apps and packages directories.

Linking Packages to Apps Using Melos

With Melos, linking shared packages to apps is straightforward.

Add Shared Packages as Dependencies:

In app1’s pubspec.yaml, add your shared package as a dependency:
dependencies:
  authentication:
  products:

Install Dependencies:

Run the following command to install dependencies across all apps and packages:
melos bootstrap
This command resolves interdependencies and links packages correctly, making them accessible to all applications within the monorepo.

Conclusion

A Flutter monorepo, managed with Melos, brings organization, efficiency, and scalability to your projects, making it easy to share packages across multiple applications. With Melos commands, you can manage dependencies, link packages, and automate workflows, helping to maintain consistency across projects in a single codebase.

Flutter Monorepo Drawbacks

While developing our CLI, we noticed some issues that you might encounter:
  • Objectbox:
    Depending on objectbox FAQs:
    The ObjectBox generator only looks for entities in the current package, it does not search workspace packages. However, you can have a separate database (MyObjectBox file) for each package. Just make sure to pass different database names when building your BoxStore.


    Scenario:

    If you have two separate packages (e.g., “categories” and “products”), and the Product model includes a reference to Category (with the Category model located in the “categories” package),
    the ObjectBox generator will throw an error in the “products” package because it cannot access the Category model.


    Solution:

    To resolve this, you need to duplicate the Category model in the “products” package. Once you do this, the ObjectBox generator will successfully generate the Product BoxStore.

  • Injectable:
    The Injectable generator only scans for dependencies within the current package and does not look into other workspace packages. This limitation can lead to errors if a dependency is injected in a different package.

    Scenario:

    Each Retrofit remote data source relies on a Dio instance for network requests. Generally, a Dio instance is registered as a lazySingleton in a CoreModule within the core package of the monorepo.
    For example, if you have a products package with a Retrofit remote data source that depends on Dio, the Injectable generator will throw an error because it cannot find the Dio configuration within the products package, as it’s unaware of the CoreModule configuration in the core package.
    @module
    abstract class CoreModule {
      @lazySingleton
      Dio provideDio(AppEnvironment environment) {
        final dio = Dio()
          ..transformer = BackgroundTransformer()
          ..options = BaseOptions(
            baseUrl: '${environment.baseUrl}/api',
          )
          ..interceptors.addAll([HttpInterceptor()]);
        return dio;
      }
    
      @test
      AppEnvironment get testEnvironment => TestEnvironment();
    
      @dev
      AppEnvironment get developmentEnvironment => DevelopmentEnvironment();
    
      @prod
      AppEnvironment get productionEnvironment => ProductionEnvironment();
    }

    Solutions:

    • Case 1: Isolated Configuration for Different Packages

      If the products package needs a separate Dio instance with a different configuration (e.g., connecting to a different backend), you can declare a @Named Dio instance in the products package itself.
      This registers an isolated instance for the products package, ensuring each products remote data source uses this named instance.
      @module
      abstract class ProductsModule {
        @lazySingleton
        @Named('products')
        Dio provideDio() {
          final dio = Dio()
            ..transformer = BackgroundTransformer()
            ..options = BaseOptions(
              baseUrl: 'https://www.productsDomain.com/api',
            );
          return dio;
        }
      }
      @LazySingleton(scope: 'products')
      @RestApi()
      abstract class ProductsRemoteDataSource {
        @factoryMethod
        factory ProductsRemoteDataSource(@Named('products') Dio dio) = _ProductsRemoteDataSource;
      }
      This setup must be done manually after generating the data source inside the products package.


    • Case 2: Shared Dio Instance Across Packages

      If all packages connect to the same backend and use the same base URL, you can register the Dio instance in one package (e.g., the core package), and then configure other packages to skip over missing instances by using ignoreUnregisteredTypes.
      @InjectableInit(ignoreUnregisteredTypes: [
        Dio, // Ignoring missing Dio config in the products package
      ])
      Future<void> configureProductsDependencies({Environment? environment}) async {
        await GetIt.instance.init(environment: environment?.name);
      }
      Ensure that in your app’s main.dart, you call configureCoreDependencies before configureProductsDependencies:
      void main() async {
        WidgetsFlutterBinding.ensureInitialized();
        // ...
      
        // Injecting dependencies in the correct order
        const environment = Environment(Environment.dev);
        await configureCoreDependencies(environment: environment);
        await configureProductsDependencies(environment: environment);
        await configureAppDependencies(environment: environment);
        
        runApp(const MyApp());
      }