Feature-First Architecture with Clean Architecture in Flutter
As a Flutter app grows, code structure becomes the factor determining factor between an app that scales and one that accumulates technical debt. The organization Feature-First — where each top-level directory represents a standalone functionality — and the pattern adopted by major enterprise Flutter apps success in 2026, combined with the principles of Clean Architecture by Robert C. Martin to strictly separate concerns within each feature.
This article builds a complete architecture from scratch: structure of directories, implementation of the three layers (data, domain, presentation), dependency injection with Riverpod, and strategies for keeping modules extractable and independently testable.
What You Will Learn
- Feature-First vs Layer-First: Why the former scales better in large teams
- The three layers of Clean Architecture: data, domain, presentation
- Repository Pattern: Abstracting data access with Dart interfaces
- Use Case (Interactor): Encapsulate business logic in testable classes
- Dependency Injection with Riverpod: How to connect layers without coupling
- Directory structure for an enterprise Flutter project
- Testing by layer: unit test domain, widget test presentation
- How to extract a feature into a separate Dart package
Feature-First vs Layer-First: The Fundamental Comparison
Most Flutter tutorials organize code by type:
models/, repositories/, screens/.
This Layer-First structure looks neat small, but doesn't scale.
# 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.
Anatomy of a Feature: The Three Layers
Within each feature, Clean Architecture imposes three layers with one-way dependencies: Presentation it depends on Domain, Date it depends on Domain, but Domain does not depend on anyone. This rule is the heart of architecture.
# 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
Use Cases (or Interactors) encapsulate a single business logic action. I am small, testable, single-accountability classes. If a Use Case grows too much, and a signal that needs to be split.
# 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);
});
}
Data Layer: DTO and Repository Implementation
# 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 with Riverpod
Riverpod is the glue that connects the three layers without direct coupling:
each provider declares its dependencies via ref.watch e
Riverpod resolves the entire dependency graph in a lazy and type-safe manner.
# 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]),
),
),
);
}
}
Testing by Layer
The Clean Architecture enables an optimal testing strategy: each layer has the own type of test with the minimum necessary setup.
# 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;
}
When to Extract a Feature in a Package
When a feature is reused in multiple apps (e.g. auth shared between
consumer and admin app) and it's time to create a separate Dart package:
dart create --template=package packages/auth_feature. The Clean
Architecture makes this extraction almost mechanical: you copy the directory of the
feature in the new package and update the imports. Zero architectural refactoring.
Conclusions
Feature-First Architecture with Clean Architecture is not the simplest solution for a small app — and the right solution for an app that needs to grow and evolve and survive the change of teams. The initial setup cost (directory structure, interfaces, DTOs, use cases) is quickly amortized: each new feature follows the same pattern, every developer knows exactly where to find and where to put the code, and each layer can be tested and replaced independently.
In 2026, the most robust enterprise Flutter apps — from Nubank to eBay to ByteDance — they all converge towards variants of this structure. It's not a trend: it's the answer practice the real problems that emerge when Flutter scales beyond the 3-person team.







