Flutter のクリーン アーキテクチャを備えた機能優先アーキテクチャ
Flutter アプリが成長するにつれて、コード構造が重要な要素になります スケールするアプリと技術的負債を蓄積するアプリの決定要因。組織 機能優先 — ここで、各トップレベルのディレクトリは スタンドアロン機能 — および大手エンタープライズ Flutter アプリで採用されているパターン の原則と組み合わせて、2026 年の成功を目指します。 クリーンなアーキテクチャ Robert C. Martin 著。各機能内の懸念事項を厳密に分離しています。
この記事では、完全なアーキテクチャを最初から構築します: 構造 ディレクトリの実装、3 つの層 (データ、ドメイン、プレゼンテーション) の実装、 Riverpod による依存関係の注入、およびモジュールを抽出可能に保つための戦略 独立してテスト可能です。
何を学ぶか
- 機能ファーストとレイヤーファースト: 大規模チームでは前者の方がより適切に拡張できる理由
- クリーン アーキテクチャの 3 つの層: データ、ドメイン、プレゼンテーション
- リポジトリ パターン: Dart インターフェイスによるデータ アクセスの抽象化
- ユースケース (インタラクター): ビジネス ロジックをテスト可能なクラスにカプセル化する
- Riverpod を使用した依存関係の注入: 結合せずにレイヤーを接続する方法
- エンタープライズ Flutter プロジェクトのディレクトリ構造
- レイヤー別のテスト: 単体テスト ドメイン、ウィジェット テスト プレゼンテーション
- 機能を別の Dart パッケージに抽出する方法
機能優先とレイヤー優先: 基本的な比較
ほとんどの Flutter チュートリアルでは、コードをタイプ別に整理しています。
models/, repositories/, screens/。
この Layer-First 構造は小さく見えますが、拡張はできません。
# Layer-First (NON SCALABILE per progetti grandi)
lib/
models/
user.dart
product.dart
cart.dart
order.dart
repositories/
user_repository.dart
product_repository.dart
cart_repository.dart
screens/
login_screen.dart
product_list_screen.dart
cart_screen.dart
blocs/
auth_bloc.dart
product_bloc.dart
cart_bloc.dart
# Problema: per aggiungere la feature "Cart" devi toccare
# 4 directory diverse. Impossibile estrarre il modulo.
# Team A e Team B modificano gli stessi file contemporaneamente.
# Feature-First (SCALABILE)
lib/
features/
auth/
data/
domain/
presentation/
products/
data/
domain/
presentation/
cart/
data/
domain/
presentation/
core/
network/
storage/
theme/
# La feature "Cart" e completamente autonoma in lib/features/cart/
# Un team lavora su cart/ senza toccare le altre feature.
# In futuro: dart create --template=package cart e sposta la directory.
機能の構造: 3 つの層
各機能内で、クリーン アーキテクチャは次の 3 つのレイヤーを課します。 一方向の依存関係: プレゼンテーション それはに依存します ドメイン, 日付 それはに依存します ドメイン、 しかし ドメインは誰にも依存しません。このルールが肝だ 建築の。
# Struttura completa della feature "products"
lib/features/products/
# DOMAIN LAYER: entita pure Dart, nessuna dipendenza esterna
domain/
entities/
product.dart # Entita del dominio (pure Dart class)
product_filter.dart # Value object per i filtri
repositories/
products_repository.dart # Interfaccia (abstract class)
usecases/
get_products_usecase.dart # Recupera lista prodotti
search_products_usecase.dart # Cerca prodotti per query
get_product_detail_usecase.dart
# DATA LAYER: implementa le interfacce domain con source concrete
data/
models/
product_dto.dart # DTO: mappa JSON API -> entita domain
sources/
products_remote_source.dart # HTTP calls
products_local_source.dart # SQLite / Hive cache
repositories/
products_repository_impl.dart # Implementa ProductsRepository
# PRESENTATION LAYER: UI e state management
presentation/
providers/
products_provider.dart # Riverpod providers
pages/
products_list_page.dart
product_detail_page.dart
widgets/
product_card.dart
product_filter_bar.dart
ドメイン層: エンティティとリポジトリのインターフェイス
# domain/entities/product.dart
// Entita: pure Dart, zero dipendenze da Flutter o pacchetti esterni
// Immutabile per convenzione (const constructor + final fields)
class Product {
const Product({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
required this.category,
this.description = '',
this.rating = 0.0,
this.inStock = true,
});
final String id;
final String name;
final double price;
final String imageUrl;
final String category;
final String description;
final double rating;
final bool inStock;
// copyWith: aggiorna campi mantenendo l'immutabilita
Product copyWith({
String? id,
String? name,
double? price,
String? imageUrl,
String? category,
String? description,
double? rating,
bool? inStock,
}) {
return Product(
id: id ?? this.id,
name: name ?? this.name,
price: price ?? this.price,
imageUrl: imageUrl ?? this.imageUrl,
category: category ?? this.category,
description: description ?? this.description,
rating: rating ?? this.rating,
inStock: inStock ?? this.inStock,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Product && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
// domain/repositories/products_repository.dart
// Interfaccia: il Domain layer definisce IL CONTRATTO
// Il Data layer lo implementa. Il Domain non sa COME vengono presi i dati.
abstract interface class ProductsRepository {
Future<List<Product>> getProducts({
String? category,
int page = 1,
int limit = 20,
});
Future<Product> getProductById(String id);
Future<List<Product>> searchProducts(String query);
Future<void> refreshCache();
}
ドメイン層: 使用例
ユース ケース (またはインタラクター) は、単一のビジネス ロジック アクションをカプセル化します。私は 小規模でテスト可能な、単一の責任を負うクラス。ユースケースが拡大した場合 多すぎるため、信号を分割する必要があります。
# domain/usecases/get_products_usecase.dart
import 'package:my_app/features/products/domain/entities/product.dart';
import 'package:my_app/features/products/domain/repositories/products_repository.dart';
// Parametri del Use Case: value object per type safety
class GetProductsParams {
const GetProductsParams({
this.category,
this.page = 1,
this.limit = 20,
});
final String? category;
final int page;
final int limit;
}
// Use Case: dipende SOLO dall'interfaccia repository (domain)
// Non sa nulla di HTTP, Dio, SQLite o Riverpod
class GetProductsUseCase {
const GetProductsUseCase(this._repository);
final ProductsRepository _repository;
Future<List<Product>> call(GetProductsParams params) {
return _repository.getProducts(
category: params.category,
page: params.page,
limit: params.limit,
);
}
}
// Test del Use Case: zero dipendenze da Flutter
// test/features/products/domain/get_products_usecase_test.dart
class MockProductsRepository extends Mock implements ProductsRepository {}
void main() {
late GetProductsUseCase useCase;
late MockProductsRepository mockRepo;
setUp(() {
mockRepo = MockProductsRepository();
useCase = GetProductsUseCase(mockRepo);
});
test('chiama repository con i parametri corretti', () async {
// Arrange
const params = GetProductsParams(category: 'electronics', page: 2);
when(() => mockRepo.getProducts(
category: 'electronics',
page: 2,
limit: 20,
)).thenAnswer((_) async => []);
// Act
await useCase(params);
// Assert
verify(() => mockRepo.getProducts(
category: 'electronics',
page: 2,
limit: 20,
)).called(1);
});
}
データ層: DTO とリポジトリの実装
# data/models/product_dto.dart
// DTO (Data Transfer Object): sa come mappare JSON <-> entita domain
// Dipende dal domain (Product entity) ma non viceversa
class ProductDto {
const ProductDto({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
required this.category,
this.description,
this.rating,
this.inStock,
});
final String id;
final String name;
final double price;
final String imageUrl;
final String category;
final String? description;
final double? rating;
final bool? inStock;
// Factory: converte Map JSON in DTO
factory ProductDto.fromJson(Map<String, dynamic> json) {
return ProductDto(
id: json['id'] as String,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
imageUrl: json['image_url'] as String,
category: json['category'] as String,
description: json['description'] as String?,
rating: (json['rating'] as num?)?.toDouble(),
inStock: json['in_stock'] as bool?,
);
}
// toEntity: converte DTO in entita domain
Product toEntity() {
return Product(
id: id,
name: name,
price: price,
imageUrl: imageUrl,
category: category,
description: description ?? '',
rating: rating ?? 0.0,
inStock: inStock ?? true,
);
}
}
# data/repositories/products_repository_impl.dart
class ProductsRepositoryImpl implements ProductsRepository {
const ProductsRepositoryImpl({
required this.remoteSource,
required this.localSource,
});
final ProductsRemoteSource remoteSource;
final ProductsLocalSource localSource;
@override
Future<List<Product>> getProducts({
String? category,
int page = 1,
int limit = 20,
}) async {
try {
// Prima prova la cache locale
final cached = await localSource.getProducts(
category: category,
page: page,
limit: limit,
);
if (cached.isNotEmpty) {
return cached.map((dto) => dto.toEntity()).toList();
}
// Se la cache e vuota, chiama l'API remota
final dtos = await remoteSource.getProducts(
category: category,
page: page,
limit: limit,
);
// Salva in cache per la prossima volta
await localSource.saveProducts(dtos);
return dtos.map((dto) => dto.toEntity()).toList();
} catch (e) {
throw ProductsException('Impossibile caricare i prodotti: $e');
}
}
@override
Future<Product> getProductById(String id) async {
final dto = await remoteSource.getProductById(id);
return dto.toEntity();
}
@override
Future<List<Product>> searchProducts(String query) async {
final dtos = await remoteSource.searchProducts(query);
return dtos.map((dto) => dto.toEntity()).toList();
}
@override
Future<void> refreshCache() => localSource.clearAll();
}
Riverpod による依存関係の注入
Riverpod は、3 つのレイヤーを直接結合せずに接続する接着剤です。
各プロバイダーは、次のように依存関係を宣言します。 ref.watch e
Riverpod は、依存関係グラフ全体を遅延かつタイプセーフな方法で解決します。
# presentation/providers/products_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'products_provider.g.dart';
// DATA LAYER providers
@riverpod
ProductsRemoteSource productsRemoteSource(Ref ref) {
return ProductsRemoteSource(dio: ref.watch(dioProvider));
}
@riverpod
ProductsLocalSource productsLocalSource(Ref ref) {
return ProductsLocalSource(db: ref.watch(databaseProvider));
}
// DOMAIN LAYER providers
@riverpod
ProductsRepository productsRepository(Ref ref) {
return ProductsRepositoryImpl(
remoteSource: ref.watch(productsRemoteSourceProvider),
localSource: ref.watch(productsLocalSourceProvider),
);
}
@riverpod
GetProductsUseCase getProductsUseCase(Ref ref) {
return GetProductsUseCase(ref.watch(productsRepositoryProvider));
}
// PRESENTATION LAYER: AsyncNotifier che usa il use case
@riverpod
class ProductsList extends _$ProductsList {
@override
Future<List<Product>> build({String? category}) async {
return ref.watch(getProductsUseCaseProvider).call(
GetProductsParams(category: category),
);
}
Future<void> refresh() async {
await ref.read(productsRepositoryProvider).refreshCache();
ref.invalidateSelf();
}
}
// Nel widget: nessun accoppiamento con i layer sottostanti
class ProductsListPage extends ConsumerWidget {
const ProductsListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsListProvider());
return Scaffold(
appBar: AppBar(title: const Text('Prodotti')),
body: productsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Errore: $e')),
data: (products) => ListView.builder(
itemCount: products.length,
itemBuilder: (context, i) => ProductCard(product: products[i]),
),
),
);
}
}
レイヤごとのテスト
クリーン アーキテクチャにより、最適なテスト戦略が可能になります。各層には、 必要最小限のセットアップで独自のタイプのテストを実行できます。
# Struttura test allineata alla struttura feature
test/
features/
products/
domain/
get_products_usecase_test.dart # Unit test puri (no Flutter)
product_entity_test.dart
data/
product_dto_test.dart # Unit test con JSON fixtures
products_repository_impl_test.dart # Mock remote + local source
presentation/
products_list_page_test.dart # Widget test con provider override
# Widget test con override del provider per mock
testWidgets('Mostra lista prodotti', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// Override del use case con dati mock
getProductsUseCaseProvider.overrideWithValue(
FakeGetProductsUseCase(products: [
const Product(
id: '1',
name: 'iPhone 15',
price: 999.0,
imageUrl: 'https://example.com/iphone.jpg',
category: 'electronics',
),
]),
),
],
child: const MaterialApp(home: ProductsListPage()),
),
);
await tester.pumpAndSettle();
// Verifica che il prodotto mock sia visibile
expect(find.text('iPhone 15'), findsOneWidget);
expect(find.text('999.00 EUR'), findsOneWidget);
});
class FakeGetProductsUseCase extends Fake implements GetProductsUseCase {
FakeGetProductsUseCase({required this.products});
final List<Product> products;
@override
Future<List<Product>> call(GetProductsParams params) async => products;
}
パッケージ内の機能を抽出する場合
機能が複数のアプリで再利用される場合 (例: auth 間で共有される
コンシューマ アプリと管理アプリ)、今度は別の Dart パッケージを作成します。
dart create --template=package packages/auth_feature。ザ・クリーン
アーキテクチャにより、この抽出はほぼ機械的に行われます。つまり、次のディレクトリをコピーします。
新しいパッケージの機能を追加し、インポートを更新します。アーキテクチャーのリファクタリングはゼロ。
結論
クリーン アーキテクチャを備えた機能優先アーキテクチャは最も単純なソリューションではない 小規模なアプリには、成長と進化が必要なアプリには適切なソリューション そしてチームの変更を乗り越えます。初期設定コスト(ディレクトリ構造、 インターフェイス、DTO、ユースケースなど)はすぐに償却されます。各新機能はその後に続きます。 同じパターンで、すべての開発者はコードを見つけてどこに配置するかを正確に知っています。 各レイヤーは個別にテストして置き換えることができます。
2026 年には、Nubank から eBay、ByteDance まで、最も堅牢なエンタープライズ Flutter アプリが登場します。 それらはすべて、この構造のバリアントに向かって収束します。それはトレンドではない、それが答えだ Flutter が 3 人のチームを超えて拡張するときに現れる実際の問題を練習します。







