The Flutter Kit logoThe Flutter Kit
Guide

Flutter App Architecture: BLoC vs Riverpod vs Provider — The Complete Guide

Architecture is the difference between a Flutter app that scales and one that collapses under its own weight. This guide compares BLoC, Riverpod, and Provider with real Dart code — and tells you exactly when to use each one.

Ahmed GaganAhmed Gagan
16 min read

Answer: Which Architecture Should You Use?

For production Flutter apps in 2026, BLoC (Business Logic Component) is the most practical choice. It enforces a clear separation between UI and business logic using events and states, is highly testable, scales well from simple to complex apps, and has the largest community and ecosystem support. Riverpod is an excellent alternative if you prefer a more declarative approach. Provider is fine for small apps but becomes messy as complexity grows.

The Flutter Kit uses BLoC throughout — and this guide explains why, with enough Dart code to implement each pattern yourself.

Why Architecture Matters More Than You Think

I have reviewed dozens of Flutter codebases from indie developers. The number one problem is not bad UI design or missing features — it is architecture. Specifically: business logic mixed into widgets, state scattered across StatefulWidgets, Firebase calls directly in the build method, and no clear data flow.

This works for the first few screens. Then you need to share state between two screens. Then you need to test something. Then you need to refactor a feature. And the whole thing unravels because there is no structure.

Good architecture is not about following patterns for their own sake. It is about making your app maintainable as it grows, testable so you can ship with confidence, and flexible so you can change backends or add features without rewriting everything.

The Three Contenders

Provider: The Simple Starting Point

Provider was Flutter's recommended state management solution for years. It is a wrapper around InheritedWidget that makes it easy to expose and consume objects in the widget tree. Provider is simple to learn and requires minimal boilerplate.

// A simple Provider example
class CounterProvider extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

// In your widget tree
ChangeNotifierProvider(
  create: (_) => CounterProvider(),
  child: const MyApp(),
)

// Consuming the state
class CounterScreen extends StatelessWidget {
  const CounterScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final counter = context.watch<CounterProvider>();
    return Text('Count: ${counter.count}');
  }
}

Pros: Minimal boilerplate, easy to learn, built into Flutter's core ecosystem.

Cons: No enforced structure — business logic tends to leak into ChangeNotifier classes that grow into god objects. Testing requires manually constructing the widget tree. State changes are implicit (call a method, hope notifyListeners() fires). As your app grows, Provider classes become catch-all containers for unrelated state and logic.

BLoC: The Structured Workhorse

BLoC (Business Logic Component) separates your app into three layers: Events (user actions), States (UI representations), and Blocs (business logic that transforms events into states). The flutter_bloc package provides the implementation.

// Events — what can happen
abstract class NotesEvent {}

class LoadNotes extends NotesEvent {}

class AddNote extends NotesEvent {
  final String title;
  final String content;
  AddNote({required this.title, required this.content});
}

class DeleteNote extends NotesEvent {
  final String noteId;
  DeleteNote(this.noteId);
}

// States — what the UI should show
abstract class NotesState {}

class NotesInitial extends NotesState {}

class NotesLoading extends NotesState {}

class NotesLoaded extends NotesState {
  final List<Note> notes;
  NotesLoaded(this.notes);
}

class NotesError extends NotesState {
  final String message;
  NotesError(this.message);
}
// The BLoC — transforms events into states
class NotesBloc extends Bloc<NotesEvent, NotesState> {
  final NotesRepository _repository;

  NotesBloc(this._repository) : super(NotesInitial()) {
    on<LoadNotes>(_onLoadNotes);
    on<AddNote>(_onAddNote);
    on<DeleteNote>(_onDeleteNote);
  }

  Future<void> _onLoadNotes(
    LoadNotes event,
    Emitter<NotesState> emit,
  ) async {
    emit(NotesLoading());
    try {
      final notes = await _repository.getNotes();
      emit(NotesLoaded(notes));
    } catch (e) {
      emit(NotesError(e.toString()));
    }
  }

  Future<void> _onAddNote(
    AddNote event,
    Emitter<NotesState> emit,
  ) async {
    try {
      await _repository.addNote(
        title: event.title,
        content: event.content,
      );
      // Reload notes after adding
      add(LoadNotes());
    } catch (e) {
      emit(NotesError(e.toString()));
    }
  }

  Future<void> _onDeleteNote(
    DeleteNote event,
    Emitter<NotesState> emit,
  ) async {
    try {
      await _repository.deleteNote(event.noteId);
      add(LoadNotes());
    } catch (e) {
      emit(NotesError(e.toString()));
    }
  }
}
// Using the BLoC in your UI
class NotesScreen extends StatelessWidget {
  const NotesScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<NotesBloc, NotesState>(
      builder: (context, state) {
        if (state is NotesLoading) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        if (state is NotesError) {
          return Center(
            child: Text('Error: ${state.message}'),
          );
        }

        if (state is NotesLoaded) {
          return ListView.builder(
            itemCount: state.notes.length,
            itemBuilder: (context, index) {
              final note = state.notes[index];
              return Dismissible(
                key: Key(note.id),
                onDismissed: (_) {
                  context.read<NotesBloc>().add(
                    DeleteNote(note.id),
                  );
                },
                child: ListTile(
                  title: Text(note.title),
                  subtitle: Text(note.content),
                ),
              );
            },
          );
        }

        return const SizedBox.shrink();
      },
    );
  }
}

Pros: Clear unidirectional data flow (Event → BLoC → State). Every state transition is explicit and traceable. Highly testable — mock the repository, send events, verify states. Scales well — each feature gets its own BLoC. Large community and extensive documentation.

Cons: More boilerplate than Provider. You write separate classes for events, states, and the BLoC itself. For very simple state (a toggle, a counter), the ceremony feels excessive.

Riverpod: The Modern Alternative

Riverpod is Remi Rousselet's (the original author of Provider) next-generation state management library. It fixes Provider's limitations — no BuildContext dependency for reading state, compile-time safety, better testing story, and the ability to override providers without the widget tree.

// Riverpod providers
final notesRepositoryProvider = Provider<NotesRepository>(
  (ref) => NotesRepository(),
);

final notesProvider = AsyncNotifierProvider<
    NotesNotifier, List<Note>>(
  NotesNotifier.new,
);

class NotesNotifier extends AsyncNotifier<List<Note>> {
  @override
  Future<List<Note>> build() async {
    final repository = ref.watch(
      notesRepositoryProvider,
    );
    return repository.getNotes();
  }

  Future<void> addNote({
    required String title,
    required String content,
  }) async {
    final repository = ref.read(
      notesRepositoryProvider,
    );
    await repository.addNote(
      title: title,
      content: content,
    );
    ref.invalidateSelf(); // Reload notes
  }

  Future<void> deleteNote(String noteId) async {
    final repository = ref.read(
      notesRepositoryProvider,
    );
    await repository.deleteNote(noteId);
    ref.invalidateSelf();
  }
}

// Using Riverpod in the UI
class NotesScreen extends ConsumerWidget {
  const NotesScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notesAsync = ref.watch(notesProvider);

    return notesAsync.when(
      loading: () => const Center(
        child: CircularProgressIndicator(),
      ),
      error: (error, stack) => Center(
        child: Text('Error: $error'),
      ),
      data: (notes) => ListView.builder(
        itemCount: notes.length,
        itemBuilder: (context, index) {
          final note = notes[index];
          return ListTile(
            title: Text(note.title),
            subtitle: Text(note.content),
          );
        },
      ),
    );
  }
}

Pros: Less boilerplate than BLoC. No BuildContext needed to read state. Compile-time safety for provider dependencies. The AsyncNotifier pattern handles loading/error/data states elegantly. Provider overrides make testing clean.

Cons: Smaller community than BLoC. The API has changed significantly between major versions (v1 vs v2), so older tutorials may be outdated. Code generation (with riverpod_generator) adds a build step. Less explicit state transitions compared to BLoC's event/state pattern.

Side-by-Side Comparison

DimensionProviderBLoCRiverpod
Learning curveLowMediumMedium-High
BoilerplateMinimalModerate (events + states + bloc)Low-Medium
TestabilityModerate — requires widget treeExcellent — test blocs in isolationExcellent — provider overrides
ScalabilityPoor at scale — god objects emergeExcellent — one bloc per featureGood — providers compose well
State traceabilityLow — implicit notifyListeners()High — explicit events and statesMedium — state changes are functional
Community sizeLargeLargestGrowing
Ecosystem packagesBuilt into Flutterflutter_bloc, bloc_test, hydrated_blocriverpod, riverpod_generator, hooks_riverpod
DebuggingBasic — print statementsBlocObserver — log every event and state transitionProviderObserver — log provider changes

Dependency Injection: The Missing Piece

Regardless of which state management solution you choose, you need a dependency injection (DI) strategy. DI is how you provide repositories, services, and configuration to your blocs/providers/notifiers without hardcoding dependencies.

The Flutter Kit uses get_it as a service locator for singleton services, combined with BlocProvider for widget-scoped state:

import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setupDependencies() {
  // Singletons — created once, shared everywhere
  getIt.registerLazySingleton<AuthRepository>(
    () => FirebaseAuthRepository(),
  );
  getIt.registerLazySingleton<NotesRepository>(
    () => FirestoreNotesRepository(),
  );
  getIt.registerLazySingleton<StorageRepository>(
    () => FirebaseStorageRepository(),
  );
  getIt.registerLazySingleton<SubscriptionRepository>(
    () => RevenueCatSubscriptionRepository(),
  );

  // Factories — new instance each time
  getIt.registerFactory<NotesBloc>(
    () => NotesBloc(getIt<NotesRepository>()),
  );
  getIt.registerFactory<AuthBloc>(
    () => AuthBloc(getIt<AuthRepository>()),
  );
}

// Call in main.dart before runApp()
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  setupDependencies();
  runApp(const MyApp());
}

This pattern gives you:

  • Testability — swap FirebaseAuthRepository for a MockAuthRepository in tests
  • Flexibility — change backends by swapping repository implementations
  • Clean widgets — widgets only know about BLoCs, never about Firebase or other infrastructure

The Layered Architecture Pattern

Here is how The Flutter Kit structures a production app. Think of it as three concentric layers:

// Layer 1: Presentation (Widgets + BLoCs)
// Widgets dispatch events to BLoCs
// BLoCs emit states that widgets render
lib/
  features/
    notes/
      bloc/
        notes_bloc.dart
        notes_event.dart
        notes_state.dart
      screens/
        notes_screen.dart
        note_detail_screen.dart
      widgets/
        note_card.dart

// Layer 2: Domain (Models + Repository Interfaces)
// Pure Dart — no Flutter or Firebase imports
  domain/
    models/
      note.dart
      user_profile.dart
    repositories/
      auth_repository.dart      # Abstract interface
      notes_repository.dart     # Abstract interface

// Layer 3: Data (Repository Implementations + Services)
// Firebase/Supabase/API implementation details
  data/
    repositories/
      firebase_auth_repository.dart
      firestore_notes_repository.dart
    services/
      notification_service.dart
      analytics_service.dart

The key rule: dependencies point inward. Presentation depends on Domain. Data depends on Domain. But Domain depends on nothing — it is pure Dart with no external imports. This means you can swap Firebase for Supabase by replacing the Data layer without touching a single widget or BLoC.

Testing a BLoC

One of BLoC's biggest advantages is testability. The bloc_test package lets you test every state transition in isolation:

import 'package:bloc_test/bloc_test.dart';
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';

class MockNotesRepository extends Mock
    implements NotesRepository {}

void main() {
  late MockNotesRepository repository;

  setUp(() {
    repository = MockNotesRepository();
  });

  group('NotesBloc', () {
    final mockNotes = [
      Note(
        id: '1',
        title: 'Test Note',
        content: 'Content',
        createdAt: DateTime.now(),
      ),
    ];

    blocTest<NotesBloc, NotesState>(
      'emits [NotesLoading, NotesLoaded] '
      'when LoadNotes is added',
      build: () {
        when(() => repository.getNotes())
            .thenAnswer((_) async => mockNotes);
        return NotesBloc(repository);
      },
      act: (bloc) => bloc.add(LoadNotes()),
      expect: () => [
        isA<NotesLoading>(),
        isA<NotesLoaded>()
            .having(
              (s) => s.notes,
              'notes',
              mockNotes,
            ),
      ],
    );

    blocTest<NotesBloc, NotesState>(
      'emits [NotesLoading, NotesError] '
      'when repository throws',
      build: () {
        when(() => repository.getNotes())
            .thenThrow(Exception('Network error'));
        return NotesBloc(repository);
      },
      act: (bloc) => bloc.add(LoadNotes()),
      expect: () => [
        isA<NotesLoading>(),
        isA<NotesError>(),
      ],
    );
  });
}

You test every state transition without running the app, without a real database, and without a widget tree. This is the kind of confidence that lets you refactor features without fear.

When to Use Which

  • Use Provider if you are building a small app (5-10 screens), prototyping quickly, or learning Flutter. It is the simplest option and gets out of your way. But plan to migrate to BLoC or Riverpod if the app grows.
  • Use BLoC if you are building a production app with 10+ screens, need excellent testability, want clear state traceability for debugging, or are working on a team. BLoC's structure prevents the "spaghetti state" that plagues larger apps.
  • Use Riverpod if you prefer a more declarative/functional approach, want less boilerplate than BLoC, and are comfortable with a smaller community. Riverpod is particularly elegant for dependency injection and computed state.

The Flutter Kit's Architecture

The Flutter Kit uses BLoC with the layered architecture pattern described above. When you clone the project, you get:

  • Feature-based folder structure with dedicated BLoCs for auth, onboarding, notes, subscriptions, and AI chat
  • Abstract repository interfaces in the domain layer — swap Firebase for Supabase by changing one file
  • get_it dependency injection configured and ready
  • BlocObserver for logging every state transition during development
  • bloc_test examples for every core BLoC
  • GoRouter navigation with BLoC-based auth guards

You do not have to figure out architecture from scratch. The structure is there — you add your features on top. Check out the features page for the full list, or grab the kit at checkout for $69.

Share this article

Ready to ship your Flutter app faster?

The Flutter Kit gives you a production-ready Flutter codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Flutter Kit