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

15 min read5 days ago

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

Step-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.1

Why do we need these packages?

  • flutter_bloc: The king of state management! 🤴
  • equatable: Makes object comparison easy
  • get_it: Dependency injection (I'll explain later)
  • dartz: Elegant error handling with Either type
  • shared_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:

  1. UI sends Event (e.g., LoginRequested)
  2. Bloc calls UseCase
  3. UseCase returns result (Either<Failure, User>)
  4. Bloc emits State (e.g., Authenticated)
  5. 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.zip

6. 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.2

Create 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.0

Usage:

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:

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

APIDataSourceRepositoryUseCaseBlocUI

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

Responses (1)

Comments