Architecture
January 20, 2025 (1y ago)
6 min read

Clean Architecture & BLoC in Production Flutter Applications

A pragmatic guide to structuring large-scale Flutter apps using Clean Architecture, BLoC state management, dependency injection with get_it, and unit testing.

#Flutter#BLoC#Clean Architecture#Dart#Testing
Key Engineering Takeaways
  • Separating Domain, Data, and Presentation layers enforces pure business rules isolated from third-party SDKs.
  • The BLoC pattern guarantees predictable, immutable state transitions driven by explicit Events.
  • Repository interfaces defined in the Domain layer invert dependencies and unlock 100% test coverage.
  • Dependency injection via get_it streamlines service locators across feature modules.

Introduction

As mobile applications expand from prototypes into enterprise-grade products with dozens of feature modules, code maintainability hinges on architectural discipline. Without a clear separation of concerns, Flutter codebases often devolve into "Fat Widgets"—where network requests, JSON serialization, database persistence, and UI logic coexist within a single stateful widget.

By applying Uncle Bob's Clean Architecture paired with the BLoC (Business Logic Component) pattern, we construct systems that are testable, decoupled, and straightforward to maintain across teams.


1. The Three Layers of Clean Architecture

Clean Architecture separates code into concentric layers, with dependencies pointing strictly inward toward the core Domain:

  1. Domain Layer (Core Business Rules):

    • Entities: Pure business objects with no Flutter or third-party dependencies.
    • Use Cases: Discrete single-responsibility business workflows (e.g. AuthenticateUser, FetchRideHistory).
    • Repository Contracts: Abstract interfaces declaring data operations without knowing how or where data is retrieved.
  2. Data Layer (Data Retrieval & Persistence):

    • Data Sources: Remote REST/GraphQL clients, Firebase SDKs, or local SQLite/Hive databases.
    • Models: DTOs extending domain entities with JSON serialization logic.
    • Repository Implementations: Implementations satisfying Domain repository interfaces by coordinating data sources.
  3. Presentation Layer (UI & State):

    • BLoCs / Cubits: Event-driven state machines converting UI user actions into immutable UI States.
    • Pages & Widgets: Pure declarative views reacting to state streams without direct API knowledge.

2. Implementing a Domain UseCase

Use cases represent discrete business tasks. In Dart, we invoke them via callable classes (call method) returning an Either<Failure, T> result type:

abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}
 
class GetActiveRideUseCase implements UseCase<RideEntity, String> {
  final RideRepository repository;
 
  GetActiveRideUseCase(this.repository);
 
  @override
  Future<Either<Failure, RideEntity>> call(String rideId) async {
    if (rideId.isEmpty) {
      return Left(InvalidInputFailure('Ride ID cannot be empty'));
    }
    return await repository.getRideById(rideId);
  }
}

3. Predictable State with BLoC

The Presentation layer consumes use cases via BLoC. Notice how clean and testable the event handler becomes:

class RideBloc extends Bloc<RideEvent, RideState> {
  final GetActiveRideUseCase getActiveRide;
 
  RideBloc({required this.getActiveRide}) : super(RideInitial()) {
    on<LoadRideRequested>(_onLoadRideRequested);
  }
 
  Future<void> _onLoadRideRequested(
    LoadRideRequested event,
    Emitter<RideState> emit,
  ) async {
    emit(RideLoading());
    final result = await getActiveRide(event.rideId);
    result.fold(
      (failure) => emit(RideError(message: failure.errorMessage)),
      (ride) => emit(RideLoaded(ride: ride)),
    );
  }
}

4. Key Architectural Takeaways

Implementing this pattern in production apps like Safari Yatri and Sangapu demonstrated clear advantages:

  • 100% Mockable Unit Tests: Because domain use cases only depend on abstract interfaces, mocking data sources takes seconds with mocktail or mockito.
  • Interchangeable Backends: Swapping a mock REST API for Firebase or GraphQL requires editing only the Data layer—leaving Domain logic and UI completely untouched.
  • Onboarding Velocity: New engineers immediately understand where code belongs and how events flow through the application.
Nishan Pradhan
Nishan Pradhan· Author

Cross-platform Flutter mobile developer and tech enthusiast specializing in Clean Architecture, BLoC, and production Android & iOS applications.

All articles