Prvotřídní architektura s čistou architekturou ve Flutteru
Jak aplikace Flutter roste, struktura kódu se stává faktorem určujícím faktorem mezi aplikací, která se škáluje, a aplikací, která hromadí technický dluh. Organizace Funkce-první — kde každý adresář nejvyšší úrovně představuje a samostatná funkce – a vzor přijatý hlavními podnikovými aplikacemi Flutter úspěch v roce 2026 v kombinaci s principy Čistá architektura od Roberta C. Martina přísně oddělit zájmy v rámci každého prvku.
Tento článek vytváří kompletní architekturu od začátku: struktura adresářů, implementace tří vrstev (data, doména, prezentace), vkládání závislostí s Riverpodem a strategie pro udržení modulů extrahovatelné a nezávisle testovatelné.
Co se naučíte
- Feature-First vs Layer-First: Proč se první škáluje lépe ve velkých týmech
- Tři vrstvy čisté architektury: data, doména, prezentace
- Vzor úložiště: Abstrahování přístupu k datům pomocí rozhraní Dart
- Use Case (Interactor): Zapouzdření obchodní logiky do testovatelných tříd
- Dependency Injection with Riverpod: Jak spojit vrstvy bez spojení
- Struktura adresářů pro podnikový projekt Flutter
- Testování podle vrstvy: jednotková testovací doména, testovací prezentace widgetů
- Jak extrahovat funkci do samostatného balíčku Dart
Feature-First vs Layer-First: Základní srovnání
Většina výukových programů Flutter organizuje kód podle typu:
models/, repositories/, screens/.
Tato struktura Layer-First vypadá úhledně malá, ale nezvětšuje se.
# 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.
Anatomie rysu: Tři vrstvy
V rámci každého prvku Clean Architecture ukládá tři vrstvy s jednosměrné závislosti: Prezentace záleží na Doména, Datum záleží na Doména, ale Doména na nikom nezávisí. Toto pravidlo je srdce architektury.
# 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 Layer: Entity and Repository Interface
# 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 Layer: Use Case
Případy použití (neboli Interactors) zapouzdřují jedinou akci obchodní logiky. jsem malé, testovatelné třídy s jednou odpovědností. Pokud případ použití roste příliš mnoho a signál, který je třeba rozdělit.
# 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);
});
}
Datová vrstva: DTO a implementace úložiště
# 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();
}
Dependency Injection s Riverpodem
Riverpod je lepidlo, které spojuje tři vrstvy bez přímého spojení:
každý poskytovatel deklaruje své závislosti prostřednictvím ref.watch e
Riverpod řeší celý graf závislostí líným a typově bezpečným způsobem.
# 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]),
),
),
);
}
}
Testování podle vrstvy
Clean Architecture umožňuje optimální testovací strategii: každá vrstva má vlastní typ testu s minimálním nezbytným nastavením.
# 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;
}
Kdy extrahovat funkci v balíčku
Když je funkce znovu použita ve více aplikacích (např. auth sdílené mezi
spotřebitelská a administrátorská aplikace) a je čas vytvořit samostatný balíček Dart:
dart create --template=package packages/auth_feature. The Clean
Architektura dělá tuto extrakci téměř mechanickou: zkopírujete adresář souboru
v novém balíčku a aktualizujte importy. Nulové architektonické refaktorování.
Závěry
Feature-First Architecture s Clean Architecture není nejjednodušší řešení pro malou aplikaci – a správné řešení pro aplikaci, která potřebuje růst a vyvíjet se a přežít změnu týmů. Počáteční náklady na nastavení (struktura adresářů, rozhraní, DTO, případy použití) se rychle amortizuje: každá nová funkce následuje stejný vzor, každý vývojář přesně ví, kde najít a kam umístit kód, a každou vrstvu lze testovat a vyměnit nezávisle.
V roce 2026 budou nejrobustnější podnikové aplikace Flutter – od Nubank přes eBay až po ByteDance – všechny konvergují k variantám této struktury. Není to trend: je to odpověď procvičte si skutečné problémy, které se objeví, když Flutter překročí hranice 3členného týmu.







