Building large-scale Flutter applications requires a solid architecture that separates concerns, is testable, and can evolve over time. Clean Architecture combined with BLoC gives you a powerful, maintainable structure. This guide will walk you through integrating BLoC with Clean Architecture, including layers, use cases, repositories, and dependency injection.
What is Clean Architecture?
Clean Architecture, popularised by Robert C. Martin, is a software design philosophy that separates the concerns of an application into layers, with the innermost layer being the business rules. Dependencies point inward – the UI depends on the business logic, which depends on the data layer, but not the other way around. This makes the core logic independent of external frameworks (like Flutter) and easily testable.
Layers in Clean Architecture
| Layer | Purpose | Contains |
|---|
| Presentation | UI and state management | Pages, widgets, Blocs/Cubits |
| Domain | Business logic & rules | Use cases, entities, repository interfaces |
| Data | Data manipulation & sources | Repository implementations, data sources (API, local DB), models |
Dependency Rule: The presentation layer depends on the domain layer, which depends on the data layer through interfaces. The data layer implements the interfaces defined in the domain. This inversion of control allows you to swap data sources (e.g., a mock API for testing) without affecting the domain or presentation.
Folder Structure
lib/
├── main.dart
├── app.dart
├── core/
│ ├── constants/
│ ├── utils/
│ ├── errors/
│ └── network/
├── features/
│ ├── auth/
│ │ ├── presentation/
│ │ │ ├── pages/
│ │ │ ├── widgets/
│ │ │ └── bloc/ # Bloc for this feature
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ ├── repositories/
│ │ │ └── usecases/
│ │ └── data/
│ │ ├── repositories/
│ │ ├── datasources/
│ │ └── models/
│ └── product/
│ ├── presentation/
│ ├── domain/
│ └── data/
└── di/ # Dependency injection (get_it)
Implementing the Domain Layer
The domain layer is pure Dart – no Flutter, no external dependencies. It contains entities (plain Dart classes), repository interfaces (abstract classes), and use cases (each encapsulating a single business action).
class User {
final String id;
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
}
import 'package:dartz/dartz.dart';
import 'package:myapp/core/errors/failures.dart';
import '../entities/user.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, void>> logout();
}
import 'package:dartz/dartz.dart';
import 'package:myapp/core/errors/failures.dart';
import '../entities/user.dart';
import '../repositories/auth_repository.dart';
class Login {
final AuthRepository repository;
Login(this.repository);
Future<Either<Failure, User>> call(LoginParams params) async {
return await repository.login(params.email, params.password);
}
}
class LoginParams {
final String email;
final String password;
LoginParams({required this.email, required this.password});
}
Implementing the Data Layer
The data layer implements the repository interfaces defined in the domain. It uses data sources (remote and local) to fetch/store data, and models that may map to/from entities.
import '../../domain/entities/user.dart';
class UserModel extends User {
UserModel({required super.id, required super.name, required super.email});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
};
}
abstract class AuthRemoteDataSource {
Future<UserModel> login(String email, String password);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final Dio dio;
AuthRemoteDataSourceImpl(this.dio);
@override
Future<UserModel> login(String email, String password) async {
final response = await dio.post('/login', data: {'email': email, 'password': password});
if (response.statusCode == 200) {
return UserModel.fromJson(response.data);
} else {
throw ServerException();
}
}
}
import 'package:dartz/dartz.dart';
import 'package:myapp/core/errors/failures.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_datasource.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
AuthRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, User>> login(String email, String password) async {
try {
final userModel = await remoteDataSource.login(email, password);
return Right(userModel);
} on ServerException {
return Left(ServerFailure());
} catch (e) {
return Left(UnknownFailure());
}
}
}
Implementing the Presentation Layer with BLoC
The presentation layer uses BLoC to interact with the domain layer. The bloc receives use cases via constructor injection and emits states based on the results. The UI listens to states using BlocBuilder/BlocConsumer.
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:myapp/features/auth/domain/usecases/login.dart';
abstract class AuthEvent {}
class LoginEvent extends AuthEvent {
final String email;
final String password;
LoginEvent(this.email, this.password);
}
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState {
final User user;
AuthSuccess(this.user);
}
class AuthFailure extends AuthState {
final String message;
AuthFailure(this.message);
}
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final Login loginUseCase;
AuthBloc(this.loginUseCase) : super(AuthInitial()) {
on<LoginEvent>(_onLogin);
}
Future<void> _onLogin(LoginEvent event, Emitter<AuthState> emit) async {
emit(AuthLoading());
final result = await loginUseCase(LoginParams(
email: event.email,
password: event.password,
));
result.fold(
(failure) => emit(AuthFailure(failure.message)),
(user) => emit(AuthSuccess(user)),
);
}
}
Dependency Injection with get_it
Use get_it to register all dependencies (data sources, repositories, use cases, blocs). This keeps the code decoupled and testable.
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../features/auth/data/datasources/auth_remote_datasource.dart';
import '../features/auth/data/repositories/auth_repository_impl.dart';
import '../features/auth/domain/repositories/auth_repository.dart';
import '../features/auth/domain/usecases/login.dart';
import '../features/auth/presentation/bloc/auth_bloc.dart';
final sl = GetIt.instance;
Future<void> init() async {
sl.registerLazySingleton(() => Dio());
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(sl()),
);
sl.registerLazySingleton(() => Login(sl()));
sl.registerFactory(() => AuthBloc(sl()));
}
Handling Errors (Failures)
Define a hierarchy of failures (e.g., ServerFailure, NetworkFailure, CacheFailure) in the domain layer. Use Either<Failure, T> from dartz to represent success/failure and avoid exceptions in domain.
abstract class Failure {
final String message;
Failure(this.message);
}
class ServerFailure extends Failure {
ServerFailure() : super('Server error');
}
class NetworkFailure extends Failure {
NetworkFailure() : super('No internet connection');
}
class UnknownFailure extends Failure {
UnknownFailure() : super('Something went wrong');
}
Testing
Clean Architecture makes testing easy: you can test each layer in isolation. Use mock repository for use case tests, mock use cases for bloc tests, etc.
test('login use case returns user on success', () async {
final mockRepo = MockAuthRepository();
when(mockRepo.login(any, any)).thenAnswer((_) async => Right(testUser));
final login = Login(mockRepo);
final result = await login(LoginParams(email: 'test@test.com', password: 'pass'));
expect(result, Right(testUser));
});
Best Practices
- Keep domain pure – No Flutter, no Dio, no database. Only Dart.
- Use
Either<Failure, T> – Avoid exceptions in domain; make failures explicit. - One use case per business action – Keeps them small and testable.
- Inject dependencies through constructors – Makes testing and swapping implementations easy.
- Use
get_it for DI – Centralizes dependency registration. - Feature‑based folder structure – Each feature contains its own layers.
- Write tests for each layer – Unit tests for domain, integration tests for data, widget tests for presentation.
Common Mistakes
- ❌ Putting business logic in BLoC – Makes it hard to test and reuse.
✅ Move business logic to use cases.
- ❌ Letting domain depend on data layer – Violates dependency rule.
✅ Data layer implements domain interfaces.
- ❌ Using exceptions for error handling in domain – Makes flow unpredictable.
✅ Use
Either<Failure, T> or Result types. - ❌ Creating huge use cases – Violates single responsibility.
✅ Split into smaller use cases.
- ❌ Directly using data sources in BLoC – Bypasses use cases.
✅ Always go through use cases.
Conclusion
Combining BLoC with Clean Architecture gives you a robust, testable, and scalable foundation for Flutter apps. The separation of concerns ensures that your core business logic is independent of UI and external frameworks, making your app easier to maintain and evolve. Start by structuring your features with presentation, domain, and data layers, and gradually introduce use cases and Either error handling as your app grows.