Perché serve un'architettura

Quando un'app Flutter cresce, mettere tutta la logica dentro i widget diventa rapidamente ingestibile. Senza una struttura chiara, ci si ritrova con setState ovunque, chiamate di rete dentro i build, e codice impossibile da testare. Un'architettura a livelli (layered architecture) risolve questi problemi separando le responsabilità.

In questo articolo vedremo un approccio pragmatico, ispirato alla Clean Architecture, organizzato in tre livelli: presentation, domain e data.

I tre livelli

  • Presentation: widget, schermate e gestione dello stato della UI.
  • Domain: la logica di business pura, indipendente da Flutter e da qualsiasi framework esterno. Contiene entità, repository (come interfacce) e use case.
  • Data: implementazione concreta dei repository, sorgenti dati remote (API) e locali (database, cache), modelli (DTO).

La regola d'oro è la dipendenza verso l'interno: la presentation dipende dal domain, il data dipende dal domain, ma il domain non dipende da nessuno.

Struttura delle cartelle

Un'organizzazione per feature mantiene il progetto leggibile:

lib/
  features/
    auth/
      data/
        datasources/
        models/
        repositories/
      domain/
        entities/
        repositories/
        usecases/
      presentation/
        pages/
        widgets/
        controllers/
  core/
    error/
    network/

Il livello Domain

Iniziamo dall'entità, un oggetto puro senza dipendenze esterne:

class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });
}

Definiamo poi l'interfaccia del repository nel domain. Nota che è un'astrazione: non sa nulla di HTTP o database.

abstract class UserRepository {
  Future<User> getUser(String id);
}

Il use case incapsula una singola azione di business:

class GetUser {
  final UserRepository repository;

  const GetUser(this.repository);

  Future<User> call(String id) {
    return repository.getUser(id);
  }
}

L'uso del metodo call permette di invocare l'oggetto come fosse una funzione: getUser(id).

Il livello Data

Il modello (DTO) estende o mappa l'entità e gestisce la serializzazione:

class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }

  Map<String, dynamic> toJson() => {
        'id': id,
        'name': name,
        'email': email,
      };
}

La data source si occupa della comunicazione con l'esterno:

abstract class UserRemoteDataSource {
  Future<UserModel> fetchUser(String id);
}

class UserRemoteDataSourceImpl implements UserRemoteDataSource {
  final HttpClient client;

  const UserRemoteDataSourceImpl(this.client);

  @override
  Future<UserModel> fetchUser(String id) async {
    final response = await client.get('/users/$id');
    return UserModel.fromJson(response.data);
  }
}

Infine l'implementazione del repository, che vive nel data ma rispetta il contratto del domain:

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remoteDataSource;

  const UserRepositoryImpl(this.remoteDataSource);

  @override
  Future<User> getUser(String id) {
    return remoteDataSource.fetchUser(id);
  }
}

Il livello Presentation

La presentation utilizza il use case senza conoscere i dettagli implementativi. Ecco un esempio con un semplice controller basato su ChangeNotifier:

class UserController extends ChangeNotifier {
  final GetUser getUser;

  UserController(this.getUser);

  User? user;
  bool isLoading = false;
  String? error;

  Future<void> load(String id) async {
    isLoading = true;
    error = null;
    notifyListeners();

    try {
      user = await getUser(id);
    } catch (e) {
      error = 'Impossibile caricare l\'utente';
    } finally {
      isLoading = false;
      notifyListeners();
    }
  }
}

I vantaggi concreti

  • Testabilità: il domain è puro Dart, testabile senza il framework Flutter. I repository si possono sostituire con mock.
  • Manutenibilità: cambiare la sorgente dati (da REST a GraphQL) impatta solo il livello data.
  • Collaborazione: team diversi possono lavorare su livelli diversi con confini chiari.

Quando NON esagerare

La Clean Architecture ha un costo in termini di boilerplate. Per un'app piccola o un prototipo, separare ogni livello con use case e modelli dedicati può essere overkill. Valuta la complessità reale del progetto: puoi adottare un approccio progressivo, partendo da repository e schermate, e introdurre i use case solo quando la logica di business cresce.

Conclusione

Un'architettura a livelli ben pensata trasforma un progetto Flutter da un groviglio di widget a un sistema ordinato e scalabile. L'investimento iniziale si ripaga rapidamente quando l'app cresce e arrivano nuove funzionalità o si rende necessario un refactoring importante.