Il problema del boilerplate ripetuto

In qualsiasi progetto Flutter maturo la creazione di una nuova feature segue uno schema prevedibile: una cartella data con repository e data source, una domain con i modelli, una presentation con la pagina, il controller dello stato e i widget. A questo si aggiungono i file di barrel export, i test scheletro, magari la registrazione della rotta in go_router.

Il risultato è che ogni sviluppatore passa 20 minuti a copiare e incollare da una feature esistente, rinominando classi a mano. È un lavoro noioso, soggetto a errori e — soprattutto — è il modo più rapido per far divergere le convenzioni interne al team.

Mason risolve esattamente questo problema. È un tool CLI (nato da Felix Angelov, lo stesso autore di bloc) che permette di definire template di codice chiamati brick, parametrizzarli con variabili e generarli con un comando. Non è un code generator a runtime come build_runner: Mason lavora una volta sola, al momento della creazione dei file, e il codice prodotto è normalissimo codice Dart che poi vive di vita propria.

Installazione e primi passi

Mason si installa come eseguibile globale di Dart:

dart pub global activate mason_cli
mason --version

Assicurati che ~/.pub-cache/bin sia nel PATH. Dentro al progetto Flutter si inizializza l'ambiente:

mason init

Il comando crea un file mason.yaml nella root, che funziona come il pubspec.yaml dei brick: elenca i template disponibili per quel progetto.

bricks:
  # brick pubblicato su brickhub.dev
  cubit:
    git:
      url: https://github.com/felangel/mason.git
      path: bricks/cubit
  # brick locale, versionato nel repository
  feature:
    path: bricks/feature

Dopo ogni modifica si scaricano le dipendenze e si verifica la lista:

mason get
mason list

Per generare codice da un brick si usa mason make:

mason make feature --feature_name checkout -o lib/features

Se non passi le variabili da riga di comando, Mason te le chiede in modo interattivo. Con -o indichi la directory di output e con --on-conflict decidi cosa fare se un file esiste già (prompt, overwrite, append, skip).

Creare il proprio brick

Il vero valore arriva quando i brick rispecchiano l'architettura del tuo team. Si parte da:

mason new feature --desc "Genera una feature completa a tre livelli"

Viene creata la struttura:

bricks/feature/
├── brick.yaml
├── README.md
└── __brick__/
    └── HELLO.md

La cartella __brick__ contiene i file template: tutto ciò che sta lì dentro viene copiato nell'output, con nomi e contenuti processati tramite mustache.

Dichiarare le variabili

Il file brick.yaml descrive il brick e soprattutto le sue variabili:

name: feature
description: Genera una feature a tre livelli (data/domain/presentation)
version: 0.1.0+1

environment:
  mason: ">=0.1.0-dev <0.1.0"

vars:
  feature_name:
    type: string
    description: Nome della feature in snake_case
    prompt: Come si chiama la feature?
    default: home
  state_management:
    type: enum
    values:
      - riverpod
      - bloc
      - none
    default: riverpod
    prompt: Quale soluzione di state management?
  with_repository:
    type: boolean
    default: true
    prompt: Vuoi generare anche il repository?
  endpoints:
    type: array
    values:
      - list
      - detail
      - create
    prompt: Quali endpoint ti servono?

I tipi supportati sono string, number, boolean, enum, array e list. Questo significa che il brick può diventare una vera piccola procedura guidata.

Template e lambda

Nei nomi dei file e nel loro contenuto puoi usare le variabili e le lambda di case conversion, che sono la caratteristica più comoda di Mason. Le più usate sono snakeCase, camelCase, pascalCase, paramCase, constantCase, titleCase, pathCase, sentenceCase.

Un file chiamato __brick__/{{feature_name.snakeCase()}}/presentation/{{feature_name.snakeCase()}}_page.dart genera, per feature_name = checkout, il file checkout/presentation/checkout_page.dart. Il contenuto:

import 'package:flutter/material.dart';
{{#with_repository}}import 'package:flutter_riverpod/flutter_riverpod.dart';{{/with_repository}}

/// Pagina principale della feature {{feature_name.titleCase()}}.
class {{feature_name.pascalCase()}}Page extends StatelessWidget {
  const {{feature_name.pascalCase()}}Page({super.key});

  static const routeName = '/{{feature_name.paramCase()}}';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('{{feature_name.titleCase()}}')),
      body: const Center(child: Text('TODO: {{feature_name.titleCase()}}')),
    );
  }
}

Le sezioni mustache {{#variabile}} ... {{/variabile}} funzionano come condizionali per i booleani e come cicli per gli array; {{^variabile}} ... {{/variabile}} è il ramo negativo. Esempio su un repository:

abstract interface class {{feature_name.pascalCase()}}Repository {
{{#endpoints}}
  // operazione: {{.}}
{{/endpoints}}
{{#with_repository}}
  Future<List<{{feature_name.pascalCase()}}>> fetchAll();
{{/with_repository}}
}

Un dettaglio importante: se un file del template genera un nome vuoto (per esempio perché la variabile condizionale è falsa), quel file non viene creato. Si può quindi condizionare l'intera esistenza di un file inserendo nel nome una sezione mustache, ad esempio {{#with_repository}}{{feature_name.snakeCase()}}_repository.dart{{/with_repository}}.

I partial

Per evitare duplicazione tra template, Mason supporta i partial: file il cui nome inizia con ~ (ad esempio __brick__/~header.dart) che non vengono generati ma possono essere inclusi altrove con {{> header.dart }}. Perfetti per header di licenza, import comuni o blocchi // GENERATED ripetuti.

Gli hook: logica Dart prima e dopo la generazione

I brick possono eseguire codice Dart arbitrario grazie agli hook. Si crea la cartella hooks con un suo pubspec.yaml che dipende dal package mason:

name: feature_hooks
environment:
  sdk: ">=3.0.0 <4.0.0"
dependencies:
  mason: ^0.1.0-dev

Il file hooks/pre_gen.dart viene eseguito prima della generazione e può aggiungere o modificare variabili:

import 'package:mason/mason.dart';

void run(HookContext context) {
  final name = context.vars['feature_name'] as String;

  context.vars = {
    ...context.vars,
    'generated_at': DateTime.now().toIso8601String(),
    'is_riverpod': context.vars['state_management'] == 'riverpod',
  };

  context.logger.info('Genero la feature "$name"...');
}

Nota is_riverpod: poiché mustache non sa confrontare stringhe, il trucco standard è precalcolare dei booleani nel pre-gen e usarli come sezioni nei template.

Il file hooks/post_gen.dart è il posto giusto per formattare, installare dipendenze o lanciare build_runner:

import 'dart:io';
import 'package:mason/mason.dart';

Future<void> run(HookContext context) async {
  final progress = context.logger.progress('Formattazione e dipendenze');

  await Process.run('dart', ['format', '.'], runInShell: true);

  if (context.vars['is_riverpod'] == true) {
    await Process.run(
      'dart',
      ['run', 'build_runner', 'build', '--delete-conflicting-outputs'],
      runInShell: true,
    );
  }

  progress.complete('Feature pronta!');
  context.logger.success(
    'Ricordati di registrare la rotta in app_router.dart',
  );
}

Gli hook hanno accesso a context.logger (con info, warn, err, success, progress, prompt, confirm) e possono quindi diventare piccoli wizard. Attenzione però a non abusarne: un hook che modifica file esistenti del progetto è potente ma fragile, perché dipende dalla forma esatta di quei file.

Distribuire i brick al team

Ci sono tre strategie, in ordine di accoppiamento crescente con il repository:

  1. Brick locali (path: bricks/feature): vivono nel monorepo, si versionano insieme al codice e si evolvono con l'architettura. È la scelta migliore per i brick specifici di un singolo prodotto.
  2. Brick su Git: un repository company_bricks condiviso tra più progetti, referenziato con git: { url: ..., path: ..., ref: v1.2.0 }. Usa sempre un ref fissato a un tag per evitare che una modifica upstream cambi il codice generato dalla sera alla mattina.
  3. Brick su BrickHub: il registry pubblico (brickhub.dev). Si pubblica con mason login e mason publish, e si consuma con una semplice versione (feature: ^0.1.0). Ottimo per brick open source o per un design system condiviso.

Esiste anche mason bundle, che impacchetta un brick in un singolo file (Dart o universale) da includere dentro una CLI personalizzata: è il meccanismo con cui tool come very_good_cli incorporano i propri template.

mason bundle bricks/feature -t dart -o lib/src/bundles/

Integrazione nel flusso di lavoro

Un brick è codice a tutti gli effetti e come tale va mantenuto. Alcune pratiche che funzionano bene:

  • Testa la generazione in CI. Un job che esegue mason make feature --feature_name sample -o build/tmp seguito da dart analyze sul risultato intercetta subito template che generano codice non compilabile dopo un aggiornamento di Flutter o di un package.
  • Usa un file di configurazione per gli scenari ricorrenti: mason make feature -c feature_config.json legge le variabili da JSON, utile negli script e nei test.
  • Mantieni i brick piccoli e componibili. Meglio tre brick (feature, repository, widget) che un unico mega-template con quindici variabili: la combinatoria esplode e diventa impossibile da mantenere.
  • Documenta ogni brick nel suo README, includendo un esempio dell'output generato. Mason inserisce automaticamente l'output di esempio se usi mason make con --output-dir nel README.
  • Non generare ciò che può essere una classe base. Se vedi che lo stesso codice identico viene generato in ogni feature senza variazioni, probabilmente appartiene a un package condiviso, non a un template. Mason serve a generare la struttura variabile, non a duplicare logica.

Quando Mason non serve

Per completezza: Mason non sostituisce build_runner. Se hai bisogno di codice che resti sincronizzato con una sorgente di verità (modelli JSON, freezed, riverpod_generator, pigeon), ti serve la generazione a build time. Mason copre il caso opposto, cioè lo scaffolding iniziale di codice che poi verrà modificato a mano.

Allo stesso modo, in un progetto piccolo con due o tre schermate l'investimento nella scrittura dei brick non si ripaga. Il punto di pareggio arriva quando il team ha più di due o tre sviluppatori, oppure quando il numero di feature cresce a doppia cifra: lì la coerenza strutturale vale molto più del tempo risparmiato.

Conclusioni

Mason trasforma le convenzioni implicite del team in template eseguibili. Il vantaggio non è solo la velocità: è che l'architettura smette di essere una pagina di wiki che nessuno legge e diventa qualcosa che il tool applica automaticamente a ogni nuova feature. Inizia con un brick semplice — anche solo uno per i widget riutilizzabili — mettilo nel repository, e lascia che cresca insieme al progetto.