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.
