Flutter의 깔끔한 아키텍처를 갖춘 기능 우선 아키텍처
Flutter 앱이 성장함에 따라 코드 구조가 중요한 요소가 됩니다. 확장 가능한 앱과 기술적 부채를 축적하는 앱 사이의 결정 요인입니다. 조직 기능 우선 — 각 최상위 디렉토리는 독립형 기능 및 주요 기업 Flutter 앱에서 채택한 패턴 2026년의 성공을 원칙과 결합하여 클린 아키텍처 Robert C. Martin이 각 기능 내에서 우려사항을 엄격하게 분리했습니다.
이 문서에서는 처음부터 완전한 아키텍처를 구축합니다. 디렉토리, 세 가지 계층(데이터, 도메인, 프리젠테이션) 구현, Riverpod를 통한 종속성 주입 및 모듈 추출 가능 유지 전략 독립적으로 테스트 가능합니다.
무엇을 배울 것인가
- 기능 우선 대 레이어 우선: 대규모 팀에서 전자가 더 잘 확장되는 이유
- 클린 아키텍처의 세 가지 계층: 데이터, 도메인, 프레젠테이션
- 저장소 패턴: Dart 인터페이스로 데이터 액세스 추상화
- 사용 사례(인터랙터): 테스트 가능한 클래스에 비즈니스 로직을 캡슐화합니다.
- Riverpod를 사용한 종속성 주입: 결합 없이 레이어를 연결하는 방법
- 기업 Flutter 프로젝트의 디렉터리 구조
- 레이어별 테스트: 단위 테스트 도메인, 위젯 테스트 프레젠테이션
- 별도의 Dart 패키지로 기능을 추출하는 방법
기능 우선 대 레이어 우선: 기본 비교
대부분의 Flutter 튜토리얼은 유형별로 코드를 구성합니다.
models/, repositories/, screens/.
이 레이어 우선 구조는 깔끔하게 작아 보이지만 확장되지는 않습니다.
# 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.
지형지물 분석: 세 가지 레이어
각 기능 내에서 클린 아키텍처는 다음과 같은 세 가지 레이어를 적용합니다. 단방향 종속성: 프레젠테이션 그것은에 달려있다 도메인, 날짜 그것은에 달려있다 도메인, 하지만 도메인은 누구에게도 의존하지 않습니다.. 이 법칙은 마음이다 건축의.
# 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는 직접적인 결합 없이 세 개의 레이어를 연결하는 접착제입니다.
각 공급자는 다음을 통해 종속성을 선언합니다. ref.watch 전자
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]),
),
),
);
}
}
레이어별 테스트
Clean Architecture는 최적의 테스트 전략을 가능하게 합니다. 각 레이어에는 최소한의 필수 설정으로 자체 테스트 유형을 테스트할 수 있습니다.
# 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인 팀 이상으로 확장될 때 나타나는 실제 문제를 연습해 보세요.







