Member-only story
From Messy Code to Maintainable Masterpiece
Flutter Clean Architecture: 99% of Developers Get This Wrong (Complete Bloc Guide 2025)
Stop writing spaghetti code! Learn how to build scalable Flutter apps with Clean Architecture and Bloc — even if you’re a complete beginner
Before We Start — Why Do You Need Clean Architecture?
Hey friend! 🙋♂️
Let me be honest with you. When I first started with Flutter, I had no idea what Clean Architecture was. I thought, “If the code works, that’s all that matters, right?”
Fast forward 6 months later… I opened my own app and couldn’t understand ANYTHING. 😱
3000-line files, code scattered everywhere, changing one API endpoint meant modifying 10 different files. It was pure hell.
That’s why today, I’m going to share everything I learned. How to build Flutter apps with Clean Architecture and Bloc like a real pro.
What is Clean Architecture? (Understand in 5 Minutes)
Clean Architecture is a concept created by Uncle Bob (Robert C. Martin). Simply put:
“Separate your business logic completely from UI, database, and external APIs”
Why does this matter? Think about it:
- Want to switch from Firebase to Supabase? → Just change the data layer
- Want to switch from REST API to GraphQL? → Again, just the data layer
- Want to switch from Bloc to Riverpod? → Only change the presentation layer
You don’t need to touch anything else! That’s the magic of Clean Architecture. ✨
Understanding the 3 Layers (Super Easy)
Clean Architecture consists of 3 layers:
🎨 1. Presentation Layer (UI Layer)
Role: Screens and interactions that users see
What’s included:
- Widgets (screens)
- Bloc/Cubit (state management)
- UI logic
Example: When user taps login button, tell Bloc “User wants to login!”
🧠 2. Domain Layer (Business Logic Layer)
Role: Core business rules of your app
What’s included:
- Entities (data models)
- Use Cases (business logic)
- Repository Interfaces (abstractions)
Example: “To login, we need email and password, and email format must be valid”
📦 3. Data Layer (Data Layer)
Role: Actually fetch and store data
What’s included:
- Repository Implementations (actual implementations)
- Data Sources (API, Database)
- Models (JSON conversion)
Example: Actually send login request to Firebase
Project Structure (Real Example)
Now let’s look at the actual project structure. This is what I use in production:
lib/
├── core/
│ ├── error/
│ │ ├── exceptions.dart
│ │ └── failures.dart
│ ├── usecases/
│ │ └── usecase.dart
│ ├── utils/
│ │ └── constants.dart
│ └── network/
│ └── network_info.dart
│
├── features/
│ └── authentication/
│ ├── data/
│ │ ├── datasources/
│ │ │ ├── auth_remote_datasource.dart
│ │ │ └── auth_local_datasource.dart
│ │ ├── models/
│ │ │ └── user_model.dart
│ │ └── repositories/
│ │ └── auth_repository_impl.dart
│ │
│ ├── domain/
│ │ ├── entities/
│ │ │ └── user.dart
│ │ ├── repositories/
│ │ │ └── auth_repository.dart
│ │ └── usecases/
│ │ ├── login_user.dart
│ │ └── get_current_user.dart
│ │
│ └── presentation/
│ ├── bloc/
│ │ ├── auth_bloc.dart
│ │ ├── auth_event.dart
│ │ └── auth_state.dart
│ └── pages/
│ └── login_page.dart
│
├── injection_container.dart
└── main.dartStep-by-Step Implementation (Follow Along)
Step 1: Add Dependencies
Add these to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
# Bloc for state management
flutter_bloc: ^8.1.3
equatable: ^2.0.5
# Dependency Injection
get_it: ^7.6.4
# Network
http: ^1.1.0
internet_connection_checker: ^1.0.0+1
# Local Storage
shared_preferences: ^2.2.2
# Functional Programming
dartz: ^0.10.1
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.1Why do we need these packages?
flutter_bloc: The king of state management! 🤴equatable: Makes object comparison easyget_it: Dependency injection (I'll explain later)dartz: Elegant error handling with Either typeshared_preferences: Save data locally
Step 2: Create Core Files
First, let’s create files that all features will use.
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];
}
// Server error
class ServerFailure extends Failure {
const ServerFailure(super.message);
}
// Cache error
class CacheFailure extends Failure {
const CacheFailure(super.message);
}
// Network error
class NetworkFailure extends Failure {
const NetworkFailure(super.message);
}Why create Failures?
You shouldn’t pass Exceptions directly to the UI. Convert them to Failures and handle them safely. This is one of the core principles of Clean Architecture!
lib/core/error/exceptions.dart
class ServerException implements Exception {
final String message;
ServerException(this.message);
}
class CacheException implements Exception {
final String message;
CacheException(this.message);
}
class NetworkException implements Exception {
final String message;
NetworkException(this.message);
}lib/core/usecases/usecase.dart
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import '../error/failures.dart';
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
class NoParams extends Equatable {
@override
List<Object> get props => [];
}This is the base class for all UseCases. Either returns success (Right) or failure (Left). Pretty clean, right? 😎
Step 3: Create Domain Layer (Most Important!)
Domain is the innermost layer. It doesn’t depend on anything else.
lib/features/authentication/domain/entities/user.dart
import 'package:equatable/equatable.dart';
class User extends Equatable {
final String id;
final String email;
final String name;
const User({
required this.id,
required this.email,
required this.name,
});
@override
List<Object> get props => [id, email, name];
}What’s the difference between Entity and Model?
- Entity: Pure Dart object used in business logic
- Model: Data layer object with JSON conversion capabilities
lib/features/authentication/domain/repositories/auth_repository.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/user.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> login({
required String email,
required String password,
});
Future<Either<Failure, User>> getCurrentUser();
Future<Either<Failure, void>> logout();
}This is an interface. The actual implementation will be in the Data layer. This way Domain doesn’t depend on Data!
lib/features/authentication/domain/usecases/login_user.dart
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/user.dart';
import '../repositories/auth_repository.dart';
class LoginUser implements UseCase<User, LoginParams> {
final AuthRepository repository;
LoginUser(this.repository);
@override
Future<Either<Failure, User>> call(LoginParams params) async {
// Email validation (business logic!)
if (!params.email.contains('@')) {
return const Left(ServerFailure('Invalid email format'));
}
// Password validation
if (params.password.length < 6) {
return const Left(ServerFailure('Password too short'));
}
return await repository.login(
email: params.email,
password: params.password,
);
}
}
class LoginParams extends Equatable {
final String email;
final String password;
const LoginParams({
required this.email,
required this.password,
});
@override
List<Object> get props => [email, password];
}What is a UseCase?
A UseCase is one business action. Things like “login user”, “get user info”. Each UseCase should do only one thing!
lib/features/authentication/domain/usecases/get_current_user.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/user.dart';
import '../repositories/auth_repository.dart';
class GetCurrentUser implements UseCase<User, NoParams> {
final AuthRepository repository;
GetCurrentUser(this.repository);
@override
Future<Either<Failure, User>> call(NoParams params) async {
return await repository.getCurrentUser();
}
}Step 4: Create Data Layer
Now let’s create the layer that actually fetches data.
lib/features/authentication/data/models/user_model.dart
import '../../domain/entities/user.dart';
class UserModel extends User {
const UserModel({
required super.id,
required super.email,
required super.name,
});
// JSON → Model
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] ?? '',
email: json['email'] ?? '',
name: json['name'] ?? '',
);
}
// Model → JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
};
}
}Why does Model extend Entity?
Model is a special version of Entity with JSON conversion! This makes it easy to convert Data → Domain.
lib/features/authentication/data/datasources/auth_remote_datasource.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../../core/error/exceptions.dart';
import '../models/user_model.dart';
abstract class AuthRemoteDataSource {
Future<UserModel> login({
required String email,
required String password,
});
Future<UserModel> getCurrentUser();
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final http.Client client;
AuthRemoteDataSourceImpl({required this.client});
@override
Future<UserModel> login({
required String email,
required String password,
}) async {
try {
final response = await client.post(
Uri.parse('https://your-api.com/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': email,
'password': password,
}),
);
if (response.statusCode == 200) {
return UserModel.fromJson(jsonDecode(response.body));
} else {
throw ServerException('Login failed: ${response.statusCode}');
}
} catch (e) {
throw ServerException('Network error: $e');
}
}
@override
Future<UserModel> getCurrentUser() async {
// Implementation...
throw UnimplementedError();
}
}lib/features/authentication/data/datasources/auth_local_datasource.dart
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../core/error/exceptions.dart';
import '../models/user_model.dart';
abstract class AuthLocalDataSource {
Future<UserModel> getCachedUser();
Future<void> cacheUser(UserModel user);
Future<void> clearCache();
}
class AuthLocalDataSourceImpl implements AuthLocalDataSource {
final SharedPreferences sharedPreferences;
static const cachedUserKey = 'CACHED_USER';
AuthLocalDataSourceImpl({required this.sharedPreferences});
@override
Future<UserModel> getCachedUser() async {
final jsonString = sharedPreferences.getString(cachedUserKey);
if (jsonString != null) {
return UserModel.fromJson(jsonDecode(jsonString));
} else {
throw CacheException('No cached user found');
}
}
@override
Future<void> cacheUser(UserModel user) async {
await sharedPreferences.setString(
cachedUserKey,
jsonEncode(user.toJson()),
);
}
@override
Future<void> clearCache() async {
await sharedPreferences.remove(cachedUserKey);
}
}Why do we need Local DataSource?
Caching is essential for offline functionality! If you save user info locally, the app works even without network.
lib/features/authentication/data/repositories/auth_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_local_datasource.dart';
import '../datasources/auth_remote_datasource.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, User>> login({
required String email,
required String password,
}) async {
try {
// 1. Login from remote
final user = await remoteDataSource.login(
email: email,
password: password,
);
// 2. Cache locally
await localDataSource.cacheUser(user);
// 3. Success!
return Right(user);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
}
@override
Future<Either<Failure, User>> getCurrentUser() async {
try {
final user = await localDataSource.getCachedUser();
return Right(user);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> logout() async {
try {
await localDataSource.clearCache();
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
}What’s the role of Repository Implementation?
This is where we convert Exceptions to Failures! And we combine remote and local logic. This is a crucial part!
Step 5: Create Presentation Layer (Bloc)
Finally, Bloc! This manages state.
lib/features/authentication/presentation/bloc/auth_event.dart
import 'package:equatable/equatable.dart';
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object> get props => [];
}
class LoginRequested extends AuthEvent {
final String email;
final String password;
const LoginRequested({
required this.email,
required this.password,
});
@override
List<Object> get props => [email, password];
}
class LogoutRequested extends AuthEvent {}
class CheckAuthStatus extends AuthEvent {}lib/features/authentication/presentation/bloc/auth_state.dart
import 'package:equatable/equatable.dart';
import '../../domain/entities/user.dart';
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class Authenticated extends AuthState {
final User user;
const Authenticated(this.user);
@override
List<Object> get props => [user];
}
class Unauthenticated extends AuthState {}
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
@override
List<Object> get props => [message];
}lib/features/authentication/presentation/bloc/auth_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/usecases/usecase.dart';
import '../../domain/usecases/get_current_user.dart';
import '../../domain/usecases/login_user.dart';
import 'auth_event.dart';
import 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUser loginUser;
final GetCurrentUser getCurrentUser;
AuthBloc({
required this.loginUser,
required this.getCurrentUser,
}) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
on<LogoutRequested>(_onLogoutRequested);
on<CheckAuthStatus>(_onCheckAuthStatus);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
final result = await loginUser(LoginParams(
email: event.email,
password: event.password,
));
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(Authenticated(user)),
);
}
Future<void> _onLogoutRequested(
LogoutRequested event,
Emitter<AuthState> emit,
) async {
emit(Unauthenticated());
}
Future<void> _onCheckAuthStatus(
CheckAuthStatus event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
final result = await getCurrentUser(NoParams());
result.fold(
(failure) => emit(Unauthenticated()),
(user) => emit(Authenticated(user)),
);
}
}Bloc Flow:
- UI sends Event (e.g., LoginRequested)
- Bloc calls UseCase
- UseCase returns result (Either<Failure, User>)
- Bloc emits State (e.g., Authenticated)
- UI listens to State and updates screen
Perfect unidirectional data flow! 🎯
lib/features/authentication/presentation/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/auth_bloc.dart';
import '../bloc/auth_event.dart';
import '../bloc/auth_state.dart';
class LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
} else if (state is Authenticated) {
Navigator.pushReplacementNamed(context, '/home');
}
},
builder: (context, state) {
if (state is AuthLoading) {
return const Center(child: CircularProgressIndicator());
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
context.read<AuthBloc>().add(
LoginRequested(
email: _emailController.text,
password: _passwordController.text,
),
);
},
child: const Text('Login'),
),
],
),
);
},
),
);
}
}Step 6: Set Up Dependency Injection
Now we need to wire all dependencies together. Let’s do it cleanly with Get_it.
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 'features/authentication/data/datasources/auth_local_datasource.dart';
import 'features/authentication/data/datasources/auth_remote_datasource.dart';
import 'features/authentication/data/repositories/auth_repository_impl.dart';
import 'features/authentication/domain/repositories/auth_repository.dart';
import 'features/authentication/domain/usecases/get_current_user.dart';
import 'features/authentication/domain/usecases/login_user.dart';
import 'features/authentication/presentation/bloc/auth_bloc.dart';
final sl = GetIt.instance;
Future<void> init() async {
//! Features - Authentication
// Bloc
sl.registerFactory(
() => AuthBloc(
loginUser: sl(),
getCurrentUser: sl(),
),
);
// Use cases
sl.registerLazySingleton(() => LoginUser(sl()));
sl.registerLazySingleton(() => GetCurrentUser(sl()));
// Repository
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
),
);
// Data sources
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(client: sl()),
);
sl.registerLazySingleton<AuthLocalDataSource>(
() => AuthLocalDataSourceImpl(sharedPreferences: sl()),
);
//! External
final sharedPreferences = await SharedPreferences.getInstance();
sl.registerLazySingleton(() => sharedPreferences);
sl.registerLazySingleton(() => http.Client());
sl.registerLazySingleton(() => InternetConnectionChecker());
}What is Dependency Injection?
Simply put, it’s “preparing what you need in advance”. For example, if AuthBloc needs LoginUser, GetIt automatically creates and injects it!
Factory vs LazySingleton:
registerFactory: Creates new instance every time (used for Bloc)registerLazySingleton: Creates once and reuses (used for Repository, UseCase)
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'features/authentication/presentation/bloc/auth_bloc.dart';
import 'features/authentication/presentation/pages/login_page.dart';
import 'injection_container.dart' as di;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await di.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Clean Architecture Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: BlocProvider(
create: (context) => di.sl<AuthBloc>()
..add(CheckAuthStatus()),
child: const LoginPage(),
),
);
}
}🍎 iOS Setup (Critical!)
iOS requires some configuration for network requests.
1. Info.plist Configuration
Open ios/Runner/Info.plist and add this:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>Warning! For production, use HTTPS and remove this setting. This is for development only!
2. Better Approach for Production
For production, use this in ios/Runner/Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSExceptionDomains</key>
<dict>
<key>your-api.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>3. Minimum iOS Version
In ios/Podfile:
platform :ios, '12.0'Most packages require iOS 12 or higher.
4. Pod Installation
Run these commands:
cd ios
pod install
cd ..5. Xcode Settings
Open ios/Runner.xcworkspace in Xcode and:
- Set deployment target to iOS 12.0+
- Enable “Signing & Capabilities”
- Add your team for code signing
🤖 Android Setup (Critical!)
1. Internet Permission
Add to android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:usesCleartextTraffic="true"
...>usesCleartextTraffic="true" allows HTTP. For production, use HTTPS only!
2. Better Approach for Production
Create android/app/src/main/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">your-api.com</domain>
</domain-config>
</network-security-config>Then reference it in AndroidManifest.xml:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>3. Minimum SDK Version
In android/app/build.gradle:
android {
defaultConfig {
minSdkVersion 21
targetSdkVersion 33
compileSdkVersion 33
...
}
}4. Kotlin Version
In android/build.gradle:
buildscript {
ext.kotlin_version = '1.8.0'
...
}5. Gradle Version
In android/gradle/wrapper/gradle-wrapper.properties:
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip6. ProGuard (For Release Builds)
In android/app/build.gradle:
buildTypes {
release {
signingConfig signingConfigs.debug
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}Create android/app/proguard-rules.pro:
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Keep data models
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
}7. MultiDex Support (If Needed)
In android/app/build.gradle:
android {
defaultConfig {
multiDexEnabled true
}
}
dependencies {
implementation 'androidx.multidex:multidex:2.0.1'
}🧪 Writing Tests (Like a Pro!)
The biggest advantage of Clean Architecture? Easy to test!
test/features/authentication/domain/usecases/login_user_test.dart
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
late LoginUser usecase;
late MockAuthRepository mockRepository;
setUp(() {
mockRepository = MockAuthRepository();
usecase = LoginUser(mockRepository);
});
const tUser = User(
id: '1',
email: 'test@example.com',
name: 'Test User',
);
test('should return User when login is successful', () async {
// arrange
when(() => mockRepository.login(
email: any(named: 'email'),
password: any(named: 'password'),
)).thenAnswer((_) async => const Right(tUser));
// act
final result = await usecase(const LoginParams(
email: 'test@example.com',
password: 'password123',
));
// assert
expect(result, const Right(tUser));
verify(() => mockRepository.login(
email: 'test@example.com',
password: 'password123',
)).called(1);
});
test('should return Failure when email is invalid', () async {
// act
final result = await usecase(const LoginParams(
email: 'invalid-email',
password: 'password123',
));
// assert
expect(result, const Left(ServerFailure('Invalid email format')));
});
test('should return Failure when password is too short', () async {
// act
final result = await usecase(const LoginParams(
email: 'test@example.com',
password: '123',
));
// assert
expect(result, const Left(ServerFailure('Password too short')));
});
}🚀 Additional Best Practices
1. Use Feature-First Structure
Make each feature independent. It’s easy to remove or add features later!
2. Leverage Either Type
final result = await usecase(params);
result.fold(
(failure) => print('Error: ${failure.message}'),
(data) => print('Success: $data'),
);This is much cleaner than try-catch!
3. Create Constants File
lib/core/utils/constants.dart:
class ApiConstants {
static const String baseUrl = 'https://your-api.com';
static const String loginEndpoint = '/auth/login';
static const String registerEndpoint = '/auth/register';
}
class CacheConstants {
static const String cachedUser = 'CACHED_USER';
static const String authToken = 'AUTH_TOKEN';
}
class AppConstants {
static const int connectionTimeout = 30000;
static const int receiveTimeout = 30000;
}4. Use Extensions
lib/core/extensions/string_extensions.dart:
extension EmailValidator on String {
bool get isValidEmail {
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this);
}
}
extension PasswordValidator on String {
bool get isValidPassword {
return length >= 6;
}
}Usage:
if (!email.isValidEmail) {
return const Left(ServerFailure('Invalid email'));
}5. Add Logging
Add to pubspec.yaml:
dependencies:
logger: ^2.0.2Create lib/core/utils/logger.dart:
import 'package:logger/logger.dart';
final logger = Logger(
printer: PrettyPrinter(
methodCount: 0,
errorMethodCount: 5,
lineLength: 50,
colors: true,
printEmojis: true,
),
);Usage:
logger.d('Debug message');
logger.i('Info message');
logger.w('Warning message');
logger.e('Error message');6. Environment Configuration
Create different files for each environment:
lib/core/config/env_config.dart:
enum Environment { dev, staging, production }
class EnvConfig {
static Environment _env = Environment.dev;
static void setEnvironment(Environment env) {
_env = env;
}
static String get baseUrl {
switch (_env) {
case Environment.dev:
return 'https://dev-api.example.com';
case Environment.staging:
return 'https://staging-api.example.com';
case Environment.production:
return 'https://api.example.com';
}
}
static bool get isProduction => _env == Environment.production;
}In main.dart:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Set environment
EnvConfig.setEnvironment(Environment.dev);
await di.init();
runApp(const MyApp());
}🐛 Common Mistakes (I’ve Made Them All 😅)
Mistake 1: Domain Depends on Data
❌ Bad:
// domain/repositories/auth_repository.dart
import '../../data/models/user_model.dart'; // Never do this!✅ Good:
// domain/repositories/auth_repository.dart
import '../entities/user.dart'; // Use Entity only!Mistake 2: Bloc Calls Repository Directly
❌ Bad:
class AuthBloc {
final AuthRepository repository;
// Bloc calls repository directly
final result = await repository.login(...);
}✅ Good:
class AuthBloc {
final LoginUser loginUser; // Use UseCase!
final result = await loginUser(params);
}Mistake 3: Too Much Logic in Widgets
❌ Bad:
ElevatedButton(
onPressed: () {
if (email.contains('@') && password.length >= 6) {
// Business logic shouldn't be here!
}
},
)✅ Good:
ElevatedButton(
onPressed: () {
context.read<AuthBloc>().add(LoginRequested(...));
// Widget only sends events!
},
)Mistake 4: Passing Exceptions to UI
Always convert Exception → Failure in Repository!
Mistake 5: Creating God Classes
One UseCase should do one thing. Don’t create AuthUseCase that does everything!
Mistake 6: Not Using Equatable
❌ Bad:
class User {
final String id;
final String name;
}
// This won't work properly in Bloc
if (oldUser == newUser) { ... }✅ Good:
class User extends Equatable {
final String id;
final String name;
@override
List<Object> get props => [id, name];
}
// Now this works perfectly!
if (oldUser == newUser) { ... }📊 Performance Optimization Tips
1. Use Debouncing
For search functionality:
import 'dart:async';
class SearchPage extends StatefulWidget {
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
Timer? _debounce;
void onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
context.read<SearchBloc>().add(SearchQueryChanged(query));
});
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
}2. Implement Pagination
class GetPosts implements UseCase<List<Post>, PaginationParams> {
@override
Future<Either<Failure, List<Post>>> call(PaginationParams params) async {
return await repository.getPosts(
page: params.page,
limit: params.limit,
);
}
}
class PaginationParams extends Equatable {
final int page;
final int limit;
const PaginationParams({
required this.page,
this.limit = 20,
});
@override
List<Object> get props => [page, limit];
}3. Image Caching
Add to pubspec.yaml:
dependencies:
cached_network_image: ^3.3.0Usage:
CachedNetworkImage(
imageUrl: user.profileUrl,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
cacheKey: user.id,
)4. Lazy Loading with BlocBuilder
BlocBuilder<PostBloc, PostState>(
buildWhen: (previous, current) {
// Only rebuild when posts change
return previous.posts != current.posts;
},
builder: (context, state) {
return ListView.builder(...);
},
)5. Connection Checking
Create 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;
}Use in Repository:
@override
Future<Either<Failure, User>> login(...) async {
if (await networkInfo.isConnected) {
// Try remote
try {
final user = await remoteDataSource.login(...);
await localDataSource.cacheUser(user);
return Right(user);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
} else {
// Try cache
try {
final user = await localDataSource.getCachedUser();
return Right(user);
} catch (e) {
return Left(NetworkFailure('No internet connection'));
}
}
}🔄 Real-World Scenario: Complete Flow
Let’s see how everything works from start to finish:
- User taps login button
// login_page.dart
context.read<AuthBloc>().add(
LoginRequested(email: email, password: password)
);2. Bloc receives Event
// auth_bloc.dart
on<LoginRequested>(_onLoginRequested);3. Bloc calls UseCase
final result = await loginUser(LoginParams(...));4. UseCase validates business logic
// login_user.dart
if (!params.email.contains('@')) {
return const Left(ServerFailure('Invalid email'));
}5. UseCase calls Repository
return await repository.login(email: email, password: password);6. Repository calls DataSource
// auth_repository_impl.dart
final user = await remoteDataSource.login(...);
await localDataSource.cacheUser(user);7. DataSource makes API call
// auth_remote_datasource.dart
final response = await client.post(Uri.parse('...'));8. Result flows back in reverse
API → DataSource → Repository → UseCase → Bloc → UI9. Bloc emits State
emit(Authenticated(user));10. UI listens to State and updates
if (state is Authenticated) {
Navigator.pushReplacementNamed(context, '/home');
}Perfect! 😎
🎯 What’s Next?
Once you’ve mastered Clean Architecture, learn these:
- Testing: Unit, Widget, Integration tests
- CI/CD: GitHub Actions, Codemagic, Fastlane
- Analytics: Firebase Analytics integration
- Crash Reporting: Sentry, Firebase Crashlytics
- Feature Flags: Firebase Remote Config
- Multiple Environments: Dev, Staging, Production
- Code Generation: Freezed, json_serializable
- Localization: i18n, intl package
- Performance Monitoring: Firebase Performance
💡 Wrapping Up
Friend, if you’ve read this far, you’re amazing! 👏
Clean Architecture seems complex at first, but once you get used to it, there’s no going back. I guarantee it.
Remember:
- Domain must be independent
- UseCase should do one thing
- Convert Exceptions to Failures
- Write tests
- Maintain consistency
It might take time at first, but you’ll save tons of time later during maintenance. And your teammates will praise your code! 😄
🤝 If This Helped You?
- 👏 Clap it up (you can clap up to 50 times!)
- 💬 Share your experience in comments
- 🔗 Share with friends facing the same challenges
- 📌 Save for later reference
- 👤 Follow me on Medium for more content
Got questions? Drop a comment. I always respond! 💪
Happy Coding! 🚀
Coming Next: “Flutter Bloc Testing: Complete Guide for Beginners”
Follow me for:
- Flutter best practices
- Clean Architecture deep dives
- Real-world app development tips
- Code review sessions
- Performance optimization techniques
See you in the next one! ✌️
#Flutter #CleanArchitecture #Bloc #FlutterDevelopment #MobileDevelopment #DartProgramming #SoftwareArchitecture #FlutterTutorial #iOSDevelopment #AndroidDevelopment #BlocPattern #StateManagement #FlutterBloc #CodingTutorial #MobileAppDevelopment #SoftwareEngineering #FlutterTips #DartLanguage #AppArchitecture #TechTutorial #Programming2025 #FlutterBestPractices #DeveloperGuide #CleanCode #SOLID
Comments
Post a Comment