Confini tra moduli in un monorepo Flutter: contratti, regole di dipendenza e verifica automatica

Foto di Parsoa Khorsand su Unsplash

Best practiceAvanzato55 min Flutter 3.x

Confini tra moduli in un monorepo Flutter: contratti, regole di dipendenza e verifica automatica

Quando un'app Flutter cresce, il problema non è più "come scrivo una feature" ma "come impedisco che le feature si parlino tra loro di nascosto". In un monorepo con decine di package interni, un singolo import sbagliato basta a trasformare l'architettura in un grafo di dipendenze circolari.

In questo tutorial costruiamo un sistema di confini verificabili: definiamo contratti espliciti tra moduli, limitiamo cosa ogni package può importare, e automatizziamo i controlli in CI con Melos, dart analyze e uno script di validazione del grafo delle dipendenze.

Prerequisiti: conoscenza di Flutter, package Dart, Melos e almeno un'architettura a layer (data/domain/presentation).

  1. 1

    Definire i livelli e il grafo delle dipendenze consentite

    Prima del codice serve una policy. Definiamo quattro livelli con direzione di dipendenza unica (dall'alto verso il basso):

    Livello Package Può dipendere da
    app app tutto
    feature feature_cart, feature_catalog contracts, core
    contracts contracts_cart core
    core core_ui, core_network nulla di interno

    Regole non negoziabili:

    1. Una feature non importa mai un'altra feature.
    2. Una feature dipende solo dal contratto di un'altra feature, mai dalla sua implementazione.
    3. core non conosce nessuna feature.

    Scriviamo la policy in un file machine-readable: sarà la fonte di verità per lo script di verifica del passo 4.

    # tool/architecture.yaml
    layers:
      core:
        packages: [core_ui, core_network]
        allowed: []
      contracts:
        packages: [contracts_cart, contracts_catalog]
        allowed: [core]
      feature:
        packages: [feature_cart, feature_catalog]
        allowed: [core, contracts]
      app:
        packages: [app]
        allowed: [core, contracts, feature]

    Risultato atteso

    Hai un file di policy versionato che descrive in modo esplicito e verificabile il grafo architetturale ammesso.

  2. 2

    Creare il package di contratto tra due feature

    Il contratto è un package senza implementazione: solo interfacce, modelli immutabili ed eventi. È ciò che feature_catalog può conoscere di feature_cart.

    Crea il package con melos exec oppure manualmente:

    mkdir -p packages/contracts_cart
    cd packages/contracts_cart && dart create -t package .
    

    Nel pubspec.yaml del contratto consenti solo dipendenze core (o nessuna). L'implementazione reale vivrà in feature_cart, che dichiara implements.

    // packages/contracts_cart/lib/contracts_cart.dart
    import 'package:meta/meta.dart';
    
    @immutable
    class CartLine {
      const CartLine({required this.sku, required this.quantity});
      final String sku;
      final int quantity;
    }
    
    /// Contratto pubblico esposto dal modulo carrello.
    /// Nessun altro modulo conosce l'implementazione.
    abstract interface class CartFacade {
      Future<void> add(CartLine line);
      Future<void> remove(String sku);
      Stream<List<CartLine>> watch();
    }
    
    /// Rotte dichiarate dal modulo, usate dall'app per il wiring.
    abstract final class CartRoutes {
      static const cart = '/cart';
    }

    Risultato atteso

    `contracts_cart` compila isolatamente con `dart analyze` e non dipende da alcun package di feature.

  3. 3

    Implementare il contratto e registrare il modulo nell'app

    feature_cart dipende dal proprio contratto e lo implementa. L'app è l'unico punto in cui implementazione e interfaccia si incontrano: qui avviene il binding nel container DI (get_it, Riverpod o un semplice registry).

    Nota chiave: feature_catalog riceverà CartFacade via DI, quindi nel suo pubspec.yaml comparirà solo contracts_cart. Se qualcuno aggiunge feature_cart tra le sue dipendenze, lo script del passo 4 fallirà.

    // packages/feature_cart/lib/src/cart_facade_impl.dart
    import 'package:contracts_cart/contracts_cart.dart';
    
    final class CartFacadeImpl implements CartFacade {
      CartFacadeImpl(this._repo);
      final CartRepository _repo;
    
      @override
      Future<void> add(CartLine line) => _repo.upsert(line);
    
      @override
      Future<void> remove(String sku) => _repo.delete(sku);
    
      @override
      Stream<List<CartLine>> watch() => _repo.stream();
    }
    
    // apps/app/lib/di/modules.dart  (unico punto di composizione)
    void registerModules(GetIt di) {
      di.registerLazySingleton<CartFacade>(
        () => CartFacadeImpl(di<CartRepository>()),
      );
    }
    
    // packages/feature_catalog/lib/src/product_page.dart
    // dipende SOLO dall'interfaccia, mai da feature_cart
    class AddToCartButton extends StatelessWidget {
      const AddToCartButton({super.key, required this.facade, required this.sku});
      final CartFacade facade;
      final String sku;
    
      @override
      Widget build(BuildContext context) => FilledButton(
            onPressed: () => facade.add(CartLine(sku: sku, quantity: 1)),
            child: const Text('Aggiungi al carrello'),
          );
    }

    Risultato atteso

    Il catalogo usa il carrello senza conoscerne l'implementazione: sostituire `CartFacadeImpl` con un fake nei test non richiede modifiche al catalogo.

  4. 4

    Validare il grafo delle dipendenze con uno script Dart

    Le convenzioni non rispettate diventano debito tecnico. Scriviamo uno script che legge tool/architecture.yaml, analizza ogni pubspec.yaml dei package interni e fallisce (exitCode = 1) se trova una dipendenza vietata o una feature che ne importa un'altra.

    Eseguilo con dart run tool/check_architecture.dart dalla root del monorepo.

    // tool/check_architecture.dart
    import 'dart:io';
    import 'package:yaml/yaml.dart';
    
    void main() {
      final policy = loadYaml(File('tool/architecture.yaml').readAsStringSync());
      final layerOf = <String, String>{};
      final allowed = <String, Set<String>>{};
    
      for (final entry in (policy['layers'] as YamlMap).entries) {
        final layer = entry.key as String;
        allowed[layer] = {...(entry.value['allowed'] as YamlList).cast<String>()};
        for (final p in (entry.value['packages'] as YamlList).cast<String>()) {
          layerOf[p] = layer;
        }
      }
    
      final errors = <String>[];
      for (final dir in Directory('packages').listSync().whereType<Directory>()) {
        final file = File('${dir.path}/pubspec.yaml');
        if (!file.existsSync()) continue;
        final pubspec = loadYaml(file.readAsStringSync()) as YamlMap;
        final name = pubspec['name'] as String;
        final layer = layerOf[name];
        if (layer == null) {
          errors.add('[$name] non è dichiarato in architecture.yaml');
          continue;
        }
        final deps = (pubspec['dependencies'] as YamlMap?)?.keys.cast<String>() ?? const <String>[];
        for (final dep in deps) {
          final depLayer = layerOf[dep];
          if (depLayer == null) continue; // pacchetto esterno: ok
          if (!allowed[layer]!.contains(depLayer)) {
            errors.add('[$name] ($layer) non può dipendere da $dep ($depLayer)');
          }
        }
      }
    
      if (errors.isEmpty) {
        stdout.writeln('✅ Confini architetturali rispettati');
        return;
      }
      stderr.writeln('❌ Violazioni:\n - ${errors.join('\n - ')}');
      exitCode = 1;
    }

    Risultato atteso

    Aggiungendo `feature_cart` alle dipendenze di `feature_catalog` lo script esce con codice 1 e stampa la violazione.

  5. 5

    Blindare gli import interni con lint e API pubbliche minime

    Il grafo dei pubspec non basta: dentro un package si può ancora importare package:feature_cart/src/.... Due difese complementari:

    1. Un solo file barrel per package, con tutto il resto sotto lib/src/: Dart segnala già implementation_imports per gli import di src/ altrui — promuovilo a errore.
    2. Attiva regole severe in un analysis_options condiviso, ereditato da ogni package.

    Aggiungi anche public_member_api_docs sui package di contratto: se un'API è pubblica deve essere documentata, il che scoraggia l'esposizione accidentale di dettagli interni.

    # analysis_options.yaml (root, condiviso)
    include: package:very_good_analysis/analysis_options.yaml
    
    analyzer:
      language:
        strict-casts: true
        strict-raw-types: true
      errors:
        implementation_imports: error
        depend_on_referenced_packages: error
        public_member_api_docs: ignore
      exclude:
        - "**/*.g.dart"
    
    # packages/contracts_cart/analysis_options.yaml
    include: ../../analysis_options.yaml
    analyzer:
      errors:
        public_member_api_docs: error

    Risultato atteso

    `dart analyze` fallisce se un package importa i file `src/` di un altro package o dipende da un package non dichiarato.

  6. 6

    Automatizzare tutto in CI con Melos

    L'ultimo tassello è rendere i controlli non aggirabili. Definiamo gli script Melos e li colleghiamo a una GitHub Action che gira su ogni PR.

    Usa melos exec --diff=origin/main per analizzare e testare solo i package toccati dalla PR: in un monorepo grande riduce drasticamente i tempi di build mantenendo il controllo architetturale globale (che invece gira sempre, perché costa pochi secondi).

    # melos.yaml
    name: shop_monorepo
    packages:
      - apps/**
      - packages/**
    
    scripts:
      arch:
        run: dart run tool/check_architecture.dart
        description: Verifica i confini tra moduli
      analyze:
        run: melos exec --diff=origin/main -- dart analyze --fatal-infos .
      test:
        run: melos exec --diff=origin/main --dir-exists=test -- flutter test
      ci:
        run: melos run arch && melos run analyze && melos run test
    
    # .github/workflows/ci.yaml
    # jobs:
    #   verify:
    #     steps:
    #       - uses: actions/checkout@v4
    #         with: { fetch-depth: 0 }
    #       - uses: subosito/flutter-action@v2
    #       - run: dart pub global activate melos && melos bootstrap
    #       - run: melos run ci

    Risultato atteso

    Ogni pull request esegue `melos run ci`: una violazione dei confini blocca il merge prima che l'architettura degradi.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!