DEV Community

Cover image for Clean Architecture with BLoC in Flutter: A Practical Guide
Shashi Kant
Shashi Kant

Posted on

Clean Architecture with BLoC in Flutter: A Practical Guide

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
└──────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

Call await init() before runApp() in main.dart.

Provide the BLoC using BlocProvider:

BlocProvider(
  create: (_) => sl<VehicleBloc>(),
  child: VehicleDetailPage(vehicleId: 'VH-001'),
)
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

The Dependency Flow (Visualized)

main.dart
   └── injection_container.dart (GetIt)
          │
          ├── VehicleBloc
          │      └── GetVehicleDetails (UseCase)
          │             └── VehicleRepository (abstract)
          │                    └── VehicleRepositoryImpl
          │                           ├── VehicleRemoteDataSourceImpl
          │                           └── VehicleLocalDataSourceImpl
          │
          └── http.Client
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
    ],
  );
}
Enter fullscreen mode Exit fullscreen mode

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)