Every Flutter app starts clean. Fifty screens later, half the team is afraid to touch the checkout flow because a change in the API model broke the profile page last sprint. After shipping 50+ production Flutter apps at Flutter Studio, we have learned that architecture is not something you add when the app gets big — it is the decision you make on day one that determines whether the app can get big. This guide walks through the exact Clean Architecture structure we use in client projects, with production code from a real e-commerce feature. Every layer, every file, every test — nothing left to imagination.
📚 What You Will Learn
This guide covers the complete Flutter Clean Architecture pattern: the dependency rule, folder-by-feature project structure, domain entities and use cases, repository pattern with dartz Either types for error handling, dependency injection with get_it and injectable, state management integration, and testing every layer with mocktail. A full product-catalog feature is built end-to-end as the running example.
🛠 Prerequisites
You should be comfortable with Dart classes, abstract classes, and generics. Familiarity with at least one state management solution ( BLoC or Riverpod) is helpful but not required — we cover the integration pattern. Experience with Dart language fundamentals is assumed throughout.
The Dependency Rule: The One Rule That Matters
Robert C. Martin's Clean Architecture boils down to one enforced constraint: source code dependencies must point inward. The Domain layer sits at the centre and knows nothing about Flutter, HTTP, or databases. The Data layer depends on the Domain (it implements the domain's repository contracts). The Presentation layer depends on the Domain (it calls use cases). Neither the Data nor Presentation layer knows about each other.
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ Widgets, Pages, BLoC / Riverpod │
│ │
│ Depends on: Domain │
└──────────────────┬──────────────────────┘
│ calls use cases
┌──────────────────▼──────────────────────┐
│ Domain Layer │
│ Entities, Use Cases, Repo Contracts │
│ │
│ Depends on: NOTHING │
└──────────────────┬──────────────────────┘
│ contracts implemented by
┌──────────────────▼──────────────────────┐
│ Data Layer │
│ Models, Data Sources, Repo Impls │
│ │
│ Depends on: Domain │
└─────────────────────────────────────────┘
This inversion is the key insight. The domain defines an abstract ProductRepository
interface. The data layer provides ProductRepositoryImpl that talks to an API. The
presentation layer receives a ProductRepository reference through dependency injection
and never knows (or cares) whether it is talking to a real API, a local database, or a mock.
Martin Fowler's Dependency Injection essay explains this principle in depth.
In practice, the dependency rule means: no import 'package:flutter/... in
the domain layer. If you see a Flutter import in lib/features/*/domain/,
the architecture is already broken. We enforce this with a custom
lint rule that
flags Flutter imports inside domain directories.
Project Structure: Folder-by-Feature
The classic Clean Architecture tutorial shows a flat folder-by-layer structure:
lib/domain/, lib/data/, lib/presentation/. This works
for a tutorial with one entity. In production, an app with 12 features ends up with
domain/entities/ containing 40 unrelated files. Folder-by-feature solves this by
co-locating each feature's layers inside one directory:
lib/
├── app.dart # MaterialApp, router, theme
├── core/ # Shared utilities
│ ├── error/
│ │ ├── failures.dart # Failure sealed class
│ │ └── exceptions.dart # Server/Cache exceptions
│ ├── network/
│ │ ├── api_client.dart # Dio instance setup
│ │ └── network_info.dart # Connectivity checker
│ ├── usecases/
│ │ └── usecase.dart # Base UseCase<Type, Params>
│ └── di/
│ └── injection.dart # get_it container init
│
├── features/
│ ├── product/ # ← One complete feature
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ └── product.dart
│ │ │ ├── repositories/
│ │ │ │ └── product_repository.dart # Abstract contract
│ │ │ └── usecases/
│ │ │ ├── get_products.dart
│ │ │ └── get_product_detail.dart
│ │ ├── data/
│ │ │ ├── models/
│ │ │ │ └── product_model.dart # JSON mapping
│ │ │ ├── datasources/
│ │ │ │ ├── product_remote_source.dart
│ │ │ │ └── product_local_source.dart
│ │ │ └── repositories/
│ │ │ └── product_repository_impl.dart
│ │ └── presentation/
│ │ ├── pages/
│ │ │ ├── product_list_page.dart
│ │ │ └── product_detail_page.dart
│ │ ├── widgets/
│ │ │ └── product_card.dart
│ │ └── bloc/ # Or providers/
│ │ ├── product_bloc.dart
│ │ ├── product_event.dart
│ │ └── product_state.dart
│ │
│ ├── auth/ # Another feature
│ │ ├── domain/ ...
│ │ ├── data/ ...
│ │ └── presentation/ ...
│ │
│ └── cart/ # Another feature
│ ├── domain/ ...
│ ├── data/ ...
│ └── presentation/ ...
│
└── main.dart # Entry point, init DI
Each feature is self-contained. You can delete features/cart/ and the product feature
still compiles. Cross-feature communication happens through the domain layer — for example,
the cart use case accepts a Product entity from the product domain. This structure
scales to 20+ features without navigational confusion. The
Effective Dart
guidelines recommend organising by purpose rather than by type for the same reasons.
Domain Layer: Entities, Repositories & Use Cases
The domain is pure Dart. No import 'package:flutter/.... No
import 'package:dio/.... Only Dart core and your own domain files. This constraint
is what makes the domain testable in under 100 milliseconds with zero setup.
Entities
Entities represent business objects. They are not database models or JSON DTOs — they contain only the fields that matter to business logic. We use equatable for value equality, which simplifies testing and state comparisons in BLoC:
import 'package:equatable/equatable.dart';
class Product extends Equatable {
final String id;
final String name;
final String description;
final double price;
final String imageUrl;
final String category;
final bool inStock;
const Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.imageUrl,
required this.category,
required this.inStock,
});
@override
List<Object?> get props =>
[id, name, description, price, imageUrl, category, inStock];
}
Notice: no fromJson, no toJson, no database annotations. The entity
knows nothing about how it is stored or transmitted. That responsibility belongs to the data
layer's model class. For immutable data classes with copyWith support, add
freezed —
but only if your entities genuinely need copy-with. Do not add freezed reflexively.
Repository Contracts
The domain defines abstract repository interfaces. The data layer implements them. This is the Dependency Inversion Principle in action:
import 'package:dartz/dartz.dart';
import '../entities/product.dart';
import '../../core/error/failures.dart';
abstract class ProductRepository {
Future<Either<Failure, List<Product>>> getProducts({
required String category,
required int page,
});
Future<Either<Failure, Product>> getProductById(String id);
Future<Either<Failure, List<Product>>> searchProducts(String query);
}
Every method returns Either<Failure, T> from
dartz. The left
side carries a typed failure, the right side carries the success value. No exceptions cross
layer boundaries — failures are first-class values. We cover error handling in detail in
Section 6.
Use Cases
Each use case encapsulates one business action. It receives a repository through its constructor
and exposes a single call() method (making it callable like a function).
Use cases are the API surface of your domain — the presentation layer never talks to
repositories directly:
import 'package:dartz/dartz.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
import '../../core/error/failures.dart';
class GetProducts {
final ProductRepository repository;
const GetProducts(this.repository);
Future<Either<Failure, List<Product>>> call({
required String category,
required int page,
}) {
return repository.getProducts(category: category, page: page);
}
}
class GetProductDetail {
final ProductRepository repository;
const GetProductDetail(this.repository);
Future<Either<Failure, Product>> call(String id) {
return repository.getProductById(id);
}
}
Use cases might look like pointless wrappers when they simply delegate to the repository. Their value becomes obvious when business rules emerge: “show out-of-stock products last”, “apply user-tier pricing before returning”, “log analytics events on every product view”. Those rules live in the use case, not in the widget, not in the repository. When you later need to test that sorting logic, you test the use case in isolation with a mock repository — no Flutter, no HTTP, no database, no setup.
Data Layer: Models, Data Sources & Repository Implementations
The data layer sits at the outer ring. It implements the domain's repository contracts and handles all external communication — REST APIs, GraphQL, local databases, shared preferences. The key design: data models are separate from domain entities.
Data Models
Models mirror entities but add serialisation logic. We use json_serializable for code generation, which eliminates hand-written JSON boilerplate and catches mismatched keys at build time:
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/product.dart';
part 'product_model.g.dart';
@JsonSerializable()
class ProductModel {
final String id;
final String name;
final String description;
final double price;
@JsonKey(name: 'image_url')
final String imageUrl;
final String category;
@JsonKey(name: 'in_stock')
final bool inStock;
const ProductModel({
required this.id,
required this.name,
required this.description,
required this.price,
required this.imageUrl,
required this.category,
required this.inStock,
});
factory ProductModel.fromJson(Map<String, dynamic> json) =>
_$ProductModelFromJson(json);
Map<String, dynamic> toJson() => _$ProductModelToJson(this);
/// Convert data model to domain entity
Product toEntity() => Product(
id: id,
name: name,
description: description,
price: price,
imageUrl: imageUrl,
category: category,
inStock: inStock,
);
/// Create model from domain entity (for caching)
factory ProductModel.fromEntity(Product entity) => ProductModel(
id: entity.id,
name: entity.name,
description: entity.description,
price: entity.price,
imageUrl: entity.imageUrl,
category: entity.category,
inStock: entity.inStock,
);
}
The toEntity() method converts from external representation to domain representation.
Some teams skip data models entirely and add fromJson directly to entities —
this technically works but breaks the dependency rule, because your domain now knows about JSON
field names from the API. When the backend renames image_url to
imageURL, you change one @JsonKey annotation instead of hunting
through domain code. Run dart run build_runner build to generate the
.g.dart file after any model change.
Data Sources
Data sources handle raw I/O. The remote data source talks to the API using dio. The local data source caches responses using Hive or drift:
import 'package:dio/dio.dart';
import '../models/product_model.dart';
abstract class ProductRemoteDataSource {
Future<List<ProductModel>> getProducts({
required String category,
required int page,
});
Future<ProductModel> getProductById(String id);
Future<List<ProductModel>> searchProducts(String query);
}
class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
final Dio dio;
const ProductRemoteDataSourceImpl({required this.dio});
@override
Future<List<ProductModel>> getProducts({
required String category,
required int page,
}) async {
final response = await dio.get(
'/products',
queryParameters: {'category': category, 'page': page},
);
final List<dynamic> data = response.data['products'];
return data
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<ProductModel> getProductById(String id) async {
final response = await dio.get('/products/$id');
return ProductModel.fromJson(
response.data['product'] as Map<String, dynamic>,
);
}
@override
Future<List<ProductModel>> searchProducts(String query) async {
final response = await dio.get(
'/products/search',
queryParameters: {'q': query},
);
final List<dynamic> data = response.data['products'];
return data
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
.toList();
}
}
Repository Implementation
The repository implementation is where everything connects. It calls data sources, maps models to entities, and catches exceptions into typed failures:
import 'package:dartz/dartz.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
import '../../core/error/failures.dart';
import '../../core/error/exceptions.dart';
import '../../core/network/network_info.dart';
import '../datasources/product_remote_source.dart';
import '../datasources/product_local_source.dart';
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteDataSource remoteDataSource;
final ProductLocalDataSource localDataSource;
final NetworkInfo networkInfo;
const ProductRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, List<Product>>> getProducts({
required String category,
required int page,
}) async {
if (await networkInfo.isConnected) {
try {
final models = await remoteDataSource.getProducts(
category: category,
page: page,
);
final entities = models.map((m) => m.toEntity()).toList();
// Cache first page for offline access
if (page == 1) {
await localDataSource.cacheProducts(category, models);
}
return Right(entities);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
} else {
try {
final cached = await localDataSource.getCachedProducts(category);
return Right(cached.map((m) => m.toEntity()).toList());
} on CacheException {
return const Left(CacheFailure('No cached data available'));
}
}
}
@override
Future<Either<Failure, Product>> getProductById(String id) async {
try {
final model = await remoteDataSource.getProductById(id);
return Right(model.toEntity());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
}
@override
Future<Either<Failure, List<Product>>> searchProducts(
String query,
) async {
try {
final models = await remoteDataSource.searchProducts(query);
return Right(models.map((m) => m.toEntity()).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
}
}
Notice the offline-first pattern: check connectivity, try remote, fall back to cache. Exceptions
from data sources are caught here and converted to Failure values. No
exception escapes the data layer. The domain and presentation layers receive clean
Either values. For a dedicated guide to the offline-first approach with drift, see
our Offline-First Flutter with Drift article.
Presentation Layer: State Management Integration
The presentation layer calls use cases and maps the Either result to UI states.
You can use BLoC, Riverpod, or any state management solution — the architecture does not
dictate which. Here is a BLoC implementation using
flutter_bloc:
// product_event.dart
sealed class ProductEvent {
const ProductEvent();
}
class LoadProducts extends ProductEvent {
final String category;
final int page;
const LoadProducts({required this.category, this.page = 1});
}
class LoadProductDetail extends ProductEvent {
final String productId;
const LoadProductDetail(this.productId);
}
// product_state.dart
sealed class ProductState {
const ProductState();
}
class ProductInitial extends ProductState {
const ProductInitial();
}
class ProductLoading extends ProductState {
const ProductLoading();
}
class ProductsLoaded extends ProductState {
final List<Product> products;
const ProductsLoaded(this.products);
}
class ProductDetailLoaded extends ProductState {
final Product product;
const ProductDetailLoaded(this.product);
}
class ProductError extends ProductState {
final String message;
const ProductError(this.message);
}
// product_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
class ProductBloc extends Bloc<ProductEvent, ProductState> {
final GetProducts getProducts;
final GetProductDetail getProductDetail;
ProductBloc({
required this.getProducts,
required this.getProductDetail,
}) : super(const ProductInitial()) {
on<LoadProducts>(_onLoadProducts);
on<LoadProductDetail>(_onLoadProductDetail);
}
Future<void> _onLoadProducts(
LoadProducts event,
Emitter<ProductState> emit,
) async {
emit(const ProductLoading());
final result = await getProducts(
category: event.category,
page: event.page,
);
result.fold(
(failure) => emit(ProductError(failure.message)),
(products) => emit(ProductsLoaded(products)),
);
}
Future<void> _onLoadProductDetail(
LoadProductDetail event,
Emitter<ProductState> emit,
) async {
emit(const ProductLoading());
final result = await getProductDetail(event.productId);
result.fold(
(failure) => emit(ProductError(failure.message)),
(product) => emit(ProductDetailLoaded(product)),
);
}
}
The BLoC receives use cases, not repositories. It calls the use case, folds the
Either result, and emits the appropriate state. The presentation layer never imports anything
from the data layer — no models, no data sources, no Dio. If you prefer Riverpod, the same
pattern applies using AsyncNotifier. Check our
BLoC vs Riverpod comparison
for guidance on which to choose.
On the widget side, BlocBuilder maps states to widgets:
class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: BlocBuilder<ProductBloc, ProductState>(
builder: (context, state) => switch (state) {
ProductInitial() => const SizedBox.shrink(),
ProductLoading() => const Center(
child: CircularProgressIndicator(),
),
ProductsLoaded(:final products) => ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ProductCard(
product: products[index],
),
),
ProductDetailLoaded() => const SizedBox.shrink(),
ProductError(:final message) => Center(
child: Text('Error: $message'),
),
},
),
);
}
}
Dart 3's pattern
matching with sealed classes makes the switch exhaustive — the compiler forces you to
handle every state, eliminating forgotten edge cases. The :final destructuring syntax
extracts fields directly in the pattern.
Error Handling with Either Types
Exceptions are invisible control flow. A try-catch three layers up from the throw
site is easy to forget and impossible to enforce at compile time. The
dartz package provides
Either<L, R> — a type that holds exactly one of two values.
Left represents failure, Right represents success. An alternative is
fpdart which offers
the same Either type with a more modern, Dart-idiomatic API.
Defining Failures
// core/error/failures.dart
sealed class Failure {
final String message;
const Failure(this.message);
}
class ServerFailure extends Failure {
const ServerFailure(super.message);
}
class CacheFailure extends Failure {
const CacheFailure(super.message);
}
class NetworkFailure extends Failure {
const NetworkFailure([super.message = 'No internet connection']);
}
class ValidationFailure extends Failure {
const ValidationFailure(super.message);
}
// core/error/exceptions.dart
class ServerException implements Exception {
final String message;
final int? statusCode;
const ServerException(this.message, {this.statusCode});
}
class CacheException implements Exception {
final String message;
const CacheException([this.message = 'Cache error']);
}
Using sealed class for failures means you can switch-match on them exhaustively
in the presentation layer, just like states. Each failure carries a descriptive message for
user-facing error UI. The sealed hierarchy prevents unknown failure types from slipping through.
The Either Flow
// In repository: catch exception, return Left
try {
final data = await remoteSource.fetchProducts();
return Right(data.map((m) => m.toEntity()).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
// In use case: pass through (or add business rules)
Future<Either<Failure, List<Product>>> call() {
return repository.getProducts();
}
// In BLoC: fold into states
final result = await getProducts();
result.fold(
(failure) => emit(ProductError(failure.message)),
(products) => emit(ProductsLoaded(products)),
);
The error path is always explicit. You cannot accidentally ignore a failure because
fold() forces you to handle both sides. Compare this to a try-catch
approach where forgetting to catch SocketException crashes the app in production.
For a comprehensive comparison of error handling approaches in Flutter, including
Result types and sealed unions, see the
Dart error
handling guide.
Dependency Injection with get_it & injectable
get_it is a service locator that acts as your DI container. Combined with injectable, you annotate classes and the code generator writes the registration boilerplate:
// core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';
final getIt = GetIt.instance;
@InjectableInit()
void configureDependencies() => getIt.init();
// main.dart
void main() {
configureDependencies();
runApp(const MyApp());
}
// Annotate classes for auto-registration
@lazySingleton
class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
final Dio dio;
const ProductRemoteDataSourceImpl({required this.dio});
// ... implementation
}
@LazySingleton(as: ProductRepository)
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteDataSource remoteDataSource;
final ProductLocalDataSource localDataSource;
final NetworkInfo networkInfo;
const ProductRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
// ... implementation
}
@injectable
class GetProducts {
final ProductRepository repository;
const GetProducts(this.repository);
// ... implementation
}
@injectable
class ProductBloc extends Bloc<ProductEvent, ProductState> {
ProductBloc({
required GetProducts getProducts,
required GetProductDetail getProductDetail,
}) : // ... constructor
}
Run dart run build_runner build and injectable generates
injection.config.dart with all registrations in the correct order, resolving the
dependency graph automatically. @lazySingleton creates one instance on first access.
@injectable creates a new instance every time. The
@LazySingleton(as: ProductRepository) syntax registers the implementation under the
abstract type, maintaining the dependency rule.
For teams that prefer manual registration (fewer dependencies, more control), here is the equivalent without injectable:
void configureDependencies() {
// External
getIt.registerLazySingleton<Dio>(() => Dio(BaseOptions(
baseUrl: 'https://api.example.com/v1',
connectTimeout: const Duration(seconds: 10),
)));
// Data sources
getIt.registerLazySingleton<ProductRemoteDataSource>(
() => ProductRemoteDataSourceImpl(dio: getIt()),
);
getIt.registerLazySingleton<ProductLocalDataSource>(
() => ProductLocalDataSourceImpl(),
);
// Repositories
getIt.registerLazySingleton<ProductRepository>(
() => ProductRepositoryImpl(
remoteDataSource: getIt(),
localDataSource: getIt(),
networkInfo: getIt(),
),
);
// Use cases
getIt.registerFactory(() => GetProducts(getIt()));
getIt.registerFactory(() => GetProductDetail(getIt()));
// BLoCs
getIt.registerFactory(() => ProductBloc(
getProducts: getIt(),
getProductDetail: getIt(),
));
}
Full Feature Walkthrough: Product Catalog
Let us trace a complete user flow through all three layers. The user opens the Products screen.
The widget dispatches LoadProducts. The BLoC calls
GetProducts. The use case calls the repository. The repository checks connectivity,
calls the remote data source, maps models to entities, caches the first page locally, and returns
Right(products). The BLoC folds the Either and emits
ProductsLoaded. The widget rebuilds with the product list.
// Full flow: Widget → BLoC → UseCase → Repository → DataSource
// 1. Widget dispatches event
context.read<ProductBloc>().add(
const LoadProducts(category: 'electronics', page: 1),
);
// 2. BLoC handles event
on<LoadProducts>((event, emit) async {
emit(const ProductLoading());
final result = await getProducts(
category: event.category,
page: event.page,
);
result.fold(
(failure) => emit(ProductError(failure.message)),
(products) => emit(ProductsLoaded(products)),
);
});
// 3. UseCase delegates to repository
Future<Either<Failure, List<Product>>> call({
required String category,
required int page,
}) => repository.getProducts(category: category, page: page);
// 4. Repository orchestrates data access
Future<Either<Failure, List<Product>>> getProducts(...) async {
if (await networkInfo.isConnected) {
try {
final models = await remoteDataSource.getProducts(...);
await localDataSource.cacheProducts(category, models);
return Right(models.map((m) => m.toEntity()).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
} else {
// Offline fallback
final cached = await localDataSource.getCachedProducts(category);
return Right(cached.map((m) => m.toEntity()).toList());
}
}
// 5. DataSource makes HTTP call
Future<List<ProductModel>> getProducts(...) async {
final response = await dio.get('/products', queryParameters: {...});
return (response.data['products'] as List)
.map((j) => ProductModel.fromJson(j))
.toList();
}
Each layer talks only to the layer directly below it through an abstraction. If you later add pagination caching, search history, or analytics logging, you know exactly which layer to modify. The widget stays untouched. This predictability is what makes Clean Architecture worth the initial setup cost in production apps. For apps with complex forms and validation, see our Beautiful Forms in Flutter guide which applies similar separation principles to form handling.
Testing Each Layer
Clean Architecture's biggest payoff is testability. Each layer can be tested independently by mocking its dependencies. We use mocktail for mock generation — it requires no code generation and works with null safety. See the official Flutter testing guide for the testing framework fundamentals.
Domain Layer: Unit Testing Use Cases
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dartz/dartz.dart';
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
late GetProducts usecase;
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
usecase = GetProducts(mockRepository);
});
final tProducts = [
const Product(
id: '1', name: 'Laptop', description: 'A laptop',
price: 999.99, imageUrl: 'url', category: 'electronics',
inStock: true,
),
];
test('should get products from repository', () async {
// Arrange
when(() => mockRepository.getProducts(
category: any(named: 'category'),
page: any(named: 'page'),
)).thenAnswer((_) async => Right(tProducts));
// Act
final result = await usecase(category: 'electronics', page: 1);
// Assert
expect(result, Right(tProducts));
verify(() => mockRepository.getProducts(
category: 'electronics', page: 1,
)).called(1);
verifyNoMoreInteractions(mockRepository);
});
test('should return failure when repository fails', () async {
when(() => mockRepository.getProducts(
category: any(named: 'category'),
page: any(named: 'page'),
)).thenAnswer(
(_) async => const Left(ServerFailure('Server error')),
);
final result = await usecase(category: 'electronics', page: 1);
expect(result, const Left(ServerFailure('Server error')));
});
}
Data Layer: Testing Repository Implementations
class MockRemoteDataSource extends Mock
implements ProductRemoteDataSource {}
class MockLocalDataSource extends Mock
implements ProductLocalDataSource {}
class MockNetworkInfo extends Mock implements NetworkInfo {}
void main() {
late ProductRepositoryImpl repository;
late MockRemoteDataSource mockRemote;
late MockLocalDataSource mockLocal;
late MockNetworkInfo mockNetwork;
setUp(() {
mockRemote = MockRemoteDataSource();
mockLocal = MockLocalDataSource();
mockNetwork = MockNetworkInfo();
repository = ProductRepositoryImpl(
remoteDataSource: mockRemote,
localDataSource: mockLocal,
networkInfo: mockNetwork,
);
});
group('when online', () {
setUp(() {
when(() => mockNetwork.isConnected).thenAnswer((_) async => true);
});
test('should return remote data and cache first page', () async {
final models = [
const ProductModel(
id: '1', name: 'Laptop', description: 'A laptop',
price: 999.99, imageUrl: 'url', category: 'electronics',
inStock: true,
),
];
when(() => mockRemote.getProducts(
category: any(named: 'category'),
page: any(named: 'page'),
)).thenAnswer((_) async => models);
when(() => mockLocal.cacheProducts(any(), any()))
.thenAnswer((_) async => {});
final result = await repository.getProducts(
category: 'electronics', page: 1,
);
expect(result.isRight(), true);
verify(() => mockLocal.cacheProducts('electronics', models)).called(1);
});
test('should return ServerFailure on exception', () async {
when(() => mockRemote.getProducts(
category: any(named: 'category'),
page: any(named: 'page'),
)).thenThrow(const ServerException('Internal Server Error'));
final result = await repository.getProducts(
category: 'electronics', page: 1,
);
expect(result, isA<Left>());
});
});
group('when offline', () {
setUp(() {
when(() => mockNetwork.isConnected).thenAnswer((_) async => false);
});
test('should return cached data', () async {
final cachedModels = [
const ProductModel(
id: '1', name: 'Laptop', description: 'A laptop',
price: 999.99, imageUrl: 'url', category: 'electronics',
inStock: true,
),
];
when(() => mockLocal.getCachedProducts(any()))
.thenAnswer((_) async => cachedModels);
final result = await repository.getProducts(
category: 'electronics', page: 1,
);
expect(result.isRight(), true);
verifyZeroInteractions(mockRemote);
});
});
}
Presentation Layer: Testing BLoC
import 'package:bloc_test/bloc_test.dart';
class MockGetProducts extends Mock implements GetProducts {}
class MockGetProductDetail extends Mock implements GetProductDetail {}
void main() {
late ProductBloc bloc;
late MockGetProducts mockGetProducts;
late MockGetProductDetail mockGetProductDetail;
setUp(() {
mockGetProducts = MockGetProducts();
mockGetProductDetail = MockGetProductDetail();
bloc = ProductBloc(
getProducts: mockGetProducts,
getProductDetail: mockGetProductDetail,
);
});
tearDown(() => bloc.close());
final tProducts = [
const Product(
id: '1', name: 'Laptop', description: 'A laptop',
price: 999.99, imageUrl: 'url', category: 'electronics',
inStock: true,
),
];
blocTest<ProductBloc, ProductState>(
'emits [Loading, Loaded] when LoadProducts succeeds',
build: () {
when(() => mockGetProducts(
category: any(named: 'category'),
page: any(named: 'page'),
)).thenAnswer((_) async => Right(tProducts));
return bloc;
},
act: (bloc) => bloc.add(
const LoadProducts(category: 'electronics'),
),
expect: () => [
const ProductLoading(),
ProductsLoaded(tProducts),
],
);
blocTest<ProductBloc, ProductState>(
'emits [Loading, Error] when LoadProducts fails',
build: () {
when(() => mockGetProducts(
category: any(named: 'category'),
page: any(named: 'page'),
)).thenAnswer(
(_) async => const Left(ServerFailure('Server error')),
);
return bloc;
},
act: (bloc) => bloc.add(
const LoadProducts(category: 'electronics'),
),
expect: () => [
const ProductLoading(),
const ProductError('Server error'),
],
);
}
Each test suite mocks only the adjacent layer. Use case tests mock the repository. Repository tests mock data sources. BLoC tests mock use cases. No test requires a running server, a database, or a Flutter widget tree. The domain layer tests run in under 50 milliseconds. For widget testing patterns that complement this architecture, see our Flutter Testing Strategy guide.
Clean Architecture vs MVVM, MVC & Feature-First
Clean Architecture is not the only valid choice. Here is an honest comparison based on production experience across all three patterns:
| Aspect | Clean Architecture | MVVM | Feature-First |
|---|---|---|---|
| Separation | 3 strict layers with dependency rule | View + ViewModel + Model | Feature folders, no enforced layers |
| Boilerplate | High (entity + model + mapper + repo interface + repo impl + use case + DI) | Medium (ViewModel + Model) | Low (just the feature code) |
| Testability | Excellent — each layer independently mockable | Good — ViewModels are testable | Varies — depends on developer discipline |
| Backend swap | Only data layer changes | Model + some ViewModel changes | Scattered changes |
| Team scale | 3+ developers, clear boundaries | 1–3 developers | Any team size |
| Best for | Large apps, multi-team, long-lived | Medium apps, simpler domains | Prototypes, hackathons, small apps |
MVVM is perfectly valid for apps with straightforward business logic. Feature-First is our recommendation for prototypes and MVPs where speed matters more than structure. Clean Architecture earns its boilerplate cost when the app has 10+ features, 3+ developers, or a backend that might change. The state management comparison article covers the ViewModel / BLoC / Riverpod choice in depth.
When NOT to Use Clean Architecture
Not every app benefits from Clean Architecture. The setup cost is real: for each feature, you create an entity, a model, a mapper, a repository interface, a repository implementation, a use case, DI registration, and tests for each layer. For a three-screen note-taking app, this is massive over-engineering. Here is our decision framework:
- Skip Clean Architecture for: prototypes, hackathon projects, apps with fewer than 3 features, single-developer hobby apps, apps expected to live less than 6 months.
- Use Clean Architecture for: client apps with ongoing maintenance, apps with 5+ features, teams with 3+ developers, apps where the backend might change (Firebase to Supabase, REST to GraphQL), apps requiring comprehensive test coverage for compliance.
A good middle ground for medium apps: use the folder-by-feature structure from Section 2 with repository abstractions, but skip use cases. Let the state management layer (BLoC or Riverpod) call repositories directly. You get 80% of Clean Architecture's benefits at 60% of the boilerplate. When the app grows past 8–10 features, introduce use cases gradually for features that accumulate business rules. For the full spectrum of state management choices, read our BLoC vs Riverpod comparison.
Migration Path: Refactoring an Existing App
Migrating a monolithic Flutter app to Clean Architecture does not happen in one sprint. Here is the incremental approach we use at Flutter Studio for client projects:
- Extract repository interfaces first. For each data access point in your app,
create an abstract class in a new
domain/repositories/folder. Make your existing code implement that interface. No behaviour changes yet — just indirection. - Move business logic from widgets to a service or use case layer. If your
onPressedcallback fetches data, transforms it, and updates state, extract the fetch + transform into a separate class. The widget should only dispatch events and render states. - Introduce DI. Set up
get_it and
register your repositories and services. Replace constructor-passed dependencies with
getIt<T>()lookups. - Separate models from entities. When you next modify a data model, create a
domain entity alongside it. Add
toEntity()to the model. Do this incrementally, one feature at a time. - Add tests. The real payoff. With repositories behind interfaces and use cases isolated, write unit tests for the domain and data layers. Target 80% coverage for the domain layer as a starting goal.
- Reorganise by feature. Once a feature has all three layers, move its files
into a
features/feature_name/directory. Do one feature per PR to keep reviews manageable.
The migration typically takes 4–8 sprints for a 15-screen app, done alongside feature work — never as a rewrite. Each step is independently shippable and adds value immediately (better testing, clearer boundaries). For apps with complex local storage during migration, see our Offline-First Flutter with Drift guide.
Performance & Maintainability Checklist
Before shipping any feature built with Clean Architecture, verify each item. We use this checklist during code review at Flutter Studio:
- ☑ No Flutter imports in the domain layer. The domain is pure Dart. If
you see
import 'package:flutter/indomain/, the dependency rule is broken. - ☑ Repository interfaces in domain, implementations in data. The domain defines the abstract class. The data layer provides the concrete implementation.
- ☑ Every repository method returns
Either<Failure, T>. No exceptions cross layer boundaries. Failures are values, not thrown objects. - ☑ Data models are separate from domain entities. Models have
fromJson/toJson. Entities have business fields only. ThetoEntity()mapper bridges them. - ☑ Use cases have a single public
call()method. One use case = one business action. If a use case has multiple public methods, it is doing too much. - ☑ State management talks to use cases, not repositories. The presentation layer never imports from the data layer.
- ☑ DI container initialised before
runApp(). All dependencies are registered and resolvable at startup. - ☑ Domain layer test coverage ≥ 80%. Use case tests mock repositories. Tests run in under 1 second.
- ☑ No circular dependencies between features. Feature A can depend on Feature B's domain entities, but not on its data or presentation layers.
- ☑ Folder-by-feature structure maintained. Each feature has its own
domain/,data/, andpresentation/subdirectories.
📚 Related Articles
- BLoC vs Riverpod in 2026: The Definitive Flutter State Management Comparison
- Flutter Testing Strategy: Unit, Widget & Integration Tests
- Offline-First Flutter with Drift: Complete Guide
- Flutter Performance Optimization: A Complete Guide
- Best Flutter State Management 2026: Ranked
- Beautiful Forms in Flutter: A Complete Guide to Professional Form Design
- Flutter App Security: A Complete Guide
- Top 10 Flutter Packages Every Developer Needs in 2026
- Flutter UI Transitions: 12 Production Animation Patterns
- Flutter Animations Masterclass: From AnimationController to Production-Ready Motion
🚀 Need Architecture Consulting for Your Flutter App?
We have designed and implemented Clean Architecture for 40+ client apps across fintech, e-commerce, healthcare, and logistics. If your team needs architecture guidance, code review, or a production structure for a new project, let's discuss your project. Check our Flutter development services.
Frequently Asked Questions
What is Clean Architecture in Flutter and why should I use it?
Clean Architecture in Flutter organises code into three layers — Domain, Data, and Presentation — with a strict dependency rule: inner layers never depend on outer layers. The Domain layer contains pure Dart business logic with no Flutter imports, the Data layer implements repository contracts and handles API/database access, and the Presentation layer holds widgets and state management. This separation makes code independently testable, lets you swap backends without touching business logic (we migrated a client from Firebase to Supabase with zero domain changes), and scales cleanly as features grow. The original concept comes from Robert C. Martin's Clean Architecture blog post.
Should I use folder-by-feature or folder-by-layer?
Use folder-by-feature for any app with more than 3–4 features. Each feature folder
(features/product/, features/auth/) contains its own
domain/, data/, and presentation/ subdirectories.
This keeps related code co-located, makes it easy to add or remove features, and prevents
the domain/entities/ folder from becoming a dumping ground for 40+ unrelated
files. Folder-by-layer (lib/domain/, lib/data/) works for small
apps but breaks down at scale. The
Effective Dart style
guide recommends grouping by purpose for
the same reasons.
How do I handle errors in Flutter Clean Architecture?
Use the Either type from
dartz or
fpdart to
represent success/failure in repository return types. Instead of throwing exceptions,
repositories return Either<Failure, SuccessType>. Use cases propagate
the Either to the presentation layer, where a fold() call maps
Left (failure) to error UI and Right (success) to content UI.
Define your failures as a
sealed
class hierarchy (ServerFailure,
CacheFailure, NetworkFailure) so Dart's exhaustive switch
forces you to handle every case.
What is the best DI solution for Flutter Clean Architecture?
get_it is
the most widely used DI solution for Flutter Clean Architecture. Pair it with
injectable for code
generation to reduce registration boilerplate.
Use @lazySingleton for services and data sources (one instance, created on
first access) and @injectable for use cases and BLoCs (new instance per
request). The injectable package generates the registration code from annotations, resolving
the dependency graph automatically. For simpler projects, manual get_it registration works
fine without the code generation step.
How do I test each layer in Flutter Clean Architecture?
Domain layer: unit test use cases by mocking repository interfaces with
mocktail.
Data layer: unit test repository implementations by mocking data sources; test data models'
fromJson/toJson with fixture JSON files. Presentation layer:
test BLoC state transitions with
bloc_test
by mocking use cases; widget test screens with mocked BLoCs. Integration tests: use
Flutter's integration testing framework with mocked HTTP clients. Each test
suite mocks only the adjacent layer, never two levels deep.
When should I NOT use Clean Architecture in Flutter?
Skip Clean Architecture for prototypes, hackathon projects, apps with fewer than 3 screens, or single-developer hobby projects where iteration speed matters more than maintainability. The three-layer structure adds meaningful boilerplate — entities, models, mappers, repository interfaces, repository implementations, use cases, and DI registration per feature. For small apps, a simpler pattern like MVVM or feature-first with direct repository access provides 80% of the benefit at 30% of the code. A good middle ground: use folder-by-feature with repository abstractions but skip use cases until features accumulate business rules.