Clean Architecture with BLoC in Flutter: A Practical Guide
After working across multiple Flutter products — from fleet management apps to OCR pipelines — one thing becomes clear fast: the way you structure your code matters more than the features you ship. Bad architecture slows you down exponentially. Clean Architecture with BLoC is the combination I keep reaching for on production Flutter apps.
This isn't a theoretical post. Every pattern here is drawn from real app structure decisions.
Why Clean Architecture?
Flutter makes it dangerously easy to write everything in one widget file. It works — until it doesn't.
Clean Architecture enforces a boundary between:
- What your app does (business logic)
- How it does it (data sources, APIs, databases)
- How it shows it (UI)
The payoff: you can swap your REST API for GraphQL, your local DB for Hive, or your UI framework entirely — without touching your core business rules.
The Three Layers
┌──────────────────────────────────┐
│ Presentation Layer │ ← Widgets, BLoC/Cubit
├──────────────────────────────────┤
│ Domain Layer │ ← Entities, Use Cases, Repository Contracts
├──────────────────────────────────┤
│ Data Layer │ ← Repository Impl, Data Sources, Models
└──────────────────────────────────┘
Dependency Rule: Arrows point inward only. Domain knows nothing about Data or Presentation.
Folder Structure
lib/
├── core/
│ ├── error/
│ │ ├── exceptions.dart
│ │ └── failures.dart
│ ├── usecases/
│ │ └── usecase.dart # Abstract base UseCase
│ └── utils/
│ └── input_converter.dart
│
├── features/
│ └── vehicle/
│ ├── data/
│ │ ├── datasources/
│ │ │ ├── vehicle_local_datasource.dart
│ │ │ └── vehicle_remote_datasource.dart
│ │ ├── models/
│ │ │ └── vehicle_model.dart
│ │ └── repositories/
│ │ └── vehicle_repository_impl.dart
│ │
│ ├── domain/
│ │ ├── entities/
│ │ │ └── vehicle.dart
│ │ ├── repositories/
│ │ │ └── vehicle_repository.dart # Abstract
│ │ └── usecases/
│ │ ├── get_vehicle_details.dart
│ │ └── update_vehicle_status.dart
│ │
│ └── presentation/
│ ├── bloc/
│ │ ├── vehicle_bloc.dart
│ │ ├── vehicle_event.dart
│ │ └── vehicle_state.dart
│ ├── pages/
│ │ └── vehicle_detail_page.dart
│ └── widgets/
│ └── vehicle_card.dart
│
└── injection_container.dart # GetIt DI setup
This feature-first layout scales well. Each feature is a self-contained vertical slice.
Layer 1: Domain
This is the heart of your app. No Flutter imports here — this layer is pure Dart.
Entity
// features/vehicle/domain/entities/vehicle.dart
class Vehicle {
final String id;
final String plateNumber;
final String ownerName;
final VehicleStatus status;
const Vehicle({
required this.id,
required this.plateNumber,
required this.ownerName,
required this.status,
});
}
enum VehicleStatus { active, inactive, underService }
Repository Contract
// features/vehicle/domain/repositories/vehicle_repository.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/vehicle.dart';
abstract class VehicleRepository {
Future<Either<Failure, Vehicle>> getVehicleDetails(String vehicleId);
Future<Either<Failure, List<Vehicle>>> getFleetVehicles();
}
Either<Failure, T> from dartz makes error handling explicit and typed — no more catching exceptions across layers.
Use Case
// features/vehicle/domain/usecases/get_vehicle_details.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/vehicle.dart';
import '../repositories/vehicle_repository.dart';
class GetVehicleDetails implements UseCase<Vehicle, String> {
final VehicleRepository repository;
GetVehicleDetails(this.repository);
@override
Future<Either<Failure, Vehicle>> call(String vehicleId) {
return repository.getVehicleDetails(vehicleId);
}
}
Base UseCase
// core/usecases/usecase.dart
import 'package:dartz/dartz.dart';
import '../error/failures.dart';
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
Layer 2: Data
This is where implementation details live — API calls, local DB, models.
Model (extends Entity)
// features/vehicle/data/models/vehicle_model.dart
import '../../domain/entities/vehicle.dart';
class VehicleModel extends Vehicle {
const VehicleModel({
required super.id,
required super.plateNumber,
required super.ownerName,
required super.status,
});
factory VehicleModel.fromJson(Map<String, dynamic> json) {
return VehicleModel(
id: json['id'] as String,
plateNumber: json['plate_number'] as String,
ownerName: json['owner_name'] as String,
status: VehicleStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => VehicleStatus.inactive,
),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'plate_number': plateNumber,
'owner_name': ownerName,
'status': status.name,
};
}
}
Remote Data Source
// features/vehicle/data/datasources/vehicle_remote_datasource.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../../core/error/exceptions.dart';
import '../models/vehicle_model.dart';
abstract class VehicleRemoteDataSource {
Future<VehicleModel> getVehicleDetails(String vehicleId);
}
class VehicleRemoteDataSourceImpl implements VehicleRemoteDataSource {
final http.Client client;
VehicleRemoteDataSourceImpl({required this.client});
@override
Future<VehicleModel> getVehicleDetails(String vehicleId) async {
final response = await client.get(
Uri.parse('https://api.example.com/vehicles/$vehicleId'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return VehicleModel.fromJson(json.decode(response.body));
} else {
throw ServerException();
}
}
}
Repository Implementation
// features/vehicle/data/repositories/vehicle_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/vehicle.dart';
import '../../domain/repositories/vehicle_repository.dart';
import '../datasources/vehicle_remote_datasource.dart';
import '../datasources/vehicle_local_datasource.dart';
class VehicleRepositoryImpl implements VehicleRepository {
final VehicleRemoteDataSource remoteDataSource;
final VehicleLocalDataSource localDataSource;
VehicleRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, Vehicle>> getVehicleDetails(String vehicleId) async {
try {
final vehicle = await remoteDataSource.getVehicleDetails(vehicleId);
await localDataSource.cacheVehicle(vehicle); // cache after fetch
return Right(vehicle);
} on ServerException {
try {
final cached = await localDataSource.getCachedVehicle(vehicleId);
return Right(cached);
} on CacheException {
return Left(CacheFailure());
}
}
}
@override
Future<Either<Failure, List<Vehicle>>> getFleetVehicles() async {
try {
final vehicles = await remoteDataSource.getFleetVehicles();
return Right(vehicles);
} on ServerException {
return Left(ServerFailure());
}
}
}
The repository is where the offline-first strategy lives — try network, fall back to cache. Neither the domain nor the UI needs to know this.
Layer 3: Presentation — BLoC
Events
// features/vehicle/presentation/bloc/vehicle_event.dart
part of 'vehicle_bloc.dart';
abstract class VehicleEvent extends Equatable {
const VehicleEvent();
@override
List<Object> get props => [];
}
class GetVehicleDetailsEvent extends VehicleEvent {
final String vehicleId;
const GetVehicleDetailsEvent(this.vehicleId);
@override
List<Object> get props => [vehicleId];
}
class RefreshFleetEvent extends VehicleEvent {
const RefreshFleetEvent();
}
States
// features/vehicle/presentation/bloc/vehicle_state.dart
part of 'vehicle_bloc.dart';
abstract class VehicleState extends Equatable {
const VehicleState();
@override
List<Object?> get props => [];
}
class VehicleInitial extends VehicleState {}
class VehicleLoading extends VehicleState {}
class VehicleLoaded extends VehicleState {
final Vehicle vehicle;
const VehicleLoaded(this.vehicle);
@override
List<Object?> get props => [vehicle];
}
class VehicleError extends VehicleState {
final String message;
const VehicleError(this.message);
@override
List<Object?> get props => [message];
}
BLoC
// features/vehicle/presentation/bloc/vehicle_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import '../../domain/entities/vehicle.dart';
import '../../domain/usecases/get_vehicle_details.dart';
part 'vehicle_event.dart';
part 'vehicle_state.dart';
const String serverFailureMessage = 'Server Failure';
const String cacheFailureMessage = 'Cache Failure';
class VehicleBloc extends Bloc<VehicleEvent, VehicleState> {
final GetVehicleDetails getVehicleDetails;
VehicleBloc({required this.getVehicleDetails}) : super(VehicleInitial()) {
on<GetVehicleDetailsEvent>(_onGetVehicleDetails);
}
Future<void> _onGetVehicleDetails(
GetVehicleDetailsEvent event,
Emitter<VehicleState> emit,
) async {
emit(VehicleLoading());
final result = await getVehicleDetails(event.vehicleId);
result.fold(
(failure) => emit(VehicleError(_mapFailureToMessage(failure))),
(vehicle) => emit(VehicleLoaded(vehicle)),
);
}
String _mapFailureToMessage(Failure failure) {
switch (failure.runtimeType) {
case ServerFailure:
return serverFailureMessage;
case CacheFailure:
return cacheFailureMessage;
default:
return 'Unexpected error';
}
}
}
UI Page
// features/vehicle/presentation/pages/vehicle_detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/vehicle_bloc.dart';
import '../widgets/vehicle_card.dart';
class VehicleDetailPage extends StatelessWidget {
final String vehicleId;
const VehicleDetailPage({super.key, required this.vehicleId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Vehicle Details')),
body: BlocConsumer<VehicleBloc, VehicleState>(
listener: (context, state) {
if (state is VehicleError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
if (state is VehicleLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is VehicleLoaded) {
return VehicleCard(vehicle: state.vehicle);
}
return const Center(child: Text('Press load to fetch vehicle'));
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<VehicleBloc>().add(GetVehicleDetailsEvent(vehicleId));
},
child: const Icon(Icons.refresh),
),
);
}
}
Dependency Injection with GetIt
Wire everything together in one place:
// injection_container.dart
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import 'features/vehicle/data/datasources/vehicle_remote_datasource.dart';
import 'features/vehicle/data/datasources/vehicle_local_datasource.dart';
import 'features/vehicle/data/repositories/vehicle_repository_impl.dart';
import 'features/vehicle/domain/repositories/vehicle_repository.dart';
import 'features/vehicle/domain/usecases/get_vehicle_details.dart';
import 'features/vehicle/presentation/bloc/vehicle_bloc.dart';
final sl = GetIt.instance;
Future<void> init() async {
// BLoC — factory: new instance per registration
sl.registerFactory(() => VehicleBloc(getVehicleDetails: sl()));
// Use Cases
sl.registerLazySingleton(() => GetVehicleDetails(sl()));
// Repository
sl.registerLazySingleton<VehicleRepository>(
() => VehicleRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
),
);
// Data Sources
sl.registerLazySingleton<VehicleRemoteDataSource>(
() => VehicleRemoteDataSourceImpl(client: sl()),
);
sl.registerLazySingleton<VehicleLocalDataSource>(
() => VehicleLocalDataSourceImpl(),
);
// External
sl.registerLazySingleton(() => http.Client());
}
Call await init() before runApp() in main.dart.
Provide the BLoC using BlocProvider:
BlocProvider(
create: (_) => sl<VehicleBloc>(),
child: VehicleDetailPage(vehicleId: 'VH-001'),
)
Failures & Exceptions
Keep these two concepts separate — exceptions are implementation details, failures are domain concepts.
// core/error/exceptions.dart
class ServerException implements Exception {}
class CacheException implements Exception {}
// core/error/failures.dart
import 'package:equatable/equatable.dart';
abstract class Failure extends Equatable {
@override
List<Object> get props => [];
}
class ServerFailure extends Failure {}
class CacheFailure extends Failure {}
The Dependency Flow (Visualized)
main.dart
└── injection_container.dart (GetIt)
│
├── VehicleBloc
│ └── GetVehicleDetails (UseCase)
│ └── VehicleRepository (abstract)
│ └── VehicleRepositoryImpl
│ ├── VehicleRemoteDataSourceImpl
│ └── VehicleLocalDataSourceImpl
│
└── http.Client
Domain sits in the middle — it defines the contracts, and both data and presentation depend on it. Neither depends on the other.
Key Packages
dependencies:
flutter_bloc: ^8.1.5
equatable: ^2.0.5
dartz: ^0.10.1
get_it: ^7.7.0
http: ^1.2.1
dev_dependencies:
bloc_test: ^9.1.7
mocktail: ^1.0.4
Testing the BLoC
Clean Architecture makes testing trivial — mock the use case, test the BLoC in isolation:
// test/features/vehicle/presentation/bloc/vehicle_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:dartz/dartz.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
class MockGetVehicleDetails extends Mock implements GetVehicleDetails {}
void main() {
late VehicleBloc bloc;
late MockGetVehicleDetails mockGetVehicleDetails;
setUp(() {
mockGetVehicleDetails = MockGetVehicleDetails();
bloc = VehicleBloc(getVehicleDetails: mockGetVehicleDetails);
});
const testVehicle = Vehicle(
id: 'VH-001',
plateNumber: 'DL01AB1234',
ownerName: 'Ravi Kumar',
status: VehicleStatus.active,
);
blocTest<VehicleBloc, VehicleState>(
'emits [Loading, Loaded] when vehicle fetch succeeds',
build: () {
when(() => mockGetVehicleDetails('VH-001'))
.thenAnswer((_) async => const Right(testVehicle));
return bloc;
},
act: (bloc) => bloc.add(const GetVehicleDetailsEvent('VH-001')),
expect: () => [
VehicleLoading(),
const VehicleLoaded(testVehicle),
],
);
blocTest<VehicleBloc, VehicleState>(
'emits [Loading, Error] when server fails',
build: () {
when(() => mockGetVehicleDetails('VH-001'))
.thenAnswer((_) async => Left(ServerFailure()));
return bloc;
},
act: (bloc) => bloc.add(const GetVehicleDetailsEvent('VH-001')),
expect: () => [
VehicleLoading(),
const VehicleError(serverFailureMessage),
],
);
}
When to Use Cubit vs BLoC
| Scenario | Use |
|---|---|
| Simple toggle, counter, form state | Cubit |
| Multiple event types driving the same state | BLoC |
| Complex event pipelines, debouncing | BLoC |
| Search with transformers | BLoC |
Cubit is BLoC without events — less boilerplate, fine for simpler state machines.
Common Pitfalls
1. Skipping the UseCase layer for "simple" features
That simple feature grows. Write the use case.
2. Putting business logic in the repository
Repositories fetch and cache. Decisions belong in use cases.
3. Importing dart:io or package:flutter in domain
If it slips in, your architecture has a leak. Domain is pure Dart.
4. Using a single BLoC for multiple features
One BLoC per feature. Shared state should go through a shared use case, not a god-BLoC.
5. Not handling Either properly
Always fold your results. Don't unwrap with getOrElse and swallow failures.
Wrapping Up
Clean Architecture with BLoC gives you:
- Testability — every layer is independently testable
- Scalability — add a feature without touching existing ones
- Replaceability — swap Hive for Drift, REST for gRPC, zero domain changes
- Clarity — a new dev can find any piece of logic by knowing which layer to look in
The upfront cost is real. The folder structure feels ceremonious for a hello-world app. But on a real product with multiple developers, multiple data sources, and multiple app variants — it pays back every single day.
Got questions about adapting this to your specific app structure? Drop them in the comments.
Top comments (0)