flutter
/

BLoC Clean Architecture: Building Scalable Flutter Apps

Last Sync: Today

On this page

12
0%
Advanced
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutterAdvanced

BLoC Clean Architecture: Building Scalable Flutter Apps

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

LayerPurposeContains
PresentationUI and state managementPages, widgets, Blocs/Cubits
DomainBusiness logic & rulesUse cases, entities, repository interfaces
DataData manipulation & sourcesRepository 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

TEXTRead-only
1
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).

DARTRead-only
1
class User {
  final String id;
  final String name;
  final String email;

  const User({required this.id, required this.name, required this.email});
}
DARTRead-only
1
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();
}
DARTRead-only
1
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.

DARTRead-only
1
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,
  };
}
DARTRead-only
1
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();
    }
  }
}
DARTRead-only
1
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.

DARTRead-only
1
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:myapp/features/auth/domain/usecases/login.dart';

// Events
abstract class AuthEvent {}
class LoginEvent extends AuthEvent {
  final String email;
  final String password;
  LoginEvent(this.email, this.password);
}

// States
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);
}

// Bloc
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.

DARTRead-only
1
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 {
  // External
  sl.registerLazySingleton(() => Dio());

  // Data sources
  sl.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(sl()),
  );

  // Repositories
  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(sl()),
  );

  // Use cases
  sl.registerLazySingleton(() => Login(sl()));

  // Blocs
  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.

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

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

Test Your Knowledge

Q1
of 3

What is the role of a use case in Clean Architecture?

A
To handle UI events
B
To encapsulate a single business action
C
To fetch data from the network
D
To manage dependency injection
Q2
of 3

Which layer defines repository interfaces?

A
Presentation
B
Domain
C
Data
D
Core
Q3
of 3

What is the main benefit of using `Either<Failure, T>` in the domain layer?

A
It makes the code run faster
B
It makes error handling explicit and testable
C
It replaces all exceptions
D
It is required by BLoC

Frequently Asked Questions

Do I need to use use cases for every simple operation?

For very simple operations (like incrementing a counter), a use case may be overkill. You can put such logic directly in the BLoC. But as soon as the logic involves multiple steps, data validation, or external dependencies, a use case becomes valuable for testability and reusability.

How do I handle dependencies between use cases?

Use cases can depend on each other, but it's better to keep them independent. If you need to combine multiple use cases, consider creating a higher‑level use case that uses the lower‑level ones via constructor injection.

Should I use freezed with equatable for entities?

It's optional but recommended. Freezed generates immutable classes with copyWith, toString, and equality, which is very helpful for entities and states. However, entities in the domain layer can remain simple classes; freezed adds extra boilerplate but improves safety.

How do I handle validation in the domain layer?

You can put validation logic inside use cases or create separate value objects that encapsulate validation. For example, a Email class that validates format and returns an Either on creation.

Can I use this architecture with Cubit instead of Bloc?

Yes, the architecture is independent of whether you use Bloc or Cubit. You would still have use cases and repositories, and the Cubit would call them directly (like a method) rather than dispatching events.

Previous

bloc navigation events

Next

bloc repository pattern

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.