
As your Flutter apps grow in complexity, structuring code in a maintainable way becomes critical. Combining Clean Architecture with BLoC (Business Logic Component) is a powerful approach that helps you scale, test, and separate concerns in your project.
In this comprehensive guide, you’ll learn how to implement Flutter clean architecture with BLoC pattern, layer by layer—with complete, production-ready examples that you can adapt for your own projects.
What Is Clean Architecture?
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), separates your application into concentric layers with strict dependency rules. The key principle is that dependencies should only point inward—outer layers depend on inner layers, never the reverse.
In Flutter clean architecture with BLoC, we typically organize into these layers:
- Presentation Layer – UI widgets and BLoC state management
- Domain Layer – Business logic, entities, use cases, and repository interfaces
- Data Layer – Repository implementations, data sources, and models
The domain layer is the core of your application and has no dependencies on Flutter or external packages. This makes it highly testable and portable.
Why Use Clean Architecture with BLoC?
- Separation of concerns – UI, business logic, and data access stay independent
- Testability – Each layer can be tested in isolation with mocks
- Scalability – Easy to add features without modifying existing code
- Team collaboration – Different developers can work on different layers
- Predictable state – BLoC provides a clear pattern for state changes
- Maintainability – Changes in one layer don’t cascade to others
Complete Project Structure
lib/
├── core/
│ ├── error/
│ │ ├── exceptions.dart
│ │ └── failures.dart
│ ├── network/
│ │ └── network_info.dart
│ ├── usecases/
│ │ └── usecase.dart
│ └── utils/
│ └── input_converter.dart
├── features/
│ └── products/
│ ├── data/
│ │ ├── datasources/
│ │ │ ├── product_local_datasource.dart
│ │ │ └── product_remote_datasource.dart
│ │ ├── models/
│ │ │ └── product_model.dart
│ │ └── repositories/
│ │ └── product_repository_impl.dart
│ ├── domain/
│ │ ├── entities/
│ │ │ └── product.dart
│ │ ├── repositories/
│ │ │ └── product_repository.dart
│ │ └── usecases/
│ │ ├── get_products.dart
│ │ ├── get_product_details.dart
│ │ └── search_products.dart
│ └── presentation/
│ ├── bloc/
│ │ ├── product_bloc.dart
│ │ ├── product_event.dart
│ │ └── product_state.dart
│ ├── pages/
│ │ ├── product_list_page.dart
│ │ └── product_detail_page.dart
│ └── widgets/
│ ├── product_card.dart
│ └── product_search_bar.dart
├── injection_container.dart
└── main.dart
Core Layer: Shared Utilities
Error Handling
// lib/core/error/exceptions.dart
class ServerException implements Exception {
final String message;
final int? statusCode;
ServerException({required this.message, this.statusCode});
}
class CacheException implements Exception {
final String message;
CacheException({required this.message});
}
class NetworkException implements Exception {}
// lib/core/error/failures.dart
import 'package:equatable/equatable.dart';
abstract class Failure extends Equatable {
final String message;
const Failure(this.message);
@override
List<Object?> get props => [message];
}
class ServerFailure extends Failure {
const ServerFailure(super.message);
}
class CacheFailure extends Failure {
const CacheFailure(super.message);
}
class NetworkFailure extends Failure {
const NetworkFailure() : super('No internet connection');
}
Base Use Case
// lib/core/usecases/usecase.dart
import 'package:dartz/dartz.dart';
import '../error/failures.dart';
// Use case that takes parameters
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
// Use case with no parameters
abstract class UseCaseNoParams<Type> {
Future<Either<Failure, Type>> call();
}
// For use cases that don't need parameters
class NoParams extends Equatable {
@override
List<Object?> get props => [];
}
Network Info
// lib/core/network/network_info.dart
import 'package:internet_connection_checker/internet_connection_checker.dart';
abstract class NetworkInfo {
Future<bool> get isConnected;
}
class NetworkInfoImpl implements NetworkInfo {
final InternetConnectionChecker connectionChecker;
NetworkInfoImpl(this.connectionChecker);
@override
Future<bool> get isConnected => connectionChecker.hasConnection;
}
Domain Layer: Pure Business Logic
Entity
// lib/features/products/domain/entities/product.dart
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 double rating;
final int reviewCount;
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.rating,
required this.reviewCount,
required this.inStock,
});
@override
List<Object?> get props => [
id, name, description, price, imageUrl,
category, rating, reviewCount, inStock,
];
}
Repository Interface
// lib/features/products/domain/repositories/product_repository.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/product.dart';
abstract class ProductRepository {
/// Get all products with optional pagination
Future<Either<Failure, List<Product>>> getProducts({
int page = 1,
int limit = 20,
});
/// Get a single product by ID
Future<Either<Failure, Product>> getProductById(String id);
/// Search products by query
Future<Either<Failure, List<Product>>> searchProducts(String query);
/// Get products by category
Future<Either<Failure, List<Product>>> getProductsByCategory(String category);
}
Use Cases
// lib/features/products/domain/usecases/get_products.dart
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
class GetProducts implements UseCase<List<Product>, GetProductsParams> {
final ProductRepository repository;
GetProducts(this.repository);
@override
Future<Either<Failure, List<Product>>> call(GetProductsParams params) {
return repository.getProducts(
page: params.page,
limit: params.limit,
);
}
}
class GetProductsParams extends Equatable {
final int page;
final int limit;
const GetProductsParams({this.page = 1, this.limit = 20});
@override
List<Object?> get props => [page, limit];
}
// lib/features/products/domain/usecases/get_product_details.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
class GetProductDetails implements UseCase<Product, String> {
final ProductRepository repository;
GetProductDetails(this.repository);
@override
Future<Either<Failure, Product>> call(String productId) {
return repository.getProductById(productId);
}
}
// lib/features/products/domain/usecases/search_products.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
class SearchProducts implements UseCase<List<Product>, String> {
final ProductRepository repository;
SearchProducts(this.repository);
@override
Future<Either<Failure, List<Product>>> call(String query) {
if (query.length < 2) {
return Future.value(
const Left(ServerFailure('Search query must be at least 2 characters')),
);
}
return repository.searchProducts(query);
}
}
Data Layer: External World
Model
// lib/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.description,
required super.price,
required super.imageUrl,
required super.category,
required super.rating,
required super.reviewCount,
required super.inStock,
});
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
price: (json['price'] as num).toDouble(),
imageUrl: json['image_url'] as String,
category: json['category'] as String,
rating: (json['rating'] as num).toDouble(),
reviewCount: json['review_count'] as int,
inStock: json['in_stock'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'price': price,
'image_url': imageUrl,
'category': category,
'rating': rating,
'review_count': reviewCount,
'in_stock': inStock,
};
}
factory ProductModel.fromEntity(Product product) {
return ProductModel(
id: product.id,
name: product.name,
description: product.description,
price: product.price,
imageUrl: product.imageUrl,
category: product.category,
rating: product.rating,
reviewCount: product.reviewCount,
inStock: product.inStock,
);
}
}
Data Sources
// lib/features/products/data/datasources/product_remote_datasource.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../../core/error/exceptions.dart';
import '../models/product_model.dart';
abstract class ProductRemoteDataSource {
Future<List<ProductModel>> getProducts({int page = 1, int limit = 20});
Future<ProductModel> getProductById(String id);
Future<List<ProductModel>> searchProducts(String query);
}
class ProductRemoteDataSourceImpl implements ProductRemoteDataSource {
final http.Client client;
final String baseUrl;
ProductRemoteDataSourceImpl({
required this.client,
required this.baseUrl,
});
@override
Future<List<ProductModel>> getProducts({int page = 1, int limit = 20}) async {
final response = await client.get(
Uri.parse('$baseUrl/products?page=$page&limit=$limit'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body)['data'];
return jsonList.map((json) => ProductModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Failed to fetch products',
statusCode: response.statusCode,
);
}
}
@override
Future<ProductModel> getProductById(String id) async {
final response = await client.get(
Uri.parse('$baseUrl/products/$id'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return ProductModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw ServerException(message: 'Product not found', statusCode: 404);
} else {
throw ServerException(
message: 'Failed to fetch product',
statusCode: response.statusCode,
);
}
}
@override
Future<List<ProductModel>> searchProducts(String query) async {
final response = await client.get(
Uri.parse('$baseUrl/products/search?q=${Uri.encodeComponent(query)}'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body)['data'];
return jsonList.map((json) => ProductModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Search failed',
statusCode: response.statusCode,
);
}
}
}
// lib/features/products/data/datasources/product_local_datasource.dart
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../core/error/exceptions.dart';
import '../models/product_model.dart';
abstract class ProductLocalDataSource {
Future<List<ProductModel>> getCachedProducts();
Future<void> cacheProducts(List<ProductModel> products);
Future<ProductModel?> getCachedProduct(String id);
Future<void> cacheProduct(ProductModel product);
}
class ProductLocalDataSourceImpl implements ProductLocalDataSource {
final SharedPreferences sharedPreferences;
static const cachedProductsKey = 'CACHED_PRODUCTS';
static const cachedProductPrefix = 'CACHED_PRODUCT_';
ProductLocalDataSourceImpl({required this.sharedPreferences});
@override
Future<List<ProductModel>> getCachedProducts() async {
final jsonString = sharedPreferences.getString(cachedProductsKey);
if (jsonString != null) {
final List<dynamic> jsonList = json.decode(jsonString);
return jsonList.map((json) => ProductModel.fromJson(json)).toList();
} else {
throw CacheException(message: 'No cached products found');
}
}
@override
Future<void> cacheProducts(List<ProductModel> products) async {
final jsonList = products.map((p) => p.toJson()).toList();
await sharedPreferences.setString(
cachedProductsKey,
json.encode(jsonList),
);
}
@override
Future<ProductModel?> getCachedProduct(String id) async {
final jsonString = sharedPreferences.getString('$cachedProductPrefix$id');
if (jsonString != null) {
return ProductModel.fromJson(json.decode(jsonString));
}
return null;
}
@override
Future<void> cacheProduct(ProductModel product) async {
await sharedPreferences.setString(
'$cachedProductPrefix${product.id}',
json.encode(product.toJson()),
);
}
}
Repository Implementation
// lib/features/products/data/repositories/product_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
import '../datasources/product_local_datasource.dart';
import '../datasources/product_remote_datasource.dart';
import '../models/product_model.dart';
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteDataSource remoteDataSource;
final ProductLocalDataSource localDataSource;
final NetworkInfo networkInfo;
ProductRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, List<Product>>> getProducts({
int page = 1,
int limit = 20,
}) async {
if (await networkInfo.isConnected) {
try {
final products = await remoteDataSource.getProducts(
page: page,
limit: limit,
);
// Cache first page results
if (page == 1) {
await localDataSource.cacheProducts(products);
}
return Right(products);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
} else {
try {
// Only return cached data for first page when offline
if (page == 1) {
final cachedProducts = await localDataSource.getCachedProducts();
return Right(cachedProducts);
}
return const Left(NetworkFailure());
} on CacheException {
return const Left(NetworkFailure());
}
}
}
@override
Future<Either<Failure, Product>> getProductById(String id) async {
if (await networkInfo.isConnected) {
try {
final product = await remoteDataSource.getProductById(id);
await localDataSource.cacheProduct(product);
return Right(product);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
} else {
final cachedProduct = await localDataSource.getCachedProduct(id);
if (cachedProduct != null) {
return Right(cachedProduct);
}
return const Left(NetworkFailure());
}
}
@override
Future<Either<Failure, List<Product>>> searchProducts(String query) async {
if (await networkInfo.isConnected) {
try {
final products = await remoteDataSource.searchProducts(query);
return Right(products);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
} else {
return const Left(NetworkFailure());
}
}
@override
Future<Either<Failure, List<Product>>> getProductsByCategory(
String category,
) async {
// Implementation similar to getProducts with category filter
return getProducts().then((result) {
return result.fold(
(failure) => Left(failure),
(products) => Right(
products.where((p) => p.category == category).toList(),
),
);
});
}
}
Presentation Layer: BLoC and UI
BLoC with Freezed
// lib/features/products/presentation/bloc/product_event.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product_event.freezed.dart';
@freezed
class ProductEvent with _$ProductEvent {
const factory ProductEvent.loadProducts({@Default(1) int page}) = LoadProducts;
const factory ProductEvent.loadProductDetails(String productId) = LoadProductDetails;
const factory ProductEvent.searchProducts(String query) = SearchProducts;
const factory ProductEvent.clearSearch() = ClearSearch;
const factory ProductEvent.refreshProducts() = RefreshProducts;
}
// lib/features/products/presentation/bloc/product_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/product.dart';
part 'product_state.freezed.dart';
@freezed
class ProductState with _$ProductState {
const factory ProductState({
@Default([]) List<Product> products,
@Default(false) bool isLoading,
@Default(false) bool isLoadingMore,
@Default(false) bool hasReachedMax,
@Default(1) int currentPage,
String? errorMessage,
Product? selectedProduct,
@Default([]) List<Product> searchResults,
@Default(false) bool isSearching,
String? searchQuery,
}) = _ProductState;
}
// lib/features/products/presentation/bloc/product_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_products.dart';
import '../../domain/usecases/get_product_details.dart';
import '../../domain/usecases/search_products.dart';
import 'product_event.dart';
import 'product_state.dart';
class ProductBloc extends Bloc<ProductEvent, ProductState> {
final GetProducts getProducts;
final GetProductDetails getProductDetails;
final SearchProducts searchProducts;
ProductBloc({
required this.getProducts,
required this.getProductDetails,
required this.searchProducts,
}) : super(const ProductState()) {
on<LoadProducts>(_onLoadProducts);
on<LoadProductDetails>(_onLoadProductDetails);
on<SearchProducts>(_onSearchProducts);
on<ClearSearch>(_onClearSearch);
on<RefreshProducts>(_onRefreshProducts);
}
Future<void> _onLoadProducts(
LoadProducts event,
Emitter<ProductState> emit,
) async {
if (state.hasReachedMax && event.page > 1) return;
final isFirstPage = event.page == 1;
emit(state.copyWith(
isLoading: isFirstPage,
isLoadingMore: !isFirstPage,
errorMessage: null,
));
final result = await getProducts(
GetProductsParams(page: event.page, limit: 20),
);
result.fold(
(failure) => emit(state.copyWith(
isLoading: false,
isLoadingMore: false,
errorMessage: failure.message,
)),
(products) {
final allProducts = isFirstPage
? products
: [...state.products, ...products];
emit(state.copyWith(
isLoading: false,
isLoadingMore: false,
products: allProducts,
currentPage: event.page,
hasReachedMax: products.length < 20,
));
},
);
}
Future<void> _onLoadProductDetails(
LoadProductDetails event,
Emitter<ProductState> emit,
) async {
emit(state.copyWith(isLoading: true, selectedProduct: null));
final result = await getProductDetails(event.productId);
result.fold(
(failure) => emit(state.copyWith(
isLoading: false,
errorMessage: failure.message,
)),
(product) => emit(state.copyWith(
isLoading: false,
selectedProduct: product,
)),
);
}
Future<void> _onSearchProducts(
SearchProducts event,
Emitter<ProductState> emit,
) async {
emit(state.copyWith(
isSearching: true,
searchQuery: event.query,
));
final result = await searchProducts(event.query);
result.fold(
(failure) => emit(state.copyWith(
isSearching: false,
errorMessage: failure.message,
)),
(products) => emit(state.copyWith(
isSearching: false,
searchResults: products,
)),
);
}
void _onClearSearch(
ClearSearch event,
Emitter<ProductState> emit,
) {
emit(state.copyWith(
searchResults: [],
searchQuery: null,
));
}
Future<void> _onRefreshProducts(
RefreshProducts event,
Emitter<ProductState> emit,
) async {
emit(state.copyWith(
hasReachedMax: false,
currentPage: 1,
));
add(const ProductEvent.loadProducts(page: 1));
}
}
UI Widgets
// lib/features/products/presentation/pages/product_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../injection_container.dart';
import '../bloc/product_bloc.dart';
import '../bloc/product_event.dart';
import '../bloc/product_state.dart';
import '../widgets/product_card.dart';
import '../widgets/product_search_bar.dart';
class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<ProductBloc>()..add(const ProductEvent.loadProducts()),
child: const ProductListView(),
);
}
}
class ProductListView extends StatefulWidget {
const ProductListView({super.key});
@override
State<ProductListView> createState() => _ProductListViewState();
}
class _ProductListViewState extends State<ProductListView> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_isBottom) {
final state = context.read<ProductBloc>().state;
if (!state.isLoadingMore && !state.hasReachedMax) {
context.read<ProductBloc>().add(
ProductEvent.loadProducts(page: state.currentPage + 1),
);
}
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.all(8),
child: ProductSearchBar(
onSearch: (query) {
context.read<ProductBloc>().add(
ProductEvent.searchProducts(query),
);
},
onClear: () {
context.read<ProductBloc>().add(
const ProductEvent.clearSearch(),
);
},
),
),
),
),
body: BlocBuilder<ProductBloc, ProductState>(
builder: (context, state) {
if (state.isLoading && state.products.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.errorMessage != null && state.products.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(state.errorMessage!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<ProductBloc>().add(
const ProductEvent.refreshProducts(),
);
},
child: const Text('Retry'),
),
],
),
);
}
final products = state.searchQuery != null
? state.searchResults
: state.products;
return RefreshIndicator(
onRefresh: () async {
context.read<ProductBloc>().add(
const ProductEvent.refreshProducts(),
);
},
child: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: products.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= products.length) {
return const Center(child: CircularProgressIndicator());
}
return ProductCard(
product: products[index],
onTap: () {
Navigator.pushNamed(
context,
'/product/${products[index].id}',
);
},
);
},
),
);
},
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
Dependency Injection with GetIt
// lib/injection_container.dart
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import 'package:internet_connection_checker/internet_connection_checker.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/network/network_info.dart';
import 'features/products/data/datasources/product_local_datasource.dart';
import 'features/products/data/datasources/product_remote_datasource.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/domain/usecases/get_product_details.dart';
import 'features/products/domain/usecases/search_products.dart';
import 'features/products/presentation/bloc/product_bloc.dart';
final sl = GetIt.instance;
Future<void> init() async {
// BLoC
sl.registerFactory(
() => ProductBloc(
getProducts: sl(),
getProductDetails: sl(),
searchProducts: sl(),
),
);
// Use cases
sl.registerLazySingleton(() => GetProducts(sl()));
sl.registerLazySingleton(() => GetProductDetails(sl()));
sl.registerLazySingleton(() => SearchProducts(sl()));
// Repository
sl.registerLazySingleton<ProductRepository>(
() => ProductRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
networkInfo: sl(),
),
);
// Data sources
sl.registerLazySingleton<ProductRemoteDataSource>(
() => ProductRemoteDataSourceImpl(
client: sl(),
baseUrl: 'https://api.example.com',
),
);
sl.registerLazySingleton<ProductLocalDataSource>(
() => ProductLocalDataSourceImpl(sharedPreferences: sl()),
);
// Core
sl.registerLazySingleton<NetworkInfo>(
() => NetworkInfoImpl(sl()),
);
// External
final sharedPreferences = await SharedPreferences.getInstance();
sl.registerLazySingleton(() => sharedPreferences);
sl.registerLazySingleton(() => http.Client());
sl.registerLazySingleton(() => InternetConnectionChecker());
}
Testing Each Layer
// test/features/products/domain/usecases/get_products_test.dart
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.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: 'Test Product',
description: 'Description',
price: 99.99,
imageUrl: 'https://example.com/image.png',
category: 'Electronics',
rating: 4.5,
reviewCount: 100,
inStock: true,
),
];
test('should get products from repository', () async {
// Arrange
when(() => mockRepository.getProducts(page: 1, limit: 20))
.thenAnswer((_) async => Right(tProducts));
// Act
final result = await usecase(const GetProductsParams());
// Assert
expect(result, Right(tProducts));
verify(() => mockRepository.getProducts(page: 1, limit: 20)).called(1);
});
}
Common Mistakes to Avoid
- Domain layer with Flutter dependencies: Keep the domain layer pure Dart with no Flutter imports
- Skipping the repository pattern: Always use interfaces in the domain layer
- BLoC in widgets: Create BLoC at the page level, not inside widgets
- Large BLoCs: Split features into multiple BLoCs when they get complex
- Ignoring error handling: Use Either type (from dartz) for proper error handling
- Not testing use cases: Use cases contain business logic and should be thoroughly tested
- Mixing layers: Never import data layer classes in domain layer
Conclusion
Using Flutter clean architecture with BLoC gives your app a solid, scalable foundation. The separation of concerns makes your code easier to test, maintain, and extend. While it requires more initial setup than a simple architecture, the benefits become clear as your application grows.
Key takeaways:
- Domain layer is the core - keep it pure and independent
- Use interfaces for repositories to enable testing and flexibility
- BLoC handles state while use cases handle business logic
- Dependency injection with GetIt keeps everything loosely coupled
- Test each layer independently with appropriate mocks
For more on dependency injection, see our guide on Dependency Injection in Flutter with GetIt. For state management alternatives, check out Top Flutter State Management Packages. For official documentation, see the BLoC Library documentation.
1 Comment