Skip to main content
Stripe SystemsStripe Systems
Mobile Development📅 January 6, 2026· 12 min read

CLEAN Architecture in Flutter — BLoC vs Riverpod for State Management

✍️
Stripe Systems

Flutter gives you a rendering engine and a widget tree. It does not give you an architecture. That gap is where most projects accumulate the technical debt that slows them down six months after launch. CLEAN Architecture addresses this by enforcing separation of concerns through layered boundaries, and the choice of state management library — BLoC or Riverpod — determines how those boundaries feel in practice.

This post walks through CLEAN Architecture as it applies to Flutter, then makes a concrete comparison between BLoC and Riverpod so you can choose based on your team's constraints rather than hype cycles.

CLEAN Architecture Layers in Flutter

CLEAN Architecture, as originally described by Robert C. Martin, organizes code into concentric layers where dependencies point inward. In Flutter, this translates into three primary layers: Domain, Data, and Presentation.

Domain Layer

The domain layer is the innermost layer. It contains business logic and has zero dependencies on Flutter, third-party packages, or any outer layer. This is non-negotiable — if your domain layer imports package:flutter/material.dart, you have a structural violation.

The domain layer consists of three components:

  • Entities — Plain Dart classes representing core business objects. An Invoice entity might hold an amount, dueDate, and status. These are not database models or JSON-serializable DTOs. They represent the business's vocabulary.
  • Use Cases — Single-responsibility classes that encapsulate one business operation. A GetOverdueInvoices use case takes a repository interface and returns a filtered list. Each use case has a call() method (or execute(), by convention) accepting input parameters and returning a result, often wrapped in an Either type from the dartz or fpdart package to handle success and failure without exceptions.
  • Repository Interfaces — Abstract classes that define what data operations the domain needs, without specifying how they are fulfilled. The domain layer says "I need a method that returns a list of invoices." It does not care whether that data comes from Firestore, a REST API, or a local SQLite database.

A typical use case class looks like this:

class GetOverdueInvoices {
  final InvoiceRepository repository;

  GetOverdueInvoices(this.repository);

  Future<Either<Failure, List<Invoice>>> call(NoParams params) async {
    final result = await repository.getAllInvoices();
    return result.map(
      (invoices) => invoices.where((i) => i.isOverdue).toList(),
    );
  }
}

The InvoiceRepository here is an abstract class defined in the domain layer. Its concrete implementation lives in the data layer.

Data Layer

The data layer implements the repository interfaces defined by the domain. It handles all I/O: network requests, database queries, cache reads, file system access. This layer knows about serialization formats, API endpoints, and storage engines. The domain layer does not.

Key components:

  • Repository Implementations — Concrete classes that implement the abstract repositories. An InvoiceRepositoryImpl might coordinate between a remote data source and a local cache, deciding which to query based on connectivity.
  • Data Sources — Classes responsible for a single I/O channel. A RemoteInvoiceDataSource makes HTTP calls. A LocalInvoiceDataSource reads from Hive or SQLite. Repositories compose these data sources.
  • DTOs / Models — Data Transfer Objects that mirror the shape of external data. An InvoiceModel knows how to deserialize from JSON via fromJson() and serialize back via toJson(). It also contains a toEntity() method (or a separate mapper class) to convert into the domain's Invoice entity.

The separation between models and entities matters. When your API changes its response format, you update InvoiceModel.fromJson(). The Invoice entity and every use case that depends on it remain untouched.

Presentation Layer

The presentation layer contains widgets, pages, state management logic, and UI-specific formatting. It depends on the domain layer (to invoke use cases) but never directly on the data layer. Widgets call use cases through a state management solution, receive results, and render accordingly.

This is where BLoC and Riverpod each take a different approach to the same problem: how does a widget trigger a business operation, and how does it react to the result?

The Dependency Rule

The single most important constraint in CLEAN Architecture: inner layers must not depend on outer layers. The domain defines interfaces. The data layer implements them. The presentation layer consumes them. Dependencies always point inward.

In practice, this means you need dependency injection. Something must wire the concrete InvoiceRepositoryImpl to the abstract InvoiceRepository that the GetOverdueInvoices use case expects. Both BLoC and Riverpod handle this, but differently:

  • With BLoC, you typically use get_it (a service locator) combined with injectable for code generation. You register repository implementations and use cases in a setup function, then inject them into BLoCs via constructor parameters.
  • With Riverpod, dependency injection is built into the provider graph. You declare a provider that returns InvoiceRepositoryImpl, and any provider or notifier that needs InvoiceRepository reads it from the provider tree. No separate service locator required.

BLoC Pattern in Detail

BLoC (Business Logic Component) enforces a strict unidirectional data flow: Events → BLoC → States. The widget dispatches an event. The BLoC processes it and emits a new state. The widget rebuilds in response to the state change.

Under the hood, BLoC is stream-based. Each BLoC extends Bloc<Event, State>, where Event and State are sealed class hierarchies (or, in older codebases, abstract classes with concrete subtypes). You define an on<EventType>() handler for each event, which calls emit() to push new states.

class InvoiceBloc extends Bloc<InvoiceEvent, InvoiceState> {
  final GetOverdueInvoices getOverdueInvoices;

  InvoiceBloc(this.getOverdueInvoices) : super(InvoiceInitial()) {
    on<LoadOverdueInvoices>((event, emit) async {
      emit(InvoiceLoading());
      final result = await getOverdueInvoices(NoParams());
      result.fold(
        (failure) => emit(InvoiceError(failure.message)),
        (invoices) => emit(InvoiceLoaded(invoices)),
      );
    });
  }
}

BLoC Advantages

  • Explicit event tracking. Every state change is triggered by a named event. This creates an auditable trail — you can log every event that passes through a BLoC using BlocObserver. For apps with complex state machines (multi-step forms, payment flows, real-time collaboration), this traceability is valuable during debugging and production monitoring.
  • Mature testing story. The bloc_test package provides blocTest(), a function that lets you declare an initial state, fire events, and assert on the exact sequence of emitted states. Testing a BLoC requires no widget tree, no build context, and no UI scaffolding.
  • Enforced separation. Because events and states are distinct class hierarchies, developers cannot easily take shortcuts. The architecture resists ad-hoc mutations.
  • Tooling. The BlocObserver class lets you intercept every event, transition, and error across all BLoCs in the application from a single place.

BLoC Disadvantages

  • Boilerplate. A single feature requires: an event base class, one or more event subclasses, a state base class, multiple state subclasses, and the BLoC class itself. For a simple CRUD screen, this can mean five to seven files before you write any logic.
  • Learning curve. Developers unfamiliar with reactive streams and sealed class hierarchies need ramp-up time. Junior developers in particular tend to struggle with understanding when to create new events versus reusing existing ones.
  • Overhead for simple state. If your state is a single boolean (e.g., "is this toggle on?"), BLoC's event-state machinery is overkill. Cubit (a simplified BLoC without events) partially addresses this, but you still carry the state class hierarchy.

Riverpod Pattern in Detail

Riverpod takes a different approach: instead of streams and events, it uses a provider graph where each piece of state is a provider that other providers or widgets can read, watch, or listen to. Riverpod 2.0+ introduced Notifier, AsyncNotifier, and code generation via riverpod_generator, which is now the recommended API.

Provider Types

  • Provider — Returns a synchronous, read-only value. Use for computed values or object instances (e.g., a repository).
  • StateNotifierProvider (legacy) / NotifierProvider (2.0+) — Exposes a mutable state object managed by a Notifier class. The notifier contains methods that update state, and widgets rebuild when the state changes.
  • FutureProvider / AsyncNotifierProvider — Handles asynchronous operations. Returns an AsyncValue<T> that widgets can pattern-match on (data, loading, error) without manual state class definitions.
  • StreamProvider — Wraps a stream, converting each emission into an AsyncValue<T>.

Code Generation

With riverpod_generator, you annotate a function or class with @riverpod and run build_runner. The generator produces the provider declaration, eliminating manual provider type selection:

@riverpod
class InvoiceNotifier extends _$InvoiceNotifier {
  @override
  FutureOr<List<Invoice>> build() async {
    final repository = ref.read(invoiceRepositoryProvider);
    final result = await GetOverdueInvoices(repository)(NoParams());
    return result.fold(
      (failure) => throw failure,
      (invoices) => invoices,
    );
  }

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => build());
  }
}

The generated code handles provider scoping, disposal, and type safety. You reference it in widgets via ref.watch(invoiceNotifierProvider).

Riverpod Advantages

  • Less boilerplate. No event classes. No state class hierarchies. An AsyncNotifier replaces the event, state, and BLoC class trio with a single class. AsyncValue provides loading, error, and data states out of the box.
  • Compile-time safety. Riverpod catches provider dependency errors at compile time. If you misspell a provider name or create a circular dependency, the analyzer flags it. BLoC's service locator approach (get_it) only catches wiring errors at runtime.
  • No BuildContext dependency. Providers can read other providers via ref without needing a widget's BuildContext. This makes it straightforward to access state in non-widget code — interceptors, background tasks, or initialization logic.
  • Granular rebuilds. ref.watch() with .select() lets you rebuild only when a specific field changes, reducing unnecessary widget rebuilds without manual buildWhen overrides.

Riverpod Disadvantages

  • Code generation. The recommended API relies on build_runner, which adds a build step. On large projects, code generation can take several seconds, and developers must remember to run it (or keep a watch process active). This is a workflow friction point, not a technical limitation.
  • Paradigm shift. Riverpod's provider graph model is conceptually different from traditional dependency injection containers. Teams experienced with service locators or InheritedWidget patterns need to re-learn how state flows.
  • Younger ecosystem. While Riverpod is stable and widely used, BLoC has been in the Flutter ecosystem longer and has a larger body of community examples, tutorials, and Stack Overflow answers.

Comparison

CriteriaBLoCRiverpod
BoilerplateHigh — event classes, state classes, BLoC class per featureLow — notifier class with built-in async states
Learning curveSteep — requires understanding streams, events, sealed classesModerate — provider graph model is unfamiliar but less ceremony
TestabilityExcellent — bloc_test provides structured test helpersExcellent — ProviderContainer allows isolated testing without widgets
Debugging toolsBlocObserver — global event/transition loggingProviderObserver — global provider change logging
DI approachExternal (get_it + injectable)Built-in (provider graph)
Complex state machinesStrong fit — explicit events model transitions naturallyWorkable but less structured — no enforced event taxonomy
Simple CRUD appsVerbose for the value deliveredGood fit — minimal ceremony for standard async operations
Team sizeBetter for large teams — the rigid structure prevents divergent patternsBetter for small-to-mid teams — flexibility speeds up development

When to Choose Which

Choose BLoC when:

  • Your application has complex, multi-step state transitions — payment flows, real-time document editing, multi-screen wizards — where an explicit event log matters for debugging and auditing.
  • Your team is large (10+ developers on the Flutter codebase) and you need a rigid structure that prevents ad-hoc state mutations. BLoC's enforced event/state separation acts as architectural guardrails.
  • You are integrating with existing backend event-sourcing patterns and want conceptual alignment between frontend and backend state models.
  • Your team already has BLoC experience. Switching frameworks mid-project introduces risk that rarely justifies the boilerplate savings.

Choose Riverpod when:

  • Your application is primarily CRUD — fetching, displaying, creating, updating, and deleting resources — where AsyncNotifier and AsyncValue handle the common loading/error/data states with minimal code.
  • Your team is small to mid-sized and values development velocity. Less boilerplate means faster feature delivery and fewer files to review in pull requests.
  • You want compile-time dependency checking rather than runtime service locator errors.
  • You need to access state outside the widget tree (e.g., in HTTP interceptors, background isolates, or initialization sequences) without threading BuildContext through your call stack.

Folder Structure

Both approaches share the same domain and data layers. The difference is in the presentation layer.

BLoC Folder Structure

lib/
├── core/
│   ├── error/
│   │   └── failures.dart
│   └── usecases/
│       └── usecase.dart
├── features/
│   └── invoices/
│       ├── domain/
│       │   ├── entities/
│       │   │   └── invoice.dart
│       │   ├── repositories/
│       │   │   └── invoice_repository.dart
│       │   └── usecases/
│       │       └── get_overdue_invoices.dart
│       ├── data/
│       │   ├── models/
│       │   │   └── invoice_model.dart
│       │   ├── datasources/
│       │   │   ├── invoice_remote_datasource.dart
│       │   │   └── invoice_local_datasource.dart
│       │   └── repositories/
│       │       └── invoice_repository_impl.dart
│       └── presentation/
│           ├── bloc/
│           │   ├── invoice_bloc.dart
│           │   ├── invoice_event.dart
│           │   └── invoice_state.dart
│           ├── pages/
│           │   └── invoice_list_page.dart
│           └── widgets/
│               └── invoice_card.dart
└── injection_container.dart

Note the injection_container.dart at the root — this is where get_it registers all dependencies. The bloc/ folder under presentation contains three files per feature: events, states, and the BLoC itself.

Riverpod Folder Structure

lib/
├── core/
│   ├── error/
│   │   └── failures.dart
│   └── usecases/
│       └── usecase.dart
├── features/
│   └── invoices/
│       ├── domain/
│       │   ├── entities/
│       │   │   └── invoice.dart
│       │   ├── repositories/
│       │   │   └── invoice_repository.dart
│       │   └── usecases/
│       │       └── get_overdue_invoices.dart
│       ├── data/
│       │   ├── models/
│       │   │   └── invoice_model.dart
│       │   ├── datasources/
│       │   │   ├── invoice_remote_datasource.dart
│       │   │   └── invoice_local_datasource.dart
│       │   └── repositories/
│       │       └── invoice_repository_impl.dart
│       └── presentation/
│           ├── providers/
│           │   └── invoice_notifier.dart
│           ├── pages/
│           │   └── invoice_list_page.dart
│           └── widgets/
│               └── invoice_card.dart
└── providers/
    └── repository_providers.dart

The bloc/ directory with its three files is replaced by a single providers/ directory. The root-level providers/repository_providers.dart replaces injection_container.dart, declaring providers for repository implementations that feature-level providers depend on.

Testing

Testing is where architectural discipline pays off. Both BLoC and Riverpod enable testing without a widget tree, but the mechanics differ.

Testing a BLoC:

blocTest<InvoiceBloc, InvoiceState>(
  'emits [loading, loaded] when LoadOverdueInvoices succeeds',
  build: () {
    when(() => mockGetOverdueInvoices(any()))
        .thenAnswer((_) async => Right(testInvoices));
    return InvoiceBloc(mockGetOverdueInvoices);
  },
  act: (bloc) => bloc.add(LoadOverdueInvoices()),
  expect: () => [
    InvoiceLoading(),
    InvoiceLoaded(testInvoices),
  ],
);

blocTest provides a declarative structure: build the BLoC, perform an action, assert on the emitted state sequence. The verify parameter lets you confirm that the use case was called with the expected arguments.

Testing a Riverpod Notifier:

test('loads overdue invoices on build', () async {
  final container = ProviderContainer(
    overrides: [
      invoiceRepositoryProvider.overrideWithValue(mockRepository),
    ],
  );
  addTearDown(container.dispose);

  when(() => mockRepository.getAllInvoices())
      .thenAnswer((_) async => Right(testInvoices));

  final notifier = container.read(invoiceNotifierProvider.notifier);
  final state = await container.read(invoiceNotifierProvider.future);

  expect(state, equals(testInvoices));
});

Riverpod's ProviderContainer acts as a standalone dependency graph for tests. You override specific providers with mocks, then read or watch the provider under test. No BuildContext, no widget pumping, no service locator reset.

Both approaches produce fast, isolated unit tests. The difference is stylistic: BLoC tests read as event-state sequences; Riverpod tests read as provider evaluations.

Final Considerations

Neither BLoC nor Riverpod is universally superior. They solve the same problem — decoupling business logic from UI — with different trade-offs in verbosity, safety, and developer experience.

What matters more than the state management choice is whether you actually enforce the CLEAN Architecture boundaries. A well-structured BLoC project and a well-structured Riverpod project will both be maintainable. A poorly structured project with either library will accumulate the same kind of debt.

Pick the tool that matches your team's experience and your application's complexity. Then enforce the dependency rule. That is where the long-term value lives.

If you are evaluating Flutter for an upcoming project and want guidance on architecture decisions tailored to your team, reach out to us.

Ready to discuss your project?

Get in Touch →
← Back to Blog

More Articles