VOOZH about

URL: https://dev.to/skntin/clean-architecture-with-bloc-in-flutter-a-practical-guide-5b4d

⇱ Clean Architecture with BLoC in Flutter: A Practical Guide - DEV Community


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.