Flutter Clean Architecture Guide Get Ready With This Series Medium
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: 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 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. - 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.
People Also Asked
- Flutter Clean Architecture Guide | get ready with this series ... - Medium
- Flutter Clean Architecture Example - GitHub
- Clean Architecture in Flutter - A Complete Guide With Code Examples ...
- Flutter Clean Architecture: The Complete Guide to Scalable App Design
- Flutter Clean Architecture: Build Scalable Apps Step-by-Step
- Clean Architecture in Flutter: Complete Guide to Building ... - Medium
- Flutter Clean Architecture 101 (Beginner's Guide)
Flutter Clean Architecture Guide | get ready with this series ... - Medium?
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 ...
Flutter Clean Architecture Example - GitHub?
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 bi...
Clean Architecture in Flutter - A Complete Guide With Code Examples ...?
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 ...
Flutter Clean Architecture: The Complete Guide to Scalable App Design?
ð 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 Sec...
Flutter Clean Architecture: Build Scalable Apps Step-by-Step?
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 Clea...