Perché un monorepo

Quando un progetto Flutter cresce, prima o poi ci si ritrova con più artefatti da mantenere: l'app mobile, magari una variante "admin", un design system condiviso, un client HTTP, un pacchetto di modelli di dominio. Tenerli in repository separati significa gestire pubblicazioni, tag di versione e allineamento delle dipendenze a mano, con il rischio classico: la modifica al pacchetto core rompe l'app e ce ne accorgiamo tre giorni dopo.

Il monorepo risolve il problema mettendo tutto sotto lo stesso versionamento Git: una singola PR può modificare contemporaneamente il pacchetto condiviso e le app che lo usano, e la CI valida il tutto insieme. Il prezzo da pagare è la gestione operativa: eseguire flutter pub get in dieci cartelle, lanciare i test ovunque, tenere in ordine i changelog. È esattamente qui che entra in gioco Melos.

Che cos'è Melos

Melos è un CLI scritto in Dart (mantenuto da Invertase) che orchestra monorepo Dart e Flutter. In sintesi fa quattro cose:

  • collega i pacchetti locali tra loro senza dover scrivere a mano decine di path: nelle dipendenze;
  • esegue comandi in parallelo su tutti i pacchetti, con filtri potenti;
  • gestisce versioni e changelog a partire dai Conventional Commits;
  • pubblica i pacchetti pubblici su pub.dev.

Installazione globale:

dart pub global activate melos
melos --version

Struttura tipica di un workspace

my_workspace/
├── melos.yaml
├── pubspec.yaml            # root (publish_to: none)
├── apps/
│   ├── mobile/             # app Flutter principale
│   └── admin/              # seconda app Flutter
└── packages/
    ├── core_models/        # pacchetto Dart puro
    ├── core_network/       # client HTTP condiviso
    └── design_system/      # widget e tema condivisi

La regola pratica: dentro packages/ mettiamo codice riutilizzabile e senza dipendenze dalle app, dentro apps/ gli eseguibili. Un pacchetto Dart puro (senza flutter) si testa più velocemente e si riusa anche lato backend o CLI.

Il file melos.yaml

È il cuore della configurazione:

name: my_workspace

packages:
  - apps/**
  - packages/**

ignore:
  - "**/example/**"

command:
  bootstrap:
    # dipendenze applicate a TUTTI i pacchetti del workspace
    environment:
      sdk: ">=3.5.0 <4.0.0"
      flutter: ">=3.24.0"
    dependencies:
      collection: ^1.18.0
    dev_dependencies:
      very_good_analysis: ^6.0.0
  version:
    linkToCommits: true
    workspaceChangelog: true
    branch: main
    message: "chore(release): publish packages\n\n{new_package_versions}"

Due punti importanti:

  • la sezione command.bootstrap.dependencies permette di centralizzare le versioni delle dipendenze comuni: Melos le scrive nei pubspec.yaml dei pacchetti durante il bootstrap, evitando derive di versione tra un pacchetto e l'altro;
  • ignore esclude cartelle che non vogliamo trattare come pacchetti (tipicamente gli example dei plugin).

Da Melos 6.x la configurazione può vivere anche direttamente nel pubspec.yaml di root sotto la chiave melos:, utile se preferite un file in meno.

Bootstrap: il comando che userete ogni giorno

melos bootstrap   # oppure: melos bs

bootstrap esegue il pub get su tutti i pacchetti e, soprattutto, collega i pacchetti locali generando un file pubspec_overrides.yaml in ciascuna cartella:

# packages/design_system/pubspec_overrides.yaml (generato da Melos)
dependency_overrides:
  core_models:
    path: ../core_models

Il vantaggio rispetto ai vecchi override scritti a mano è che il pubspec.yaml resta pulito e pubblicabile: dentro dichiarate la versione reale (core_models: ^1.2.0), mentre in locale lavorate sempre sul codice del repository. Ricordatevi di aggiungere a .gitignore:

pubspec_overrides.yaml

Nel codice non cambia nulla: gli import restano quelli di un normale pacchetto.

import 'package:core_models/core_models.dart';
import 'package:design_system/design_system.dart';

class ProductTile extends StatelessWidget {
  const ProductTile({required this.product, super.key});

  final Product product; // definito in core_models

  @override
  Widget build(BuildContext context) {
    return DsCard(   // widget del design system condiviso
      title: product.name,
      subtitle: product.formattedPrice,
    );
  }
}

Script: automatizzare i task ripetitivi

La sezione scripts trasforma Melos in un piccolo task runner condiviso da tutto il team.

scripts:
  analyze:
    description: Analisi statica su tutti i pacchetti
    run: melos exec -c 5 -- dart analyze . --fatal-infos

  format:
    run: dart format --set-exit-if-changed .

  test:
    description: Test unitari e widget
    run: melos exec -c 4 --dir-exists=test -- flutter test --coverage

  gen:
    description: build_runner sui pacchetti che lo usano
    run: melos exec -c 1 --depends-on=build_runner -- \
      dart run build_runner build --delete-conflicting-outputs

  ci:
    description: Pipeline locale completa
    run: melos run format && melos run analyze && melos run test

Esecuzione:

melos run analyze
melos run test
melos run gen

Alcune opzioni utili di melos exec:

  • -c N / --concurrency N: quanti pacchetti in parallelo (usate -c 1 per i comandi che scrivono file, come build_runner);
  • --fail-fast: interrompe al primo errore;
  • --order-dependents: rispetta l'ordine del grafo delle dipendenze.

Filtri: lavorare solo su ciò che serve

Su un monorepo grande, rieseguire tutto è uno spreco. Melos offre filtri combinabili:

# solo il design system
melos exec --scope="design_system" -- flutter test

# tutto tranne le app
melos exec --ignore="apps/**" -- dart analyze .

# solo i pacchetti che dipendono da core_models
melos exec --depends-on="core_models" -- flutter test

# solo i pacchetti modificati rispetto a main (perfetto in CI)
melos exec --diff=origin/main -- flutter test

# solo i pacchetti pubblicabili
melos exec --no-private -- dart pub publish --dry-run

Il filtro --diff è quello che fa davvero la differenza sui tempi di build: nella PR eseguite i test solo sui pacchetti toccati e sui loro dipendenti.

Versionamento e changelog automatici

Melos legge i Conventional Commits e calcola le nuove versioni:

feat(core_network): aggiunge interceptor di retry
fix(design_system): corregge il padding di DsCard
refactor(core_models)!: rinomina Product.price in Product.amount
  • fix: → bump patch
  • feat: → bump minor
  • ! o BREAKING CHANGE: → bump major

Poi basta:

melos version           # calcola versioni, aggiorna CHANGELOG, crea commit e tag
melos version --dry-run # anteprima senza scrivere nulla
melos publish           # dry-run di default; --no-dry-run per pubblicare davvero

Melos propaga automaticamente il bump ai pacchetti dipendenti: se core_models passa a 2.0.0, i vincoli in design_system e nelle app vengono aggiornati e viene generata anche una voce di changelog "dependency update". Con workspaceChangelog: true ottenete inoltre un CHANGELOG.md di root con il riepilogo della release.

Se i pacchetti non vanno su pub.dev, ricordatevi publish_to: none nei loro pubspec.yaml: Melos li tratterà come privati e li escluderà dalla pubblicazione pur continuando a versionarli.

Melos e i pub workspaces di Dart 3.6

Da Dart 3.6 il tool pub supporta nativamente i workspaces: una sola risoluzione delle dipendenze e un unico .dart_tool/package_config.json per l'intero repository, con pub get molto più rapido. Si configura così nel pubspec.yaml di root:

name: my_workspace
publish_to: none
environment:
  sdk: ^3.6.0
workspace:
  - apps/mobile
  - apps/admin
  - packages/core_models
  - packages/core_network
  - packages/design_system

E in ogni pacchetto figlio:

name: core_models
environment:
  sdk: ^3.6.0
resolution: workspace

I due strumenti non sono alternativi: le versioni recenti di Melos riconoscono i pub workspaces e, quando sono attivi, delegano a pub la risoluzione (niente più pubspec_overrides.yaml generati), continuando a offrire script, filtri, versionamento e changelog. È la combinazione consigliata sui progetti nuovi con SDK ≥ 3.6.

Un avvertimento pratico: con i workspaces la risoluzione è unica, quindi tutti i pacchetti devono essere compatibili con lo stesso set di versioni. Se avete bisogno di versioni divergenti della stessa dipendenza, restate sull'approccio classico con gli override.

Integrazione con la CI

Un workflow GitHub Actions minimale ma efficace:

name: CI
on:
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # necessario per --diff

      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      - name: Install Melos
        run: dart pub global activate melos

      - name: Bootstrap
        run: melos bootstrap

      - name: Format & analyze
        run: melos run format && melos run analyze

      - name: Test (solo pacchetti modificati)
        run: melos exec --diff=origin/main --dir-exists=test -- flutter test

Note operative:

  • fetch-depth: 0 è indispensabile perché --diff possa confrontare i branch;
  • mettete in cache ~/.pub-cache per accorciare i tempi;
  • se pubblicate su pub.dev, aggiungete un job separato su main che esegue melos version e melos publish --no-dry-run con le credenziali OIDC.

Supporto negli IDE

Melos genera automaticamente le configurazioni di avvio per VS Code e IntelliJ/Android Studio:

ide:
  intellij:
    enabled: true

Su VS Code conviene inoltre creare un file my_workspace.code-workspace con le cartelle dei singoli pacchetti: l'analyzer di Dart lavora meglio quando ogni pacchetto è una root del workspace, evitando falsi errori sugli import.

Errori comuni da evitare

  1. Dimenticare melos bootstrap dopo un git pull: se qualcuno ha aggiunto un pacchetto, gli override locali sono obsoleti. Rendetelo parte del vostro make setup o di un hook post-merge.
  2. Committare i pubspec_overrides.yaml: sono artefatti locali, vanno in .gitignore.
  3. Dipendenze circolari tra pacchetti: Melos le segnala, ma il vero problema è di design. Tenete un grafo aciclico: app → feature → core, mai il contrario.
  4. Un unico pacchetto "utils" tuttofare: diventa in fretta il collo di bottiglia che invalida la cache di build ad ogni modifica. Meglio pacchetti piccoli e coesi.
  5. build_runner in parallelo: usate sempre -c 1 o --order-dependents per evitare conflitti sui file generati.
  6. Versionare a mano: se scrivete i Conventional Commits, lasciate calcolare tutto a melos version; i changelog manuali si disallineano sempre.

Conclusione

Melos non è un framework né cambia il modo in cui scrivete Flutter: è l'infrastruttura che rende sostenibile un monorepo. Con un melos.yaml di venti righe ottenete link automatici tra pacchetti, script condivisi da tutto il team, esecuzione mirata dei test in CI e release versionate senza intervento manuale.

Se state avviando un progetto nuovo su Dart 3.6 o superiore, partite direttamente con i pub workspaces più Melos per script e versionamento: è la configurazione più veloce e con meno file generati. Su progetti esistenti, il passaggio a Melos si può fare in modo incrementale, un pacchetto alla volta, iniziando a estrarre il design system o i modelli di dominio.