Architecture

Flutter Clean Architecture: The Complete Guide to Scalable App Design

Muhammad Shakil Muhammad Shakil
Feb 25, 2026
30 min read
Flutter Clean Architecture: The Complete Guide to Scalable App Design
Back to Blog

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:

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:

  1. 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.
  2. Move business logic from widgets to a service or use case layer. If your onPressed callback fetches data, transforms it, and updates state, extract the fetch + transform into a separate class. The widget should only dispatch events and render states.
  3. Introduce DI. Set up get_it and register your repositories and services. Replace constructor-passed dependencies with getIt<T>() lookups.
  4. 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.
  5. 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.
  6. 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:

📚 Related Articles

🚀 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.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.