Skip to main content
mobile28 dicembre 202512 min di lettura

Architettura di App Flutter che Scala Oltre il Tutorial

Pattern di architettura Flutter per la produzione — dalla gestione dello stato alla navigazione, all'iniezione di dipendenza e alla struttura del progetto orientata alle feature.

flutterarchitecturemobile
Architettura di App Flutter che Scala Oltre il Tutorial

Le app Flutter dei tutorial funzionano benissimo fino a quando non raggiungono circa venti schermate. Poi il singolo file main.dart con tutte le rotte diventa ingestibile, le chiamate setState creano race condition, l'approccio "passalo semplicemente tramite il costruttore" produce alberi di widget con dieci parametri, e ogni nuova feature tocca cinque file esistenti. Ho visto questo accadere su più progetti, inclusi i miei, e il pattern è sempre lo stesso — il team si scontra con un muro intorno al terzo mese e deve ristrutturare o accettare una crescente perdita di velocità di sviluppo.

L'architettura che descrivo qui è quella che uso per le app in produzione. Non è l'approccio più semplice possibile, ed è proprio questo il punto. L'approccio più semplice è quello che insegnano i tutorial, e smette di funzionare nel momento in cui la tua app ha requisiti reali.

Perché l'Architettura dei Tutorial Fallisce

La maggior parte dei tutorial e corsi Flutter insegna una struttura di progetto piatta:

lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
    ├── custom_button.dart
    └── loading_indicator.dart

Questo funziona per un'app di gestione delle attività. Fallisce per un'app reale per ragioni prevedibili:

I file crescono senza confini naturali. Quando home_screen.dart contiene il layout della schermata, la gestione dello stato, le chiamate API e la logica di business, raggiunge rapidamente più di 500 righe. Non c'è alcuna guida strutturale su cosa dovrebbe essere estratto e dove dovrebbe andare.

Le dipendenze diventano implicite. Quando home_screen.dart istanzia direttamente ApiService(), non c'è modo di scambiarlo con un mock nei test, nessun modo di configurarlo diversamente per ambiente, e nessun modo di condividere una singola istanza tra schermate che necessitano degli stessi dati.

La navigazione diventa una rete di rotte hardcoded. Deep link, guardie di autenticazione e flussi condizionali vengono tutti aggiunti a una lista di rotte piatta. Quando hai trenta rotte con guardie e reindirizzamenti, il codice di navigazione è la parte più fragile dell'app.

Il testing diventa impraticabile. Senza l'iniezione di dipendenza, i test dei widget richiedono il mocking a livello HTTP piuttosto che a livello di servizio. Senza una chiara separazione della logica di business dall'UI, si finisce per testare dettagli di implementazione piuttosto che il comportamento.

Struttura del Progetto Orientata alle Feature

La decisione architettonica più impattante è la struttura delle cartelle. Utilizzo un approccio orientato alle feature in cui ogni feature possiede le proprie schermate, widget, stato e modelli:

lib/
├── app/
│   ├── app.dart                    # MaterialApp configuration
│   ├── router.dart                 # GoRouter configuration
│   └── theme.dart                  # Theme data
├── core/
│   ├── constants/
│   │   ├── api_constants.dart
│   │   └── app_constants.dart
│   ├── errors/
│   │   ├── app_exception.dart
│   │   └── error_handler.dart
│   ├── network/
│   │   ├── api_client.dart
│   │   ├── interceptors/
│   │   └── network_info.dart
│   └── utils/
│       ├── date_utils.dart
│       └── string_utils.dart
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   ├── auth_repository.dart
│   │   │   └── auth_remote_source.dart
│   │   ├── domain/
│   │   │   ├── user_model.dart
│   │   │   └── auth_state.dart
│   │   ├── presentation/
│   │   │   ├── screens/
│   │   │   │   ├── login_screen.dart
│   │   │   │   └── register_screen.dart
│   │   │   ├── widgets/
│   │   │   │   ├── login_form.dart
│   │   │   │   └── social_login_buttons.dart
│   │   │   └── providers/
│   │   │       └── auth_provider.dart
│   │   └── auth.dart               # Barrel file for public API
│   ├── home/
│   │   ├── data/
│   │   ├── domain/
│   │   ├── presentation/
│   │   └── home.dart
│   └── settings/
│       ├── data/
│       ├── domain/
│       ├── presentation/
│       └── settings.dart
└── shared/
    ├── widgets/
    │   ├── app_button.dart
    │   ├── app_text_field.dart
    │   ├── loading_overlay.dart
    │   └── error_view.dart
    ├── models/
    │   └── paginated_response.dart
    └── providers/
        └── connectivity_provider.dart

Le Regole

Le feature non importano mai da altre feature. Se la feature A necessita di dati dalla feature B, quei dati risiedono in un modello condiviso o in un servizio condiviso, non nella cartella della feature B. Questo previene dipendenze circolari e mantiene le feature distribuibili indipendentemente (utile se mai si passa a un monorepo modulare).

La directory core/ contiene l'infrastruttura. Networking, gestione degli errori, costanti, utility — cose di cui ogni feature potrebbe aver bisogno ma che non appartengono a nessuna singola feature.

La directory shared/ contiene UI riutilizzabile e aspetti trasversali. Widget condivisi, modelli condivisi e provider da cui dipendono più feature.

I file barrel espongono l'API pubblica di ogni feature. Altre parti dell'app importano da features/auth/auth.dart, mai da file interni. Questo ti permette di rifattorizzare la struttura interna di una feature senza rompere i consumatori esterni.

// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// Don't export internal implementation details

Gestione dello Stato: Riverpod

Ho usato Provider, Bloc e Riverpod su progetti in produzione. Ognuno ha dei compromessi, e la scelta dipende più dalla familiarità del team che dalla superiorità tecnica. Detto questo, mi oriento su Riverpod per i nuovi progetti per ragioni specifiche.

Perché Riverpod Invece di Bloc

Bloc è eccellente per i team con un background aziendale. Il pattern evento/stato è familiare agli sviluppatori che hanno lavorato con Redux o MVI. Impone un flusso di dati unidirezionale rigoroso e genera macchine a stati prevedibili e testabili. Lo svantaggio è la verbosità — una semplice feature richiede una classe evento, una classe stato e una classe bloc, spesso distribuite su tre file. Per app con logica di business complessa e più sviluppatori, questa "cerimonia" ripaga. Per team più piccoli che si muovono velocemente, può sembrare un sovraccarico.

Provider (il pacchetto originale) funziona bene per l'iniezione di dipendenza semplice ma ha difficoltà con stati complessi. Dipende dall'albero dei widget per lo scoping, il che significa che il ciclo di vita del tuo stato è legato alla struttura della tua UI. Rifattorizzare l'albero dei widget può rompere la gestione dello stato in modi sottili.

Riverpod risolve le limitazioni fondamentali di Provider. I provider sono globali (non dipendenti dall'albero dei widget), sicuri in fase di compilazione e supportano pattern avanzati come l'auto-disposal, i family provider e lo stato asincrono. Genera anche meno boilerplate di Bloc pur mantenendo la testabilità.

Riverpod in Pratica

Ecco come strutturo la gestione dello stato per una feature tipica:

// features/auth/domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';

part 'auth_state.freezed.dart';

@freezed
class AuthState with _$AuthState {
  const factory AuthState.initial() = _Initial;
  const factory AuthState.loading() = _Loading;
  const factory AuthState.authenticated(User user) = _Authenticated;
  const factory AuthState.unauthenticated() = _Unauthenticated;
  const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_repository.dart';
import '../../domain/auth_state.dart';

part 'auth_provider.g.dart';

@riverpod
class AuthNotifier extends _$AuthNotifier {
  @override
  AuthState build() {
    _checkAuthStatus();
    return const AuthState.initial();
  }

  Future<void> _checkAuthStatus() async {
    final repository = ref.read(authRepositoryProvider);
    final user = await repository.getCurrentUser();
    state = user != null
        ? AuthState.authenticated(user)
        : const AuthState.unauthenticated();
  }

  Future<void> signIn(String email, String password) async {
    state = const AuthState.loading();
    final repository = ref.read(authRepositoryProvider);

    try {
      final user = await repository.signIn(email, password);
      state = AuthState.authenticated(user);
    } on AuthException catch (e) {
      state = AuthState.error(e.message);
    }
  }

  Future<void> signOut() async {
    final repository = ref.read(authRepositoryProvider);
    await repository.signOut();
    state = const AuthState.unauthenticated();
  }
}

L'uso dell'annotazione @riverpod con la generazione di codice elimina il boilerplate della dichiarazione manuale del provider. Esegui dart run build_runner build per generare il codice del provider.

Quando Bloc è Migliore

Nonostante la mia preferenza per Riverpod, ci sono scenari in cui Bloc è la scelta più forte:

  • Grandi team dove i pattern imposti prevengono l'inconsistenza.
  • Macchine a stati complesse con molte transizioni — il sistema di eventi di Bloc le rende esplicite e testabili.
  • Quando hai bisogno di funzionalità di replay/undo — il log degli eventi di Bloc lo rende semplice.
  • Quando il team conosce già Bloc. La coerenza attraverso la codebase è più importante di qualsiasi differenza tecnica tra i due.

GoRouter è diventato lo standard per la navigazione in Flutter, e per una buona ragione. Supporta routing dichiarativo, deep link, reindirizzamenti e navigazione annidata out of the box.

// app/router.dart
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'router.g.dart';

@riverpod
GoRouter router(RouterRef ref) {
  final authState = ref.watch(authNotifierProvider);

  return GoRouter(
    initialLocation: '/',
    redirect: (context, state) {
      final isAuthenticated = authState is Authenticated;
      final isAuthRoute = state.matchedLocation.startsWith('/auth');

      if (!isAuthenticated && !isAuthRoute) return '/auth/login';
      if (isAuthenticated && isAuthRoute) return '/';
      return null;
    },
    routes: [
      GoRoute(
        path: '/auth/login',
        builder: (context, state) => const LoginScreen(),
      ),
      GoRoute(
        path: '/auth/register',
        builder: (context, state) => const RegisterScreen(),
      ),
      ShellRoute(
        builder: (context, state, child) => AppShell(child: child),
        routes: [
          GoRoute(
            path: '/',
            builder: (context, state) => const HomeScreen(),
          ),
          GoRoute(
            path: '/settings',
            builder: (context, state) => const SettingsScreen(),
          ),
          GoRoute(
            path: '/item/:id',
            builder: (context, state) {
              final id = state.pathParameters['id']!;
              return ItemDetailScreen(itemId: id);
            },
          ),
        ],
      ),
    ],
  );
}

Guardie di Navigazione

La callback redirect gestisce le guardie di autenticazione globalmente. Ma potresti aver bisogno di una logica più complessa — accesso basato sui ruoli, flussi di onboarding, feature flag. Gestisco questi aspetti con una catena di reindirizzamenti:

redirect: (context, state) {
  // Authentication check
  final isAuthenticated = authState is Authenticated;
  if (!isAuthenticated && !state.matchedLocation.startsWith('/auth')) {
    return '/auth/login';
  }

  // Onboarding check
  if (isAuthenticated && !hasCompletedOnboarding && state.matchedLocation != '/onboarding') {
    return '/onboarding';
  }

  // Already authenticated, redirect away from auth screens
  if (isAuthenticated && state.matchedLocation.startsWith('/auth')) {
    return '/';
  }

  return null;  // No redirect
},

Il Pattern Repository

I repository si trovano tra la tua logica di business e le sorgenti di dati. Astraggono se i dati provengono da un'API REST, un database locale, una cache o una combinazione di tutti e tre.

// features/auth/data/auth_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user_model.dart';

part 'auth_repository.g.dart';

@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
  return AuthRepository(
    remoteSource: ref.read(authRemoteSourceProvider),
    localSource: ref.read(authLocalSourceProvider),
  );
}

class AuthRepository {
  final AuthRemoteSource _remoteSource;
  final AuthLocalSource _localSource;

  AuthRepository({
    required AuthRemoteSource remoteSource,
    required AuthLocalSource localSource,
  }) : _remoteSource = remoteSource,
       _localSource = localSource;

  Future<User?> getCurrentUser() async {
    // Try local cache first
    final cachedUser = await _localSource.getCachedUser();
    if (cachedUser != null) return cachedUser;

    // Fall back to remote
    try {
      final user = await _remoteSource.fetchCurrentUser();
      if (user != null) await _localSource.cacheUser(user);
      return user;
    } catch (_) {
      return null;
    }
  }

  Future<User> signIn(String email, String password) async {
    final user = await _remoteSource.signIn(email, password);
    await _localSource.cacheUser(user);
    await _localSource.saveToken(user.token);
    return user;
  }

  Future<void> signOut() async {
    await _remoteSource.signOut();
    await _localSource.clearCache();
  }
}

Perché Non Chiamare Direttamente l'API

Le chiamate API dirette dal codice di gestione dello stato creano diversi problemi:

  1. La logica di caching si mescola con la logica di business. Controlli la cache prima della chiamata API? Quanto a lungo è valida la cache? Questa è una preoccupazione del livello dati, non una preoccupazione della gestione dello stato.
  2. Il supporto offline diventa una riscrittura. Se il tuo Bloc chiama direttamente http.get, aggiungere il supporto offline significa modificare ogni Bloc. Con un repository, aggiungi la logica di caching e fallback in un unico posto.
  3. Il testing richiede il mocking HTTP. Mockare un repository è un mock per test. Mockare HTTP richiede la configurazione di corpi di risposta, codici di stato e header per ogni caso di test.

Livello di Servizio

Per operazioni che coinvolgono più repository o logica di business complessa, utilizzo un livello di servizio:

class OrderService {
  final OrderRepository _orderRepo;
  final PaymentRepository _paymentRepo;
  final NotificationRepository _notificationRepo;

  OrderService({
    required OrderRepository orderRepo,
    required PaymentRepository paymentRepo,
    required NotificationRepository notificationRepo,
  }) : _orderRepo = orderRepo,
       _paymentRepo = paymentRepo,
       _notificationRepo = notificationRepo;

  Future<Order> placeOrder(Cart cart, PaymentMethod method) async {
    // Validate stock
    await _orderRepo.validateStock(cart.items);

    // Process payment
    final payment = await _paymentRepo.charge(method, cart.total);

    // Create order
    final order = await _orderRepo.create(
      items: cart.items,
      paymentId: payment.id,
    );

    // Send confirmation (fire and forget)
    _notificationRepo.sendOrderConfirmation(order).ignore();

    return order;
  }
}

I servizi contengono logica di business che non appartiene all'UI o a nessun singolo repository. Il livello di gestione dello stato (notifier Riverpod, Bloc) chiama il servizio, e il servizio coordina tra i repository.

Gestione degli Errori

La gestione coerente degli errori è uno degli aspetti più trascurati dell'architettura Flutter. Utilizzo una combinazione di eccezioni tipizzate e un tipo di risultato.

// core/errors/app_exception.dart
sealed class AppException implements Exception {
  final String message;
  final String? code;

  const AppException(this.message, {this.code});
}

class NetworkException extends AppException {
  const NetworkException([String message = 'Network error']) : super(message);
}

class ServerException extends AppException {
  final int statusCode;
  const ServerException(this.statusCode, String message) : super(message);
}

class AuthException extends AppException {
  const AuthException([String message = 'Authentication failed']) : super(message);
}

class ValidationException extends AppException {
  final Map<String, String> fieldErrors;
  const ValidationException(this.fieldErrors) : super('Validation failed');
}

Il client API cattura gli errori HTTP e li converte in eccezioni tipizzate:

// core/network/api_client.dart
class ApiClient {
  final Dio _dio;

  ApiClient(this._dio);

  Future<T> get<T>(String path, {T Function(dynamic)? fromJson}) async {
    try {
      final response = await _dio.get(path);
      return fromJson != null ? fromJson(response.data) : response.data as T;
    } on DioException catch (e) {
      throw _mapError(e);
    }
  }

  AppException _mapError(DioException error) {
    if (error.type == DioExceptionType.connectionTimeout ||
        error.type == DioExceptionType.receiveTimeout) {
      return const NetworkException('Connection timed out');
    }

    final statusCode = error.response?.statusCode;
    if (statusCode == null) return const NetworkException();

    return switch (statusCode) {
      401 => const AuthException('Session expired'),
      403 => const AuthException('Access denied'),
      422 => ValidationException(
        _parseValidationErrors(error.response?.data),
      ),
      _ => ServerException(statusCode, 'Server error'),
    };
  }
}

Architettura di Testing

Una buona architettura rende il testing semplice. Ogni livello ha la propria strategia di testing:

Test dei Repository

void main() {
  late AuthRepository repository;
  late MockAuthRemoteSource mockRemote;
  late MockAuthLocalSource mockLocal;

  setUp(() => {
    mockRemote = MockAuthRemoteSource();
    mockLocal = MockAuthLocalSource();
    repository = AuthRepository(
      remoteSource: mockRemote,
      localSource: mockLocal,
    );
  });

  group('getCurrentUser', () {
    test('returns cached user when available', () async {
      final user = User(id: '1', name: 'Test');
      when(() => mockLocal.getCachedUser()).thenAnswer((_) async => user);

      final result = await repository.getCurrentUser();

      expect(result, equals(user));
      verifyNever(() => mockRemote.fetchCurrentUser());
    });

    test('fetches from remote when cache is empty', () async {
      final user = User(id: '1', name: 'Test');
      when(() => mockLocal.getCachedUser()).thenAnswer((_) async => null);
      when(() => mockRemote.fetchCurrentUser()).thenAnswer((_) async => user);
      when(() => mockLocal.cacheUser(any())).thenAnswer((_) async {});

      final result = await repository.getCurrentUser();

      expect(result, equals(user));
      verify(() => mockLocal.cacheUser(user)).called(1);
    });
  });
}

Test dei Provider con Riverpod

void main() {
  test('signIn updates state to authenticated', () async {
    final user = User(id: '1', name: 'Test');
    final mockRepository = MockAuthRepository();
    when(() => mockRepository.signIn(any(), any()))
        .thenAnswer((_) async => user);

    final container = ProviderContainer(
      overrides: [
        authRepositoryProvider.overrideWithValue(mockRepository),
      ],
    );

    final notifier = container.read(authNotifierProvider.notifier);
    await notifier.signIn('test@test.com', 'password');

    final state = container.read(authNotifierProvider);
    expect(state, AuthState.authenticated(user));
  });
}

Il ProviderContainer di Riverpod e il meccanismo di override rendono il testing pulito — si sovrascrivono le dipendenze a livello di container piuttosto che a livello di albero dei widget.

Compromessi Pratici

Questa architettura non è gratuita. Ci sono costi reali:

Più file. Una feature che sarebbe un singolo file in un'app tutorial potrebbe essere da cinque a otto file in questa struttura. Per app piccole (meno di dieci schermate), questo può sembrare eccessivo.

Curva di apprendimento. Uno sviluppatore nuovo al progetto deve comprendere i confini dei livelli e dove posizionare le cose. La documentazione aiuta, ma c'è comunque un tempo di apprendimento.

Generazione di codice. Il sistema di annotazioni di Riverpod, Freezed per i modelli immutabili e la serializzazione JSON richiedono tutti build_runner. La generazione di build aggiunge un passaggio allo sviluppo e può essere lenta su codebase di grandi dimensioni.

Tendenza all'eccessiva astrazione. Quando si ha un'architettura pulita, è facile aggiungere astrazioni "per ogni evenienza". Un'interfaccia di repository per una sorgente dati che avrà sempre e solo un'implementazione aggiunge complessità senza valore. Creo astrazioni quando ho una seconda implementazione concreta, non prima.

L'architettura vale la pena quando la tua app ha più di circa dieci schermate, più di uno sviluppatore, o una durata di vita superiore a pochi mesi. Per un progetto di hackathon o una prova di concetto, usa la cosa più semplice che funziona. Ma quando sai che l'app deve crescere, investire nella struttura in anticipo risparmia costi multipli in seguito.

DU

Danil Ulmashev

Full Stack Developer

Interessato a collaborare?