Modern Monorepo Management with Pub Workspaces and Melos in Dart

Modern Monorepo Management with Pub Workspaces and Melos in Dart

Monorepos store multiple projects in a single repository, providing benefits such as atomic changes, shared dependencies, and unified workflows. However, they can also introduce complexity when it comes to managing various packages and apps. In this article, we’ll explore how to effectively manage a monorepo in the Dart/Flutter ecosystem.

Table of Contents

  1. Public vs Local Packages
  2. Managing Dependencies with Pub Workspaces
  3. Creating Reusable Scripts
  4. Using Melos for Large Monorepos
  5. Conclusion (What and When to Use)

Public vs Local Packages

A monorepo typically has two main categories of projects:

  1. Public packages:
    These are released to a public or private registry (for instance, pub.dev). They might include shared packages, reusable frontend widgets, backend libraries, or CLI tools.
  2. Local (internal) packages:
    These exist solely for use within the same repository. They commonly provide code like UI components, database abstractions, utility functions, shared features and so on.

Typical Monorepo Structure

A monorepo can be organized in various ways, depending on the number of "main" projects it contains.

When there’s only one main application:

repo/
├── lib/        # Contains the main application
│   └── main.dart
└── packages/   # Contains reusable local packages
    ├── ui_library/
    ├── database/
    └── core_utils/

When there are multiple applications:

repo/
├── apps/       # Contains multiple Flutter/Dart apps
│   ├── app_1/
│   └── app_2/
└── packages/   # Contains reusable local packages
    ├── ui_library/
    ├── database/
    └── core_utils/

Grouping Public Packages

Public packages can also be grouped in a single repository to keep related libraries together. For example, the Bloc repository organizes multiple packages under packages/, such as:

repo/
└── packages/
    ├── bloc/
    ├── flutter_bloc/
    └── bloc_concurrency/

This approach simplifies making atomic updates across multiple packages, as changes can be applied simultaneously. It also makes management easier since the packages are logically grouped, often sharing a common dependency, like a "bloc." When the "bloc" changes, the dependent packages should be updated as well.


Managing Dependencies with Pub Workspaces

Pub Workspaces, introduced in Dart 3.6 and Flutter 3.27, simplify dependency management in monorepos by organizing multiple projects as a workspace. Here are the key benefits:

  • Centralized Dependency Management: All projects in the workspace share a single version of each dependency, ensuring consistency across the monorepo. dart pub get downloads dependencies for all packages in workspace.
  • Unified Upgrades: Running dart pub upgrade applies updates across all packages in the workspace.
  • Improved Dart Analyzer Performance: Issues with monorepos causing degraded analyzer performance have been resolved, making analysis faster and more efficient.

This makes managing monorepos with Dart and Flutter significantly smoother and more efficient.

Setting up a Pub Workspace

Suppose you have the following directory layout:

repo/
├── lib/          # Main application
│   └── main.dart
├── packages/     # Reusable local packages
│   ├── ui_library/
│   ├── database/
│   └── core_utils/
└── pubspec.yaml  # Workspace root

Add the resolution: workspace field to each child package’s pubspec.yaml:

name: database
description: "Database for the app"
version: 1.0.0
publish_to: none

environment:
  sdk: ">=3.6.0 <4.0.0"
  flutter: ">=3.27.1 <4.0.0"

# Use shared dependency resolution
resolution: workspace

Add the workspace field to the root pubspec.yaml:

name: sizzle_starter
description: A production-ready template for Flutter applications.
publish_to: "none"
version: 0.0.1+1

environment:
  sdk: ">=3.6.0 <4.0.0"
  flutter: ">=3.27.1 <4.0.0"

workspace:
  - packages/ui_library
  - packages/database
  - packages/core_utils

Once set up, a single dart pub get at the workspace root resolves dependencies for every package in the repository—no more running the command for each package separately.

Version Mismatch

With a Pub Workspace, only one version of each package is allowed across the entire codebase. If a mismatch occurs, you’ll see an error like:

Because <A> depends on sizzle_lints 2.1.0 and <B> 
depends on sizzle_lints ^2.1.3, version solving failed.

By design, this forces you to keep versions in sync, reducing the likelihood of hidden dependency conflicts.

Unified Upgrades

  • dart pub upgrade updates all dependencies in the workspace.
  • dart pub upgrade --major-versions and dart pub upgrade --tighten also work at the workspace level, updating constraints in all relevant pubspec.yaml files.

For more details, check out the official Pub Workspaces documentation.


Creating Reusable Scripts

Dependency resolution is just one piece of a bigger puzzle. Many packages require additional commands—for example, running code generation via build_runner or executing scripts to clean, test, or bootstrap projects.

A robust way to handle these tasks is to create reusable scripts. Popular tools for this are Makefile, Taskfile, Shell/Bash scripts. I have included some examples:

Bootstrap Script

This script downloads dependencies for the entire workspace and detects if a package requires build_runner and triggers code generation only where needed:

#!/bin/bash
# Get workspace dependencies
flutter pub get

# For each package in 'packages/' that uses build_runner, run code generation
for dir in packages/*; do
  if [ -f "$dir/pubspec.yaml" ]; then
    if grep -q build_runner "$dir/pubspec.yaml"; then
      pushd $dir || exit
      dart run build_runner build --delete-conflicting-outputs
      popd || exit
    fi
  fi
done

Clean Script

This script cleans up the root project and all child packages, and fixes some local caching issues:

#!/bin/bash
# Clean the main Flutter project
flutter clean

# Clean each package
for dir in packages/*; do
  if [ -f "$dir/pubspec.yaml" ]; then
    pushd $dir || exit
    flutter clean
    popd || exit
  fi
done

Test and Coverage Script

This script automatically detects packages that contain tests, and then runs them with coverage enabled:

#!/bin/bash

# Exit immediately if a command exits with a non-zero status
set -e

# Find directories with a pubspec.yaml and test folder
find_test_dirs() {
  find . -type f -name "pubspec.yaml" -exec dirname {} \; | while read -r dir; do
    if [ -d "$dir/test" ]; then
      echo "$dir"
    fi
  done
}

test_dirs=$(find_test_dirs)
if [ -n "$test_dirs" ]; then
  flutter test $test_dirs --no-pub --coverage
else
  echo "No directories with pubspec.yaml and a test/ folder found."
fi

For small, internal-only monorepos, this script-based approach is simple and effective.


Using Melos for Large Monorepos

When a monorepo grows in size and complexity—especially if it contains multiple publishable packages, elaborate build steps, or complex CI/CD pipelines—managing everything with homemade scripts can become overwhelming. Enter Melos.

Melos used to handle some workspace-like features before Pub Workspaces were introduced, but it remains extremely valuable for:

  1. Automated Version Management: Supports automatic version bumps and changelog generation, driven by conventional commit messages.
  2. Flexible Scripting: Easily run commands across all packages, some packages, or filtered groups of packages. Useful when different subsets of packages need specialized tasks.

Getting Started with Melos

Install Melos in dev_dependencies of pubspec.yaml in the root and activate globally:

dart pub add -d melos
dart pub global activate melos

Create a melos.yaml file in the repository root (next to pubspec.yaml):

name: my_workspace

packages:
  - packages/*

scripts:
  example_script:
    description: "Script for demonstration"
    run: echo "Hello from Melos!"
  hello_flutter:
    exec: echo 'Hello from package that uses Flutter $(dirname $PWD)'
    packageFilters:
      flutter: true
  hello_build_runner:
    exec: echo 'Hello from package that uses build_runner $(dirname $PWD)'
    packageFilters:
      build_runner: true

This configuration also includes scripts with the packageFilters field, allowing targeted execution:

  • hello_flutter: Runs only in packages that use Flutter (i.e., packages with Flutter dependencies).
  • hello_build_runner: Runs only in packages that include the build_runner dependency in their pubspec.yaml.

You can run melos commands as follows:

# Executes the specified example script
melos run example_script

# Automatically handles versioning by examining all relevant commits and adjusting package versions as necessary.
melos version

...

Important: You no longer need to use melos bootstrap, because dependencies are now resolved through pub workspaces. Melos works seamlessly with them, focusing on higher-level tasks like publishing, versioning, changelogs, and script orchestration across a large set of packages. Find more info on their site.


Conclusion (What and When to Use)

Choosing the right setup for your Dart/Flutter monorepo depends on your project's size, complexity, and goals:

For Small to Medium Projects (with Internal Packages):

  • Use Pub Workspaces for simple dependency management.
  • Create lightweight scripts (e.g., Bash, Makefile) for tasks like code generation, testing, and cleaning.
  • This approach is straightforward, easy to maintain, and avoids unnecessary complexity.

For Large Projects or Teams:

  • Combine Pub Workspaces with a tool like Melos.
  • Make use of features like automated versioning, changelog generation, and advanced scripting.
  • Ideal for teams needing consistent publishing workflows, robust CI/CD, and coordination across multiple packages or squads.

Hybrid Approach:

  • Start with Pub Workspaces and basic scripts if your monorepo is still small.
  • Gradually adopt Melos as your needs grow for more structure, automation, and large-scale release management.

Read more