Architettura a feature in Flutter: moduli, confini e regole di dipendenza

Foto di Sonder Bridge Photography su Unsplash

Best practiceAvanzato60 min Flutter 3.x (Dart 3.3+)

Architettura a feature in Flutter: moduli, confini e regole di dipendenza

Quando un'app Flutter supera le poche decine di schermate, la classica divisione per tipo (screens/, models/, services/) smette di funzionare: ogni modifica tocca mezza codebase, i merge conflict esplodono e nessuno sa più chi può chiamare cosa.

La risposta è un'architettura a feature (feature-first) con confini espliciti tra moduli: ogni funzionalità è una cartella autonoma con i suoi layer interni, espone una piccola API pubblica e nasconde tutto il resto. I confini non restano però nella documentazione: in questo tutorial li rendiamo eseguibili, con regole di lint e un controllo automatico in CI che fa fallire la build se qualcuno importa il file sbagliato.

Alla fine avrai:

  • una struttura lib/features/<feature>/{domain,data,presentation} più core/ e shared/;
  • un barrel file per feature che funge da facciata pubblica;
  • una composition root che collega le implementazioni ai contratti;
  • routing modulare con go_router delegato alle singole feature;
  • regole di dipendenza applicate con import_lint e uno script di verifica.

Prerequisiti: buona conoscenza di Dart moderno (classi sealed, abstract interface class), di un gestore di stato (useremo Riverpod) e dei fondamenti di testing.

  1. 1

    Definire la struttura feature-first e le regole di dipendenza

    Il primo passo è decidere dove vive il codice e chi può importare chi. Adottiamo tre zone:

    • lib/core/: infrastruttura trasversale senza logica di business (HTTP client, logger, errori, risultati, DI). Non conosce nessuna feature.
    • lib/shared/: widget e utility di UI riutilizzabili (design system). Non conosce nessuna feature.
    • lib/features/<nome>/: una cartella per funzionalità, con i layer domain, data, presentation e un barrel file <nome>.dart che ne rappresenta l'API pubblica.

    Le regole (che renderemo eseguibili nel passo 6) sono:

    1. domain non importa data né presentation, né package:flutter/*: è Dart puro e testabile in isolamento.
    2. data può importare domain e core, mai presentation.
    3. presentation può importare domain e shared, mai data (accede ai dati solo tramite i contratti del dominio).
    4. Una feature non importa file interni di un'altra feature: può usare solo il suo barrel features/<altra>/<altra>.dart.
    5. Solo la composition root (lib/app/) può vedere le implementazioni concrete di tutte le feature.

    Questa gerarchia produce un grafo aciclico: se domani orders diventa un package separato, basta spostare la cartella.

    lib/
    ├── app/                      # composition root: entrypoint, DI, router, theme
    │   ├── app.dart
    │   ├── bootstrap.dart
    │   └── router.dart
    ├── core/                     # infrastruttura trasversale, zero business logic
    │   ├── error/failure.dart
    │   ├── network/api_client.dart
    │   └── result.dart
    ├── shared/                   # design system e widget riutilizzabili
    │   └── widgets/app_error_view.dart
    └── features/
        ├── orders/
        │   ├── orders.dart            # BARREL: unica API pubblica della feature
        │   ├── orders_module.dart     # rotte + override di provider da esporre all'app
        │   ├── domain/
        │   │   ├── entities/order.dart
        │   │   ├── repositories/order_repository.dart
        │   │   └── usecases/get_orders.dart
        │   ├── data/
        │   │   ├── dto/order_dto.dart
        │   │   ├── datasources/order_remote_data_source.dart
        │   │   └── repositories/order_repository_impl.dart
        │   └── presentation/
        │       ├── controllers/orders_controller.dart
        │       └── screens/orders_screen.dart
        └── auth/
            └── auth.dart

    Risultato atteso

    Una struttura di cartelle chiara e un contratto scritto su chi può importare cosa: la base di tutti i passi successivi.

  2. 2

    Modellare il dominio della feature: entità, contratti e use case

    Il domain è il cuore del modulo e deve essere Dart puro: nessun package:flutter, nessun dio, nessun json. Contiene:

    • entità immutabili che rappresentano il linguaggio del business;
    • contratti (abstract interface class) dei repository: descrivono cosa serve, non come ottenerlo;
    • use case: unità di intenzione applicativa, utili per rendere esplicite le operazioni e per intercettare policy trasversali (permessi, analytics).

    Usiamo anche un tipo Result in core/ per evitare eccezioni non tipizzate che attraversano i confini: il dominio dichiara i suoi errori, la presentation li gestisce esaustivamente grazie alle classi sealed.

    Regola pratica: se una classe del dominio ha bisogno di BuildContext o di una libreria HTTP, sta sbagliando layer.

    // lib/core/result.dart
    sealed class Result<T> {
      const Result();
    }
    
    final class Ok<T> extends Result<T> {
      const Ok(this.value);
      final T value;
    }
    
    final class Err<T> extends Result<T> {
      const Err(this.failure);
      final Failure failure;
    }
    
    // lib/core/error/failure.dart
    sealed class Failure {
      const Failure(this.message);
      final String message;
    }
    
    final class NetworkFailure extends Failure {
      const NetworkFailure([super.message = 'Connessione non disponibile']);
    }
    
    final class ServerFailure extends Failure {
      const ServerFailure(super.message, {required this.statusCode});
      final int statusCode;
    }
    
    // lib/features/orders/domain/entities/order.dart
    enum OrderStatus { pending, paid, shipped, cancelled }
    
    final class Order {
      const Order({
        required this.id,
        required this.customer,
        required this.total,
        required this.status,
      });
    
      final String id;
      final String customer;
      final double total;
      final OrderStatus status;
    
      bool get isShippable => status == OrderStatus.paid;
    }
    
    // lib/features/orders/domain/repositories/order_repository.dart
    abstract interface class OrderRepository {
      Future<Result<List<Order>>> fetchOrders({int page = 1});
      Future<Result<Order>> markAsShipped(String orderId);
    }
    
    // lib/features/orders/domain/usecases/get_orders.dart
    final class GetOrders {
      const GetOrders(this._repository);
      final OrderRepository _repository;
    
      Future<Result<List<Order>>> call({int page = 1}) {
        assert(page > 0, 'La pagina parte da 1');
        return _repository.fetchOrders(page: page);
      }
    }

    Risultato atteso

    Il package `domain` compila senza alcuna dipendenza da Flutter: puoi eseguirne i test con `dart test` e riutilizzarlo su server o CLI.

  3. 3

    Implementare il layer data: DTO, data source e mapping verso il dominio

    Il layer data è l'unico che conosce il formato dei dati esterni. Tre accorgimenti fondamentali per mantenere il confine:

    1. I DTO non escono mai dalla feature. Le API di rete parlano OrderDto, il repository converte in Order e restituisce solo entità di dominio.
    2. Le eccezioni non escono mai dal repository. DioException, SocketException e simili vengono tradotte in Failure tipizzate.
    3. Il data source è a sua volta un'interfaccia, così nei test puoi sostituirlo senza toccare HTTP.

    In questo modo un cambio di backend (REST → GraphQL) tocca solo data/, mentre domain/ e presentation/ restano intatti: è esattamente questo il valore dei confini.

    // lib/features/orders/data/dto/order_dto.dart
    import '../../domain/entities/order.dart';
    
    final class OrderDto {
      const OrderDto({
        required this.id,
        required this.customerName,
        required this.totalCents,
        required this.status,
      });
    
      factory OrderDto.fromJson(Map<String, dynamic> json) => OrderDto(
            id: json['id'] as String,
            customerName: json['customer_name'] as String,
            totalCents: json['total_cents'] as int,
            status: json['status'] as String,
          );
    
      final String id;
      final String customerName;
      final int totalCents;
      final String status;
    
      Order toDomain() => Order(
            id: id,
            customer: customerName,
            total: totalCents / 100,
            status: OrderStatus.values.firstWhere(
              (s) => s.name == status,
              orElse: () => OrderStatus.pending,
            ),
          );
    }
    
    // lib/features/orders/data/datasources/order_remote_data_source.dart
    import '../../../../core/network/api_client.dart';
    import '../dto/order_dto.dart';
    
    abstract interface class OrderRemoteDataSource {
      Future<List<OrderDto>> getOrders({required int page});
      Future<OrderDto> ship(String orderId);
    }
    
    final class HttpOrderRemoteDataSource implements OrderRemoteDataSource {
      const HttpOrderRemoteDataSource(this._client);
      final ApiClient _client;
    
      @override
      Future<List<OrderDto>> getOrders({required int page}) async {
        final json = await _client.getList('/orders', query: {'page': page});
        return json.map(OrderDto.fromJson).toList(growable: false);
      }
    
      @override
      Future<OrderDto> ship(String orderId) async {
        final json = await _client.post('/orders/$orderId/ship');
        return OrderDto.fromJson(json);
      }
    }
    
    // lib/features/orders/data/repositories/order_repository_impl.dart
    import 'dart:io';
    import '../../../../core/error/failure.dart';
    import '../../../../core/result.dart';
    import '../../domain/entities/order.dart';
    import '../../domain/repositories/order_repository.dart';
    import '../datasources/order_remote_data_source.dart';
    
    final class OrderRepositoryImpl implements OrderRepository {
      const OrderRepositoryImpl(this._remote);
      final OrderRemoteDataSource _remote;
    
      @override
      Future<Result<List<Order>>> fetchOrders({int page = 1}) =>
          _guard(() async {
            final dtos = await _remote.getOrders(page: page);
            return dtos.map((dto) => dto.toDomain()).toList(growable: false);
          });
    
      @override
      Future<Result<Order>> markAsShipped(String orderId) =>
          _guard(() async => (await _remote.ship(orderId)).toDomain());
    
      Future<Result<T>> _guard<T>(Future<T> Function() action) async {
        try {
          return Ok(await action());
        } on SocketException {
          return const Err(NetworkFailure());
        } on HttpException catch (e) {
          return Err(ServerFailure(e.message, statusCode: 500));
        }
      }
    }

    Risultato atteso

    Il repository restituisce sempre `Result<...>` con entità di dominio: nessun DTO e nessuna eccezione di rete attraversa il confine della feature.

  4. 4

    Presentation isolata e barrel file: definire l'API pubblica del modulo

    La presentation dipende solo dal dominio. Il controller riceve lo use case via provider e trasforma i Result in uno stato UI sealed, gestibile esaustivamente con switch.

    Il pezzo chiave del passo è il barrel file orders.dart: è l'unico punto di ingresso della feature per il resto dell'app. Esporta la schermata (o meglio la rotta), le entità che davvero servono all'esterno e nulla di più. Repository concreti, DTO, data source e controller restano invisibili.

    Se un file non è elencato nel barrel, nessun'altra feature deve poterlo importare. Nel passo 6 lo faremo rispettare al linter.

    // lib/features/orders/presentation/controllers/orders_controller.dart
    import 'package:riverpod_annotation/riverpod_annotation.dart';
    import '../../../../core/error/failure.dart';
    import '../../../../core/result.dart';
    import '../../domain/entities/order.dart';
    import '../../domain/usecases/get_orders.dart';
    
    sealed class OrdersState {
      const OrdersState();
    }
    
    final class OrdersLoading extends OrdersState {
      const OrdersLoading();
    }
    
    final class OrdersLoaded extends OrdersState {
      const OrdersLoaded(this.orders);
      final List<Order> orders;
    }
    
    final class OrdersError extends OrdersState {
      const OrdersError(this.failure);
      final Failure failure;
    }
    
    // Il provider dello use case viene sovrascritto dalla composition root.
    final getOrdersProvider = Provider<GetOrders>(
      (ref) => throw UnimplementedError('Override in bootstrap()'),
    );
    
    final ordersControllerProvider =
        AsyncNotifierProvider<OrdersController, OrdersState>(OrdersController.new);
    
    final class OrdersController extends AsyncNotifier<OrdersState> {
      @override
      Future<OrdersState> build() => _load();
    
      Future<OrdersState> _load() async {
        final result = await ref.read(getOrdersProvider)();
        return switch (result) {
          Ok(:final value) => OrdersLoaded(value),
          Err(:final failure) => OrdersError(failure),
        };
      }
    
      Future<void> refresh() async {
        state = const AsyncLoading();
        state = AsyncData(await _load());
      }
    }
    
    // lib/features/orders/orders.dart  --> API PUBBLICA DELLA FEATURE
    export 'domain/entities/order.dart' show Order, OrderStatus;
    export 'orders_module.dart' show ordersRoutes, ordersOverrides;
    // NON esportiamo: dto, datasources, repositories impl, controller interni.

    Risultato atteso

    Dall'esterno la feature è una scatola nera: `import 'features/orders/orders.dart';` mostra solo `Order`, `OrderStatus`, le rotte e gli override di DI.

  5. 5

    Composition root: collegare i moduli senza accoppiarli tra loro

    I moduli non si conoscono: qualcuno deve però istanziare OrderRepositoryImpl e iniettarlo. Quel qualcuno è la composition root in lib/app/, l'unico posto autorizzato a vedere le implementazioni concrete.

    Ogni feature espone due cose nel suo *_module.dart:

    • ordersOverrides: la lista di Override di Riverpod che lega contratti e implementazioni;
    • ordersRoutes: le sue RouteBase per go_router, così il router dell'app non contiene percorsi hard-coded di ogni schermata.

    Aggiungere una nuova feature diventa un'operazione di due righe nel bootstrap, senza toccare i moduli esistenti: è il test definitivo della qualità dei confini.

    Comunicazione tra feature. Se orders ha bisogno dell'utente loggato, non importa la repository di auth: dichiara un proprio contratto nel suo dominio (es. CurrentUserProvider) e la composition root lo soddisfa con un adapter che usa l'API pubblica di auth. La dipendenza resta verso l'astrazione, mai verso l'implementazione altrui.

    // lib/features/orders/orders_module.dart
    import 'package:flutter_riverpod/flutter_riverpod.dart';
    import 'package:go_router/go_router.dart';
    import '../../core/network/api_client.dart';
    import 'data/datasources/order_remote_data_source.dart';
    import 'data/repositories/order_repository_impl.dart';
    import 'domain/repositories/order_repository.dart';
    import 'domain/usecases/get_orders.dart';
    import 'presentation/controllers/orders_controller.dart';
    import 'presentation/screens/orders_screen.dart';
    
    final _orderRepositoryProvider = Provider<OrderRepository>(
      (ref) => OrderRepositoryImpl(
        HttpOrderRemoteDataSource(ref.watch(apiClientProvider)),
      ),
    );
    
    /// Override che la composition root deve installare.
    List<Override> get ordersOverrides => [
          getOrdersProvider.overrideWith(
            (ref) => GetOrders(ref.watch(_orderRepositoryProvider)),
          ),
        ];
    
    /// Rotte esposte dalla feature.
    List<RouteBase> get ordersRoutes => [
          GoRoute(
            path: '/orders',
            name: 'orders',
            builder: (_, __) => const OrdersScreen(),
          ),
        ];
    
    // lib/app/bootstrap.dart
    import 'package:flutter/widgets.dart';
    import 'package:flutter_riverpod/flutter_riverpod.dart';
    import '../features/auth/auth.dart';
    import '../features/orders/orders.dart';
    import 'app.dart';
    
    Future<void> bootstrap() async {
      WidgetsFlutterBinding.ensureInitialized();
    
      runApp(
        ProviderScope(
          overrides: [
            ...authOverrides,
            ...ordersOverrides,
          ],
          child: const App(),
        ),
      );
    }
    
    // lib/app/router.dart
    import 'package:go_router/go_router.dart';
    import '../features/auth/auth.dart';
    import '../features/orders/orders.dart';
    
    final appRouter = GoRouter(
      initialLocation: '/orders',
      routes: [
        ...authRoutes,
        ...ordersRoutes,
      ],
    );

    Risultato atteso

    L'app parte e mostra la schermata ordini; rimuovendo due righe dal bootstrap e dal router la feature si disattiva completamente senza errori di compilazione altrove.

  6. 6

    Rendere i confini eseguibili con import_lint e un controllo in CI

    Le convenzioni che nessuno verifica decadono in poche settimane. Trasformiamole quindi in errori di analisi.

    Installa import_lint (basato su custom_lint) e descrivi le regole in import_lint.yaml. Le direttive target/from/not_allow_imports permettono di esprimere esattamente la nostra gerarchia: dominio senza Flutter, presentation senza data, feature che si parlano solo attraverso i barrel.

    Aggiungi poi in CI il comando dart run custom_lint (che restituisce exit code diverso da zero in caso di violazione). Per i team che non vogliono dipendenze extra, lo script Dart in fondo è un guardiano minimale da eseguire con dart run tool/check_boundaries.dart.

    # pubspec.yaml (dev_dependencies)
    dev_dependencies:
      custom_lint: ^0.6.0
      import_lint: ^2.0.0
    
    # analysis_options.yaml
    analyzer:
      plugins:
        - custom_lint
    
    # import_lint.yaml
    import_lint:
      severity: error
      rules:
        domain_is_pure:
          target: "package:my_app/features/**/domain/**.dart"
          from: "package:my_app/**"
          not_allow_imports:
            - "package:flutter/**"
            - "package:my_app/features/**/data/**.dart"
            - "package:my_app/features/**/presentation/**.dart"
    
        presentation_never_touches_data:
          target: "package:my_app/features/**/presentation/**.dart"
          from: "package:my_app/**"
          not_allow_imports:
            - "package:my_app/features/**/data/**.dart"
    
        core_and_shared_are_feature_agnostic:
          target: "package:my_app/{core,shared}/**.dart"
          from: "package:my_app/**"
          not_allow_imports:
            - "package:my_app/features/**"
    
        cross_feature_only_via_barrel:
          target: "package:my_app/features/**"
          from: "package:my_app/features/**"
          not_allow_imports:
            - "package:my_app/features/*/{domain,data,presentation}/**.dart"
          exclude_imports:
            - "package:my_app/features/*/[a-z_]*.dart"

    Risultato atteso

    Un import `package:my_app/features/orders/data/...` dentro `auth` viene segnalato come errore dall'IDE e fa fallire `dart run custom_lint` in pipeline.

  7. 7

    Verificare l'architettura con test: contract test e guardiano delle dipendenze

    Ultimo tassello: i test che proteggono i confini nel tempo.

    1. Contract test del repository: un unico set di test viene eseguito su ogni implementazione del contratto (HTTP, cache, fake). Se domani aggiungi CachedOrderRepository, riusi gli stessi test e sei certo che il comportamento visto dal dominio non cambia.
    2. Test di architettura: uno script che scandisce i sorgenti e fallisce se trova import proibiti. È la rete di sicurezza anche per chi non ha il plugin di lint attivo nell'IDE.
    3. Test della presentation con fake: sostituisci getOrdersProvider con uno use case che usa un FakeOrderRepository in memoria — nessun mock di Dio, nessun JSON, test velocissimi.

    Esegui il tutto con flutter test e collega il comando alla pipeline: da questo momento l'architettura non è più una convenzione, è un vincolo verificato a ogni push.

    // test/architecture/boundaries_test.dart
    import 'dart:io';
    import 'package:test/test.dart';
    
    void main() {
      final dartFiles = Directory('lib')
          .listSync(recursive: true)
          .whereType<File>()
          .where((f) => f.path.endsWith('.dart'));
    
      test('il domain non dipende da Flutter né da data/presentation', () {
        final violations = <String>[];
    
        for (final file in dartFiles) {
          final path = file.path.replaceAll(r'\', '/');
          if (!path.contains('/domain/')) continue;
    
          for (final line in file.readAsLinesSync()) {
            if (!line.trimLeft().startsWith('import')) continue;
            final forbidden = line.contains('package:flutter/') ||
                line.contains('/data/') ||
                line.contains('/presentation/');
            if (forbidden) violations.add('$path -> ${line.trim()}');
          }
        }
    
        expect(violations, isEmpty, reason: violations.join('\n'));
      });
    
      test('le feature dialogano solo tramite il barrel file', () {
        final pattern =
            RegExp(r"import\s+'package:my_app/features/(\w+)/(domain|data|presentation)/");
        final violations = <String>[];
    
        for (final file in dartFiles) {
          final path = file.path.replaceAll(r'\', '/');
          final owner = RegExp(r'lib/features/(\w+)/').firstMatch(path)?.group(1);
          if (owner == null) continue;
    
          for (final line in file.readAsLinesSync()) {
            final match = pattern.firstMatch(line);
            if (match != null && match.group(1) != owner) {
              violations.add('$path -> ${line.trim()}');
            }
          }
        }
    
        expect(violations, isEmpty, reason: violations.join('\n'));
      });
    }
    
    // test/features/orders/order_repository_contract.dart
    import 'package:my_app/features/orders/orders.dart';
    import 'package:test/test.dart';
    
    /// Suite riutilizzabile per QUALSIASI implementazione del contratto.
    void runOrderRepositoryContract(
      String name,
      OrderRepositoryFactory build,
    ) {
      group('OrderRepository contract: $name', () {
        test('fetchOrders restituisce entità di dominio', () async {
          final repo = await build();
          final result = await repo.fetchOrders();
          expect(result, isA<Ok<List<Order>>>());
        });
    
        test('markAsShipped su id inesistente restituisce un Failure', () async {
          final repo = await build();
          final result = await repo.markAsShipped('missing');
          expect(result, isA<Err<Order>>());
        });
      });
    }

    Risultato atteso

    `flutter test` verde su contract test e test di architettura: ogni violazione dei confini viene intercettata prima della code review.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!