Flutter is a fantastic framework for shipping beautiful, fast apps across platforms. But as your app grows beyond a few screens, ad-hoc code organization breaks down fast. Clean architecture — originally described by Robert C. Martin — gives you a disciplined separation of concerns that makes large Flutter codebases maintainable, testable, and extensible. This guide walks through a real-world Flutter project structure using clean architecture, from folder layout to dependency injection to per-layer testing strategy.
The Three Layers
Clean architecture divides your app into three concentric layers. The dependency rule is simple and strict: outer layers depend on inner layers, never the reverse.
- Domain layer — The core. Contains Entities, Use Cases, and Repository interfaces. Zero Flutter dependencies. Pure Dart.
- Data layer — Implements Repository interfaces. Contains Models (JSON serialization), Data Sources (remote API, local DB), and Repository implementations.
- Presentation layer — Flutter UI. Contains Pages, Widgets, and state management (Bloc/Cubit). Calls Use Cases, never touches the data layer directly.
Folder Structure
I organize by feature first, then by layer inside each feature. This scales well because all code related to a feature lives together.
lib/
├── core/
│ ├── error/
│ │ ├── failures.dart # Sealed failure classes
│ │ └── exceptions.dart
│ ├── network/
│ │ └── network_info.dart # Connectivity check
│ ├── usecases/
│ │ └── usecase.dart # Base UseCase interface
│ └── di/
│ └── injection.dart # GetIt service locator setup
│
├── features/
│ └── products/
│ ├── domain/
│ │ ├── entities/
│ │ │ └── product.dart
│ │ ├── repositories/
│ │ │ └── product_repository.dart # abstract
│ │ └── usecases/
│ │ ├── get_products.dart
│ │ └── get_product_by_id.dart
│ │
│ ├── data/
│ │ ├── models/
│ │ │ └── product_model.dart # extends Product entity
│ │ ├── datasources/
│ │ │ ├── product_remote_source.dart
│ │ │ └── product_local_source.dart
│ │ └── repositories/
│ │ └── product_repository_impl.dart
│ │
│ └── presentation/
│ ├── bloc/
│ │ ├── product_bloc.dart
│ │ ├── product_event.dart
│ │ └── product_state.dart
│ ├── pages/
│ │ └── products_page.dart
│ └── widgets/
│ └── product_card.dart
│
└── main.dart
Domain Layer
The domain layer has no external dependencies — no Flutter, no Dio, no database packages. Every entity is a pure Dart class. I use the dartz package for the Either type, which forces explicit error handling at every call site.
// features/products/domain/entities/product.dart
import 'package:equatable/equatable.dart';
class Product extends Equatable {
final String id;
final String name;
final double price;
final String imageUrl;
final String categoryId;
const Product({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
required this.categoryId,
});
@override
List<Object?> get props => [id, name, price, imageUrl, categoryId];
}
// features/products/domain/repositories/product_repository.dart
import 'package:dartz/dartz.dart';
import '../entities/product.dart';
import '../../../../core/error/failures.dart';
abstract class ProductRepository {
Future<Either<Failure, List<Product>>> getProducts();
Future<Either<Failure, Product>> getProductById(String id);
}
// features/products/domain/usecases/get_products.dart
import 'package:dartz/dartz.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
class GetProducts implements UseCase<List<Product>, NoParams> {
final ProductRepository repository;
const GetProducts(this.repository);
@override
Future<Either<Failure, List<Product>>> call(NoParams params) {
return repository.getProducts();
}
}
Data Layer
Models extend entities and add JSON serialization. The repository implementation handles the offline-first pattern: try remote, catch exception, fall back to cache.
// features/products/data/models/product_model.dart
import '../../domain/entities/product.dart';
class ProductModel extends Product {
const ProductModel({
required super.id,
required super.name,
required super.price,
required super.imageUrl,
required super.categoryId,
});
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
id: json['id'] as String,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
imageUrl: json['image_url'] as String,
categoryId: json['category_id'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'price': price,
'image_url': imageUrl,
'category_id': categoryId,
};
}
// features/products/data/repositories/product_repository_impl.dart
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 '../datasources/product_remote_source.dart';
import '../datasources/product_local_source.dart';
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteSource remote;
final ProductLocalSource local;
const ProductRepositoryImpl({ required this.remote, required this.local });
@override
Future<Either<Failure, List<Product>>> getProducts() async {
try {
final products = await remote.getProducts();
await local.cacheProducts(products);
return Right(products);
} on ServerException catch (e) {
try {
final cached = await local.getCachedProducts();
return Right(cached);
} on CacheException {
return Left(ServerFailure(e.message));
}
}
}
@override
Future<Either<Failure, Product>> getProductById(String id) async {
try {
final product = await remote.getProductById(id);
return Right(product);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
}
}
Presentation Layer with Bloc
The Cubit (simplified Bloc) receives the Use Case via constructor injection. It never imports from the data layer. State is sealed — either loading, loaded, or error — which makes UI code predictable.
// features/products/presentation/bloc/product_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_products.dart';
import '../../../../core/usecases/usecase.dart';
import 'product_state.dart';
class ProductCubit extends Cubit<ProductState> {
final GetProducts getProducts;
ProductCubit({ required this.getProducts }) : super(ProductInitial());
Future<void> loadProducts() async {
emit(ProductLoading());
final result = await getProducts(const NoParams());
result.fold(
(failure) => emit(ProductError(failure.message)),
(products) => emit(ProductLoaded(products)),
);
}
}
// features/products/presentation/pages/products_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/product_cubit.dart';
import '../bloc/product_state.dart';
import '../widgets/product_card.dart';
class ProductsPage extends StatefulWidget {
const ProductsPage({super.key});
@override
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
@override
void initState() {
super.initState();
context.read<ProductCubit>().loadProducts();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: BlocBuilder<ProductCubit, ProductState>(
builder: (context, state) {
if (state is ProductLoading) return const Center(child: CircularProgressIndicator());
if (state is ProductError) return Center(child: Text(state.message));
if (state is ProductLoaded) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
itemCount: state.products.length,
itemBuilder: (ctx, i) => ProductCard(product: state.products[i]),
);
}
return const SizedBox.shrink();
},
),
);
}
}
Dependency Injection with GetIt
I use get_it as a service locator. All wiring happens in one place so the rest of the codebase never instantiates dependencies directly.
// core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../../features/products/data/datasources/product_remote_source.dart';
import '../../features/products/data/datasources/product_local_source.dart';
import '../../features/products/data/repositories/product_repository_impl.dart';
import '../../features/products/domain/repositories/product_repository.dart';
import '../../features/products/domain/usecases/get_products.dart';
import '../../features/products/presentation/bloc/product_cubit.dart';
final sl = GetIt.instance;
Future<void> initDependencies() async {
// External
sl.registerLazySingleton(() => Dio());
// Data sources
sl.registerLazySingleton<ProductRemoteSource>(
() => ProductRemoteSourceImpl(dio: sl()),
);
sl.registerLazySingleton<ProductLocalSource>(
() => ProductLocalSourceImpl(),
);
// Repository
sl.registerLazySingleton<ProductRepository>(
() => ProductRepositoryImpl(remote: sl(), local: sl()),
);
// Use cases
sl.registerLazySingleton(() => GetProducts(sl()));
// Bloc / Cubit
sl.registerFactory(() => ProductCubit(getProducts: sl()));
}
Testing per Layer
Clean architecture's biggest benefit is testability. Each layer can be tested in isolation.
- Domain: Unit test use cases by mocking the repository interface. No Flutter, no IO.
- Data: Test repository implementations by mocking remote/local sources. Test models with fixture JSON.
- Presentation: Use
bloc_testto test Cubit state transitions. Useflutter_testwithBlocProviderfor widget tests.
// test/features/products/domain/get_products_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:dartz/dartz.dart';
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
late GetProducts usecase;
late MockProductRepository mockRepo;
setUp(() {
mockRepo = MockProductRepository();
usecase = GetProducts(mockRepo);
});
test('should return list of products from repository', () async {
final products = [const Product(id:'1', name:'Keffiyeh', price:99, imageUrl:'', categoryId:'c1')];
when(mockRepo.getProducts()).thenAnswer((_) async => Right(products));
final result = await usecase(const NoParams());
expect(result, Right(products));
verify(mockRepo.getProducts());
verifyNoMoreInteractions(mockRepo);
});
}
Common Mistakes to Avoid
- Importing
flutter/material.dartin the domain layer — your entities should be pure Dart. - Putting business logic in the Cubit — use cases exist for a reason.
- Using a single giant repository instead of one per feature.
- Skipping
Equatableon entities — Bloc comparisons will break without it. - Registering everything as
registerSingletonin GetIt — Cubits should beregisterFactoryso they get a fresh instance per page.
Final Thoughts
Clean architecture feels like extra ceremony on a small project, but it pays for itself the moment a second developer joins or you need to swap a data source. The strict dependency rule — domain knows nothing about data or presentation — is the key insight. Once it clicks, you will never go back to dumping everything in a single file.
The complete boilerplate for this architecture is available in my GitHub repositories if you want to use it as a starting point for your next Flutter project.