A concise guide to managing a monorepo in a Dart project with Melos

A concise guide to managing a monorepo in a Dart project with Melos

A monorepo (or shared codebase) is a software development strategy in which multiple projects share a single git repository. Assuming you are familiar with the concept of a git repository and have some understanding of a monorepo, this article aims to provide guidance on effectively managing a monorepo. However, to ensure clarity, I will briefly dwell on the "What".

In a monorepo, multiple projects share a single codebase, which should not be confused with a monolithic architecture. These projects have a well-defined relationship with each other. Another software development approach is a polyrepo, also known as a multi-repository. It is an alternative approach where each project exists in its own separate repository and is independently managed. The purpose of this article is not to go into detailed explanations of these concepts, so I will refrain from providing an in-depth explanation of what a monolith or polyrepo is. If you are unfamiliar with these terms, I recommend referring to the resources provided at the end of this article. It is assumed that you already have a basic understanding of a monorepo, although additional resources will be provided for further information. Let's get down to business.

This is what a monorepo would look like in a dart project.

my-repo/
 |- ...
 |- `packages/`
 |-   |- *package-1/*
 |-   |-   |- lib/
 |-   |-   |- pubspec.yaml
 |-   |-   |- ...
 |-   |- *package-2/*
 |-   |-   |- lib/
 |-   |-   |- pubspec.yaml
 |-   |-   |- ...
 |- pubspec.yaml
 |- ...

(Ignore the formatting - the use of `` and **).
In any dart project, you will always find a pubspec.yaml file among other things. In the folder structure above, notice that lib/ and pubspec.yaml appears more than once, indicating the presence of multiple projects. You have the root project, and inside your root project you have a directory packages/ with what appears to contain two projects - package-1 and package-2 both containing lib/ and pubspec.yaml.

This is a common way that a monorepo (a project with sub-projects) will be structured in a dart project. The use of the words packages, package-1 and package-2 are subjective and can be replaced to give a tailored meaning to a project. You would commonly find packages replaced with shared_libraries, and both could sometimes co-exist.

Splitting a single project into sub-projects introduces new challenges which can slow down development time and reduce productivity. Overall, it can affect development workflow if not properly managed. This is what this article aims to cover - the "How to" of managing a monorepo. One automatically inherits some challenges when using a monorepo approach, but I'll discuss more in the context of Flutter and Dart - no pun intended. One significant challenge that arises when utilizing a monorepo approach includes:

  • Fetching dependencies: This poses a challenge because if your project was broken down into four sub-projects, you would need to run flutter pub get in all four packages.
    This is also the same when a good number of sub-projects depend on code generation; this is an ugly experience for any new collaborator on your project. This is where melos comes in.

Melos - The "What"

So far I've introduced some maybe-foreign concepts, but what exactly is melos. Melos is a tool.

Melos is a tool for managing Dart & Flutter repositories with multiple packages (monorepo)....

There are other tools to manage monorepos outside the context of flutter and dart - again no pun intended.

Getting started setting up melos

As of the time of writing, according to the melos documentation, there are a few steps that are required to start using melos

  1. Install melos as a global package by running the following command in a terminal.

     dart pub global activate melos
    
  2. Add the melos to your dev dependency in the pubspec.yaml at the root of your main project - in our folder structure above, that will be the pubspec.yaml file directly inside my_repo/. You can add it by running the following command:

    In a dart project:

     dart pub add melos --dev
    

    In a Flutter project:

     flutter pub add melos --dev
    
Note:
When incorporating a tool like Melos into a project, it is typically added as a development dependency. This is because Melos serves the purpose of aiding the development workflow and doesn't have a direct impact on the production code. By adding Melos as a development dependency, it ensures that it will be ignored and not included in the final production build. As a rule of thumb, if a dependency is going to be imported within the lib/ directory of the project, it should be added as a regular dependency. On the other hand, if the dependency is only required for development-related tasks, such as build automation or testing, it should be added as a dev dependency. By following this approach, you can maintain a clear separation between the dependencies needed for production code functionality and those used solely for development purposes. This helps in keeping the production build lean and efficient, while still having the necessary development tools and utilities available during the development process. For more detailed information, you can refer to the resources provided here and here.

Next is to configure your Melos workspace. To configure your Melos workspace, you need to create a melos.yaml file at the root of your repository. In the melos.yaml file:

name: <project>

packages:
  - packages/package-1/
  - packages/package-2/

The name field is required and should be set to the name of your project. The packages field specifies the list of sub-projects that will be managed by melos, and should contain paths to individual packages in the repository.

Note: To provide more flexibility in defining the package paths, you can use glob patterns instead of explicitly listing each package. Glob patterns allow you to specify patterns that match multiple files or directories based on specific criteria. So we replace the packages field with:

...
packages:
  - packages/*

The packages/* pattern matches all directories within the packages directory, effectively including all directories under packages. It simply means include all packages inside the packages directory.

This approach allows for easier management of package paths, especially when dealing with a larger number of sub-projects within your monorepo.

The next thing to do is to bootstrap melos. From the documentation, bootstrapping serves two purposes:

  1. Installing package dependencies: The bootstrap process ensures that all package dependencies are installed. It runs the equivalent of flutter pub get or dart pub get across all packages in your monorepo, fetching the required dependencies for each package.

  2. Locally linking packages: Additionally, the bootstrap process establishes local linking between packages. This linking allows you to depend on a package within your monorepo without explicitly using a file path. Instead, the package is treated as if it were fetched from a remote source like Pub, but it is available locally within your monorepo.

To bootstrap Melos, you can use the following command:

melos bootsrap

By running melos bootstrap whenever there are new packages added to your monorepo, you ensure that the dependencies are correctly installed and linked, maintaining a consistent development environment across all packages.

Once bootstrapping is completed, the packages within the monorepo can establish dependencies on each other without the need for explicit file paths. This enables package-1 to depend on package-2 directly in its pubspec.yaml file.

However, it's important to avoid circular dependencies, where package-1 depends on package-2 and vice versa. Circular dependencies can lead to code complexities and make it harder to maintain the codebase.

To mitigate circular dependencies, it's recommended to abstract common libraries or shared functionality into a separate package. This shared package can then be included as a dependency in both package-1 and package-2, allowing them to access the common libraries without creating a circular dependency.

By abstracting shared functionality into a separate package, you promote code reuse and maintain a modular and manageable monorepo structure. It also helps in avoiding conflicts and inconsistencies that may arise from circular dependencies.

By adhering to this practice, you can create a well-organized monorepo where packages have clear and independent responsibilities, reducing the complexity and improving the maintainability of your codebase.

The melos file has another field scripts. Let's dive into a practical example of how the scripts field in the melos.yaml file can be used to streamline package-wide commands.

Suppose you want to run dart pub get across all packages simultaneously. Without Melos, you would need to navigate to each package directory and execute dart pub get individually, which can be time-consuming and repetitive.

With Melos and the scripts field, you can define a custom script that runs the command across all packages at once.

Here's an example of how you can define a get script in melos.yaml file:

Declare another field called scripts:

...
scripts:
  get:
    exec: dart pub get

We have defined a script called get. Melos will execute the defined script for each package in the monorepo, effectively running dart pub get across all packages simultaneously.

By leveraging scripts in Melos, you can significantly speed up development time by executing commands across packages without the need for manual navigation. This helps maintain a consistent and efficient workflow within your monorepo.

To run this script, simply open the terminal and run

melos run get

There are two ways to define and run a script with slight differences. Using run and exec. We can rewrite the script above with:

...
scripts:
  get:
    run: dart pub get

If we wanted to run this script across all packages, we would run in the terminal:

melos exec "melos run get"

This syntax can also be used to run multiple commands:

melos exec "melos run get && <another script>"

run and exec can also be used together when defining a script. If you wanted to define options for your script, you will use exec to define the options and run for the command. An example is shown in the melos docs:

...
scripts:
  get:
    run: dart pub get
    exec:
      concurrency: 1

The concurrency option defines how many packages will execute the command dart pub get at a time. It defaults to 5. Another option is failFast, this is a boolean. It basically says, if executing a script fails in one package, should I continue executing the script in other packages or not? The default value is false.
Another useful option is orderDependents, more about it here.

Note:
You can execute commands without defining a script. We could execute another command without defining it as a script with: melos exec "dart analyze". This will execute the dart analyze command across packages.

Note that you can define multiple scripts for different commands.

All the instructions provided here are readily available in the official documentation and offer more comprehensive details. This article aims to serve as a guiding resource to help you grasp the benefits of using Melos for managing your monorepo. For a comprehensive understanding and access to additional information, I strongly encourage you to read the official documentation. It provides in-depth explanations, further instructions, and advanced features that may not be covered in this guide. Familiarizing yourself with the official documentation will enable you to leverage Melos to its full potential and enhance your monorepo management workflow effectively.

Don't forget to include the melos badge in your README file as suggested here.

If you have any observations or insights you would like to share, please feel free to do so. Your input is valuable.

While writing this article, I encountered an issue with bootstrapping melos. After trying to find out what I was doing wrong, I realized my project structure was the problem, this was after I went through different projects on pub that are managed by melos. It appears it is how a dart repo with sub-projects should be structured (the example at the beginning) but I could not find anything in the melos or dart documentation about project structure. I would appreciate any information or some clarity about this.

Resources:

melos documentation:

https://pub.dev/packages/melos

https://melos.invertase.dev/~melos-latest/

video on melos by Lukas:

https://www.youtube.com/watch?v=fkgTXV3bHZk

monorepo:

https://www.youtube.com/watch?v=flbz_5aMikw

others:

https://www.youtube.com/watch?v=rv4LlmLmVWk

Did you find this article valuable?

Support David Nwaneri by becoming a sponsor. Any amount is appreciated!