Monorepo Flutter con Melos: package interni e confini tra moduli

Foto di Google DeepMind su Unsplash

Best practiceAvanzato60 min Flutter 3.27+

Monorepo Flutter con Melos: package interni e confini tra moduli

Quando un'app Flutter cresce, il classico progetto monolitico con una cartella lib/ piena di feature diventa ingestibile: i tempi di analisi si allungano, i confini tra moduli evaporano e ogni refactoring rischia di toccare tutto.

La risposta più solida è il monorepo: un unico repository che contiene l'app (o più app) e una serie di package interni versionati e testabili in modo indipendente. Melos è lo strumento standard dell'ecosistema Dart/Flutter per orchestrare questo scenario: collega i package tra loro, esegue comandi in parallelo su tutto il workspace, gestisce versioning e changelog basati sui conventional commit.

In questo tutorial avanzato costruiremo da zero un monorepo realistico con questa struttura:

acme/
├── melos.yaml
├── pubspec.yaml          # workspace root
├── apps/
│   └── mobile/           # app Flutter
├── packages/
│   ├── core/             # modelli, networking, utility (Dart puro)
│   ├── design_system/    # widget e tema condivisi (Flutter)
│   ├── feature_auth/
│   └── feature_catalog/
└── tool/
    └── check_boundaries.dart

Vedremo anche come rendere i confini architetturali eseguibili: non basta scriverli in un documento, devono fallire in CI quando qualcuno li viola.

Prerequisiti: Flutter 3.27+ / Dart 3.6+ (per i pub workspaces), dimestichezza con pubspec.yaml, CLI e GitHub Actions.

  1. 1

    Installare Melos e creare la radice del workspace

    Melos si installa come pacchetto globale, ma va sempre dichiarato anche come dev_dependency alla radice del repository: in questo modo tutti gli sviluppatori e la CI usano la stessa versione.

    dart pub global activate melos
    

    Crea la cartella del monorepo e il pubspec.yaml di root. Da Dart 3.6 possiamo sfruttare i pub workspaces: un unico pubspec.lock e una sola cartella .dart_tool/ condivisa, con risoluzione delle dipendenze coerente tra tutti i package.

    Punti chiave del file di root:

    • publish_to: none — la radice non finisce mai su pub.dev;
    • la sezione workspace: elenca i path dei package membri;
    • melos come dev dependency, così dart run melos ... funziona ovunque.

    Se usi ancora Dart < 3.6, salta la sezione workspace: e lascia che sia il melos bootstrap classico a creare i link tra i package: il resto del tutorial resta valido.

    # pubspec.yaml (radice del monorepo)
    name: acme_workspace
    publish_to: none
    
    environment:
      sdk: ^3.6.0
    
    # Dart pub workspaces: un solo lockfile per tutto il monorepo
    workspace:
      - apps/mobile
      - packages/core
      - packages/design_system
      - packages/feature_auth
      - packages/feature_catalog
    
    dev_dependencies:
      melos: ^6.3.0

    Risultato atteso

    Eseguendo `melos --version` ottieni la versione installata; la radice del repository contiene un `pubspec.yaml` che dichiara il workspace.

  2. 2

    Creare i package interni e collegarli tra loro

    Ogni modulo è un package Dart o Flutter a sé stante. La regola pratica:

    • Dart puro (dart create -t package) per logica che non tocca il framework: modelli, parsing, client HTTP, use case. Si testa in millisecondi con dart test.
    • Flutter package (flutter create -t package) per widget, tema, feature UI.
    mkdir -p apps packages
    flutter create --template=app --org com.acme apps/mobile
    dart create --template=package packages/core
    flutter create --template=package packages/design_system
    flutter create --template=package packages/feature_auth
    flutter create --template=package packages/feature_catalog
    

    In ogni package membro aggiungi resolution: workspace e dichiara le dipendenze interne senza path:: con i pub workspaces (o con melos bootstrap) la risoluzione locale è automatica.

    Usa inoltre la convenzione barrel file + cartella src/: tutto ciò che non è esportato dal file pubblico lib/<nome>.dart è codice privato del modulo. Dart lo sa: importare package:core/src/... da un altro package attiva il lint implementation_imports, che nel prossimo step trasformeremo in errore.

    # packages/feature_auth/pubspec.yaml
    name: feature_auth
    description: Feature di autenticazione dell'app Acme.
    version: 0.1.0
    publish_to: none
    resolution: workspace
    
    environment:
      sdk: ^3.6.0
      flutter: ">=3.27.0"
    
    dependencies:
      flutter:
        sdk: flutter
      core: ^0.1.0            # risolto dal workspace
      design_system: ^0.1.0
    
    dev_dependencies:
      flutter_test:
        sdk: flutter
      very_good_analysis: ^6.0.0
    
    # ---------------------------------------------
    # packages/feature_auth/lib/feature_auth.dart (barrel)
    # library feature_auth;
    #
    # export 'src/presentation/login_page.dart' show LoginPage;
    # export 'src/domain/auth_repository.dart' show AuthRepository;
    # NIENTE altro: il resto resta interno al modulo.

    Risultato atteso

    `melos bootstrap` (o `dart pub get` alla radice) risolve tutte le dipendenze interne senza riferimenti `path:` e genera un unico `pubspec.lock`.

  3. 3

    Configurare melos.yaml: package, environment condiviso e IDE

    Il file melos.yaml alla radice è il cervello del monorepo. Tre sezioni fanno la differenza in un progetto reale:

    1. packages — i glob che definiscono cosa fa parte del workspace (e ignore per escludere esempi o build).
    2. command.bootstrap.environment/dependencies/dev_dependencies — la centralizzazione delle versioni: Melos riscrive i pubspec.yaml dei package che dichiarano quelle dipendenze, allineandoli al valore centrale. Addio a 12 package con 4 versioni diverse di intl.
    3. command.bootstrap.hooks — per lanciare automaticamente la code generation dopo il bootstrap.

    Dopo la configurazione lancia:

    melos bootstrap   # alias: melos bs
    

    Melos risolve le dipendenze di tutto il workspace, genera i file di configurazione per IntelliJ/VS Code e ti mostra il grafo dei package.

    Da Melos 6 puoi anche spostare l'intera configurazione dentro il pubspec.yaml di root sotto la chiave melos:. Tenere un file separato resta più leggibile quando gli script diventano molti.

    # melos.yaml
    name: acme
    repository: https://github.com/acme/acme_app
    
    packages:
      - apps/*
      - packages/*
    
    ignore:
      - "**/example/**"
    
    command:
      bootstrap:
        # Versioni centralizzate: propagate a tutti i package che le usano
        environment:
          sdk: ^3.6.0
          flutter: ">=3.27.0"
        dependencies:
          intl: ^0.19.0
          dio: ^5.7.0
        dev_dependencies:
          very_good_analysis: ^6.0.0
          build_runner: ^2.4.13
          mocktail: ^1.0.4
        hooks:
          post: melos run generate --no-select
    
      version:
        linkToCommits: true
        workspaceChangelog: true
        branch: main

    Risultato atteso

    `melos bootstrap` termina con l'elenco dei package trovati (app + package interni) e le versioni delle dipendenze condivise risultano allineate in tutti i pubspec.

  4. 4

    Rendere eseguibili i confini tra moduli

    Un monorepo senza regole degenera in un monolite distribuito. Definiamo quattro layer e le dipendenze consentite:

    Layer Può dipendere da
    core nessun package interno
    design_system core
    feature_* core, design_system
    apps/* tutti

    La regola più importante è quella implicita: una feature non può dipendere da un'altra feature. Se due feature devono parlarsi, il contratto (interfaccia, evento, rotta) sale in core.

    Due livelli di enforcement:

    1. Analyzer: in analysis_options.yaml alla radice promuovi implementation_imports a error, così nessuno può importare package:core/src/... bypassando il barrel file.
    2. Script di verifica del grafo: uno script Dart che legge tutti i pubspec.yaml e fallisce quando una dipendenza viola la tabella. Va eseguito in CI.

    Ricorda di aggiungere yaml: ^3.1.2 alle dev dependencies della radice per far girare lo script.

    // tool/check_boundaries.dart
    import 'dart:io';
    
    import 'package:yaml/yaml.dart';
    
    /// Layer -> layer da cui e' lecito dipendere.
    const _allowed = <String, Set<String>>{
      'core': <String>{},
      'design_system': {'core'},
      'feature': {'core', 'design_system'},
      'app': {'core', 'design_system', 'feature'},
    };
    
    String _layerOf(String pkg) => switch (pkg) {
          'core' => 'core',
          'design_system' => 'design_system',
          _ when pkg.startsWith('feature_') => 'feature',
          _ => 'app',
        };
    
    void main() {
      final files = Directory.current
          .listSync(recursive: true)
          .whereType<File>()
          .where((f) =>
              f.uri.pathSegments.last == 'pubspec.yaml' &&
              !f.path.contains('.dart_tool') &&
              !f.path.contains('build/'));
    
      final internal = <String, YamlMap>{};
      for (final file in files) {
        final spec = loadYaml(file.readAsStringSync());
        if (spec is YamlMap && spec['name'] is String) {
          internal[spec['name'] as String] = spec;
        }
      }
    
      var violations = 0;
      internal.forEach((name, spec) {
        final layer = _layerOf(name);
        final deps = <String>[
          ...?(spec['dependencies'] as YamlMap?)?.keys.whereType<String>(),
        ];
        for (final dep in deps) {
          if (dep == name || !internal.containsKey(dep)) continue;
          final depLayer = _layerOf(dep);
          if (!_allowed[layer]!.contains(depLayer)) {
            stderr.writeln('X  $name ($layer) non puo dipendere da $dep ($depLayer)');
            violations++;
          }
        }
      });
    
      if (violations > 0) {
        stderr.writeln('\n$violations violazione/i dei confini architetturali.');
        exit(1);
      }
      stdout.writeln('OK: confini architetturali rispettati.');
    }

    Risultato atteso

    `dart run tool/check_boundaries.dart` stampa "OK"; se aggiungi `feature_catalog` alle dipendenze di `feature_auth`, lo script esce con codice 1 e indica la violazione.

  5. 5

    Script Melos per analisi, test e coverage aggregata

    Gli script sono il vero moltiplicatore di produttività di Melos: melos exec esegue un comando in ogni package, con filtri e parallelismo.

    Filtri che userai di continuo:

    • --flutter / --no-flutter: separa i package Flutter da quelli Dart puro (fondamentale: flutter test fallisce su un package Dart puro e viceversa);
    • --dir-exists=test: salta i package senza test;
    • --diff=origin/main: esegue solo sui package modificati rispetto a un branch, dimezzando i tempi di CI;
    • -c N: grado di concorrenza (-c 1 per output leggibile).

    Per la coverage, ogni package produce il proprio coverage/lcov.info: li uniamo in un unico report. Assicurati di avere lcov installato (brew install lcov / apt-get install lcov).

    Uso quotidiano:

    melos run analyze
    melos run test
    melos run test:changed   # solo i package toccati dalla tua branch
    melos run ci
    
    # melos.yaml (sezione scripts)
    scripts:
      format:
        run: dart format --set-exit-if-changed .
        description: Verifica la formattazione di tutto il monorepo.
    
      analyze:
        run: melos exec -c 1 -- dart analyze . --fatal-infos
        description: Analisi statica su ogni package.
    
      boundaries:
        run: dart run tool/check_boundaries.dart
        description: Verifica le regole di dipendenza tra i layer.
    
      generate:
        run: melos exec -c 1 --depends-on=build_runner -- \
          dart run build_runner build --delete-conflicting-outputs
    
      test:dart:
        run: melos exec -c 4 --no-flutter --dir-exists=test -- dart test
    
      test:flutter:
        run: melos exec -c 4 --flutter --dir-exists=test -- flutter test
    
      test:
        run: melos run test:dart --no-select && melos run test:flutter --no-select
    
      test:changed:
        run: melos exec -c 4 --diff=origin/main --dir-exists=test -- flutter test
    
      coverage:
        run: |
          melos exec -c 4 --flutter --dir-exists=test -- \
            flutter test --coverage && \
          mkdir -p coverage && \
          lcov $(find . -path '*/coverage/lcov.info' -printf '-a %p ') \
            -o coverage/lcov.info && \
          genhtml coverage/lcov.info -o coverage/html
    
      ci:
        run: melos run format && melos run analyze && melos run boundaries && melos run test

    Risultato atteso

    `melos run ci` esegue in sequenza formattazione, analisi, controllo dei confini e test di tutti i package, fallendo al primo errore.

  6. 6

    Versionamento automatico e changelog con conventional commits

    Melos calcola le nuove versioni a partire dai conventional commit e dal grafo delle dipendenze: se core passa da 0.1.0 a 0.2.0, tutti i package che dipendono da core ricevono automaticamente un bump di patch e una riga nel changelog.

    Convenzione dei messaggi (lo scope è opzionale ma molto utile, di solito è il nome del package):

    • feat(feature_auth): aggiunge login biometrico → minor
    • fix(core): gestisce il timeout della richiesta → patch
    • refactor(design_system)!: rinomina AppButton (o footer BREAKING CHANGE:) → major
    • chore: ..., docs: ... → nessun bump

    Comandi:

    # Anteprima: mostra cosa verrebbe versionato, senza scrivere nulla
    melos version --no-git-tag-version --no-changelog --diff=origin/main --dry-run
    
    # Versionamento reale: aggiorna pubspec, CHANGELOG, crea commit e tag
    melos version --yes
    
    # Pubblicazione dei soli package non privati (publish_to: none esclusi)
    melos publish --dry-run
    

    Con workspaceChangelog: true Melos genera anche un CHANGELOG.md aggregato alla radice, perfetto per le release note dell'app. Se alcuni package restano privati (tipico delle feature interne) melos publish li salta automaticamente grazie a publish_to: none.

    Per casi particolari puoi forzare una versione: melos version core 0.3.0 --yes.

    # Esempio di output di `melos version --dry-run`
    #
    # Melos will version the following packages:
    #
    #  core: 0.1.0 -> 0.2.0
    #    feat(core): nuovo ApiClient con retry
    #  design_system: 0.1.0 -> 0.1.1
    #    (dependency update: core)
    #  feature_auth: 0.1.0 -> 0.1.1
    #    (dependency update: core, design_system)
    #
    # git commit -m "chore(release): publish packages"
    # git tag core-v0.2.0 design_system-v0.1.1 feature_auth-v0.1.1

    Risultato atteso

    Dopo `melos version --yes` trovi i `pubspec.yaml` aggiornati, un `CHANGELOG.md` per package, uno aggregato alla radice, un commit di release e i tag Git per ogni package versionato.

  7. 7

    Integrare il monorepo in CI con GitHub Actions

    L'ultimo tassello è la pipeline. Tre accorgimenti specifici per un monorepo:

    1. fetch-depth: 0 nel checkout: senza la storia completa, --diff=origin/main e il calcolo delle versioni non funzionano.
    2. Cache del pub cache: con i pub workspaces c'è un solo pubspec.lock, quindi la chiave di cache è banale ed efficacissima.
    3. Job di release separato, attivo solo su main, che esegue melos version con un bot Git.

    Problemi frequenti e soluzioni:

    • «Melos non vede i miei package» → controlla i glob in packages: e che ogni package abbia name: nel pubspec; melos list -l mostra cosa è stato rilevato.
    • Analyzer lento o incoerente nell'IDE → rilancia melos bootstrap e riapri il progetto: i file .idea//.vscode/ con i moduli vengono rigenerati.
    • Conflitti di versione tra package → usa command.bootstrap.dependencies per centralizzare, non dependency_overrides sparsi.
    • Build dell'app → la cartella su cui lanciare flutter build resta apps/mobile; nulla cambia rispetto a un progetto singolo.

    Da qui in poi il monorepo scala: aggiungere una feature significa creare un package, dichiararlo nel workspace e lasciare che gli script e il controllo dei confini facciano il resto.

    # .github/workflows/ci.yml
    name: CI
    
    on:
      pull_request:
        branches: [main]
      push:
        branches: [main]
    
    jobs:
      verify:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
    
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: '3.27.x'
              channel: stable
              cache: true
    
          - name: Cache pub
            uses: actions/cache@v4
            with:
              path: ~/.pub-cache
              key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
    
          - name: Install Melos
            run: dart pub global activate melos
    
          - name: Bootstrap
            run: melos bootstrap
    
          - name: Quality gate
            run: melos run ci
    
          - name: Build app
            run: flutter build apk --debug
            working-directory: apps/mobile
    
      release:
        needs: verify
        if: github.ref == 'refs/heads/main'
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
              token: ${{ secrets.RELEASE_TOKEN }}
          - uses: subosito/flutter-action@v2
            with: { flutter-version: '3.27.x', channel: stable }
          - run: dart pub global activate melos && melos bootstrap
          - name: Version packages
            run: |
              git config user.name "acme-bot"
              git config user.email "bot@acme.dev"
              melos version --yes --no-private
              git push --follow-tags

    Risultato atteso

    Ogni pull request esegue formattazione, analisi, controllo dei confini, test di tutti i package e build dell'app; il merge su main produce automaticamente versioni, changelog e tag.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!