A comprehensive tutorial application demonstrating Clean Architecture with four feature examples using BLoC and Cubit patterns: BLoC (event-driven), Cubit (method-driven), Cubit with BlocConsumer (listener + builder), and BLoC with BlocConsumer using flutter_bloc to manage state when loading data from an API.
This tutorial demonstrates:
- ✅ Clean Architecture: Separation of concerns with Domain, Data, and Presentation layers
- ✅ BLoC Pattern: Event-driven architecture with events and states (User example)
- ✅ Cubit Pattern: Simplified approach with direct method calls - no events (Post example)
- ✅ Cubit with BlocConsumer: Combined builder and listener for side effects (Todo example)
- ✅ BLoC with BlocConsumer: Event-driven with side effects (Product example)
- ✅ Use Cases: Business logic isolation from presentation and data layers
- ✅ Repository Pattern: Abstract data access with concrete implementations
- ✅ Entities vs Models: Pure domain objects vs data transfer objects
- ✅ Managing different UI states (Initial, Loading, Success, Error, Refreshing)
- ✅ Dual state emission pattern for repeated actions (BlocConsumer examples)
- ✅ Side effects: Snackbars, navigation, haptic feedback
- ✅ Simulating API calls with
Future.delayed - ✅ Handling errors gracefully with retry logic and Failure classes
- ✅ Using
BlocProvider,BlocBuilder,BlocListener, andBlocConsumer - ✅ Modern Dart 3+ features: sealed classes, switch expressions, record patterns
- ✅ When to use BLoC vs Cubit vs BlocConsumer for your projects
- ✅ Dependency flow: Presentation → Domain ← Data
lib/
├── core/ # Shared across features
│ ├── error/
│ │ └── failures.dart # Failure classes for error handling
│ └── usecases/
│ └── usecase.dart # Base use case interface
│
├── features/ # Feature-based organization
│ ├── user/ # BLoC pattern example
│ │ ├── domain/ # Business logic layer
│ │ │ ├── entities/
│ │ │ │ └── user.dart # Pure domain entity
│ │ │ ├── repositories/
│ │ │ │ └── user_repository.dart # Repository interface
│ │ │ └── usecases/
│ │ │ ├── get_users.dart # Use case: Get users
│ │ │ └── get_users_with_error.dart # Use case: Trigger error
│ │ ├── data/ # Data access layer
│ │ │ ├── datasources/
│ │ │ │ └── user_remote_datasource.dart # API service
│ │ │ ├── models/
│ │ │ │ └── user_model.dart # DTO with JSON serialization
│ │ │ └── repositories/
│ │ │ └── user_repository_impl.dart # Repository implementation
│ │ └── presentation/ # UI layer
│ │ ├── bloc/
│ │ │ ├── user_bloc.dart # BLoC implementation
│ │ │ ├── user_event.dart # Event definitions
│ │ │ └── user_state.dart # State definitions
│ │ └── screens/
│ │ └── user_list_screen.dart # BLoC pattern UI
│ │
│ ├── post/ # Cubit pattern example
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ └── post.dart
│ │ │ ├── repositories/
│ │ │ │ └── post_repository.dart
│ │ │ └── usecases/
│ │ │ ├── get_posts.dart
│ │ │ └── get_posts_with_error.dart
│ │ ├── data/
│ │ │ ├── datasources/
│ │ │ │ └── post_remote_datasource.dart
│ │ │ ├── models/
│ │ │ │ └── post_model.dart
│ │ │ └── repositories/
│ │ │ └── post_repository_impl.dart
│ │ └── presentation/
│ │ ├── cubit/
│ │ │ ├── post_cubit.dart # Cubit implementation (no events!)
│ │ │ └── post_state.dart # State definitions
│ │ └── screens/
│ │ └── post_list_screen.dart # Cubit pattern UI
│ │
│ ├── product/ # BlocConsumer pattern example
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ └── product.dart # with copyWith for cart updates
│ │ │ ├── repositories/
│ │ │ │ └── product_repository.dart
│ │ │ └── usecases/
│ │ │ └── get_products.dart
│ │ ├── data/
│ │ │ ├── datasources/
│ │ │ │ └── product_remote_datasource.dart
│ │ │ ├── models/
│ │ │ │ └── product_model.dart
│ │ │ └── repositories/
│ │ │ └── product_repository_impl.dart
│ │ └── presentation/
│ │ ├── bloc/
│ │ │ ├── product_bloc.dart # BLoC for BlocConsumer
│ │ │ ├── product_event.dart # 7 events including cart actions
│ │ │ └── product_state.dart # 8 states including action states
│ │ └── screens/
│ │ └── product_list_screen.dart # BlocConsumer demo
│ │
│ └── home/
│ └── home_screen.dart # Home screen with pattern selection
│
└── main.dart # App entry point
Pure business logic - independent of frameworks
-
Entities: Core business objects (User, Post, Product)
- Pure Dart classes with business rules
- No JSON serialization
- No framework dependencies
-
Repositories: Abstract interfaces defining data operations
- Contracts that data layer must implement
- Return entities, not models
-
Use Cases: Single-responsibility business operations
- GetUsers, GetPosts, GetProducts
- Contains business logic
- Called by presentation layer
Data access implementation
-
Data Sources: Remote/local data fetching (API services)
UserRemoteDataSource- API calls with Future.delayed (2s mock)- Returns models, not entities
-
Models: DTOs extending entities with JSON serialization
UserModel extends User- adds fromJson/toJson- Handles data transformation
-
Repository Implementations: Concrete implementations
UserRepositoryImpl implements UserRepository- Calls data sources, returns entities
UI and state management
-
BLoC/Cubit: State management using use cases
- UserBloc calls GetUsers use case (not repository!)
- PostCubit calls GetPosts use case
- ProductBloc calls GetProducts use case
-
Screens: UI components
- BlocProvider, BlocBuilder, BlocConsumer
- Dispatches events or calls methods
-
States: UI state representations
- Initial, Loading, Loaded, Error, Action states
Presentation Layer → Domain Layer ← Data Layer
(UI) (Logic) (API)
UserBloc → GetUsers ← UserRepositoryImpl
(Use Case) (calls UserRemoteDataSource)
- UserBloc (Presentation) → GetUsers (Domain) → UserRepository (Domain interface) → UserRepositoryImpl (Data) → UserRemoteDataSource (Data)
Events represent user actions or system events:
LoadUsersEvent- Triggered to load users successfullyLoadUsersWithErrorEvent- Triggered to simulate an errorRetryLoadUsersEvent- Triggered to retry after an error
States represent the current condition of the UI:
UserInitialState- Initial state before any actionUserLoadingState- Data is being loaded (shows loading indicator)UserLoadedState- Data loaded successfully (shows user list of entities)UserErrorState- An error occurred (shows error message)
The UserBloc class:
- Receives events from the UI
- Calls use cases (not repositories or services directly!)
- Emits appropriate states based on the result
- Keeps business logic separate from UI
- Works with entities from domain layer
- PostCubit (Presentation) → GetPosts (Domain) → PostRepository (Domain interface) → PostRepositoryImpl (Data) → PostRemoteDataSource (Data)
Cubit doesn't use events - you call methods directly:
// BLoC way
context.read<UserBloc>().add(LoadUsersEvent());
// Cubit way
context.read<PostCubit>().loadPosts();PostInitialState- Initial statePostLoadingState- Data is being loadedPostLoadedState- Data loaded successfully (contains entities)PostErrorState- An error occurredPostRefreshingState- Refreshing while showing old data
The PostCubit class provides direct methods:
loadPosts()- Load posts from API via GetPosts use caseloadPostsWithError()- Simulate error via GetPostsWithError use caseretry()- Retry after errorrefreshPosts()- Refresh with optimistic updateclear()- Reset to initial state
- Todo Example (Cubit): TodoCubit → GetTodos/AddTodo/ToggleTodo/DeleteTodo → TodoRepository → TodoRepositoryImpl → TodoRemoteDataSource
- Product Example (BLoC): ProductBloc → GetProducts → ProductRepository → ProductRepositoryImpl → ProductRemoteDataSource
BlocConsumer combines BlocBuilder and BlocListener into one widget:
- Builder: Updates UI based on state (like normal BlocBuilder)
- Listener: Handles side effects (snackbars, navigation, haptics)
BlocConsumer<ProductBloc, ProductState>(
listener: (context, state) {
// Side effects here (snackbars, navigation)
},
builder: (context, state) {
// UI here (widget tree)
},
)LoadProductsEvent- Load products successfullyLoadProductsWithErrorEvent- Trigger error scenarioAddToCartEvent- Add product to cartRemoveFromCartEvent- Remove product from cartCheckoutEvent- Initiate checkout flowRefreshProductsEvent- Refresh product listResetProductsEvent- Reset to initial state
Note: Product feature only has one use case (GetProducts) since cart operations are UI state only
ProductInitialState- Starting stateProductLoadingState- Products being fetchedProductLoadedState- Products loaded (contains product entities, cartItemCount)ProductAddedToCartState- Action state with timestamp (triggers listener)ProductRemovedFromCartState- Action state with timestamp (triggers listener)ProductErrorState- Error occurredProductCheckoutState- Checkout initiated (triggers listener for navigation)ProductRefreshingState- Refreshing while showing current products
The key to making BlocConsumer work for repeated actions:
// Problem: Listener only fires when state TYPE changes
// Solution: Emit action state → then emit base state
void _onAddToCart(event, emit) {
// ... update data ...
// Step 1: Emit action state (triggers listener for snackbar)
emit(ProductAddedToCartState(..., timestamp: DateTime.now()));
// Step 2: Emit loaded state (updates UI, ready for next action)
emit(ProductLoadedState(...));
}This allows:
- ✅ Snackbar shows every time you add to cart
- ✅ Listener fires for every action (not just first time)
- ✅ State resets to
LoadedStatebetween actions
The listener handles non-UI logic:
- Snackbars: "Product added to cart!" with green/orange colors
- Navigation: Navigate to checkout screen
- Haptic Feedback: Phone vibration on actions
- Dialogs: Show confirmation modals
The builder handles UI rendering:
- Product list with cart badges
- Loading indicators
- Error messages with retry button
- Pull-to-refresh functionality
| Feature | BLoC (User) | Cubit (Post) | Cubit + BlocConsumer (Todo) | BLoC + BlocConsumer (Product) |
|---|---|---|---|---|
| Events | Required | Not needed | Not needed | Required |
| States | 4 states | 5 states | Action states | Action states |
| How to trigger | bloc.add(Event()) |
cubit.method() |
cubit.method() |
bloc.add(Event()) |
| Side Effects | Separate BlocListener | Separate BlocListener | Built-in listener | Built-in listener |
| UI Updates | BlocBuilder | BlocBuilder | Built-in builder | Built-in builder |
| Boilerplate | Medium | Low (-40%) | Medium | High |
| Use case | CRUD operations | Simple lists | Todo lists, CRUD | Shopping carts |
| Files needed | 3 (bloc, events, states) | 2 (cubit, states) | 2 (cubit, states) | 3 (bloc, events, states) |
| User Feedback | Manual | Manual | Integrated snackbars | Integrated snackbars/haptics |
| Learning curve | Medium | Low | Medium | High |
| When to use | Standard features | Prototyping | Interactive CRUD | Complex interactions |
- Make sure you have Flutter installed
- Navigate to the project directory
- Get dependencies:
flutter pub get
- Run the app:
flutter run
When you launch, you'll see four demo options:
- Posts - Cubit Pattern (simple method calls)
- Users - BLoC Pattern (event-driven architecture)
- Todos - Cubit with BlocConsumer (CRUD with side effects)
- Products - BLoC with BlocConsumer (shopping cart with haptic feedback)
- Initial State: Welcome screen with two buttons
- Load Users (Success): Click to simulate successful API call
- Watch the loading indicator appear
- After 2 seconds, see the user list displayed
- Load Users (Error): Click to simulate a failed API call
- Watch the loading indicator appear
- After 2 seconds, see an error message
- Click "Retry" to try loading again
- Info Button: Tap for BLoC pattern information
- Initial State: Welcome screen with comparison info
- Load Posts (Success): Click to see direct method call in action
- Observe the same loading → success flow
- No events needed!
- Load Posts (Error): Test error handling
- Same user experience, simpler code
- Refresh Button: In app bar - demonstrates advanced refresh pattern
- Info Button: Tap for Cubit pattern information
- Initial Load: Todos load automatically on screen open
- Add Todo: Tap the floating action button
- ✅ Green snackbar appears: "Todo added successfully!"
- ✅ New todo appears in the list
- Toggle Todo: Tap a todo item to mark complete/incomplete
- ✅ Snackbar shows status change
- ✅ Checkbox updates
- Delete Todo: Swipe to delete a todo
- ✅ Orange snackbar appears: "Todo deleted"
- ✅ Todo removed from list
- Error Handling: Test error scenarios
- Info Button: Tap for BlocConsumer pattern explanation with Cubit
- Initial Load: Products load automatically on screen open
- Add to Cart: Tap the cart icon on any product
- ✅ Green snackbar appears: "Product added to cart!"
- ✅ Phone vibrates (haptic feedback)
- ✅ Icon changes to checkmark
- ✅ Cart badge updates
- Remove from Cart: Tap the checkmark icon
- ✅ Orange snackbar appears: "Product removed from cart"
- ✅ Icon changes back to cart
- ✅ Cart badge decrements
- Checkout: Tap the checkout button in app bar
- ✅ Dialog appears with item count
- ✅ Strong haptic feedback
- Pull to Refresh: Drag down to refresh product list
- Error Handling: Use app bar menu to trigger error scenario
- Info Button: Tap for BlocConsumer pattern explanation with BLoC
// lib/features/user/domain/entities/user.dart
class User {
final int id;
final String name;
final String email;
final String role;
User({required this.id, required this.name, required this.email, required this.role});
// Pure domain object - no JSON, no dependencies
}// lib/features/user/data/models/user_model.dart
class UserModel extends User {
UserModel({required super.id, required super.name, required super.email, required super.role});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
name: json['name'],
email: json['email'],
role: json['role'],
);
}
// Model adds JSON serialization
}// lib/features/user/data/datasources/user_remote_datasource.dart
class UserRemoteDataSource {
Future<List<UserModel>> getUsers() async {
await Future.delayed(const Duration(seconds: 2)); // Simulates network delay
return [/* mock user models */];
}
Future<List<UserModel>> getUsersWithError() async {
await Future.delayed(const Duration(seconds: 2));
throw Exception('Failed to load users');
}
}// lib/features/user/domain/repositories/user_repository.dart
abstract class UserRepository {
Future<List<User>> getUsers();
Future<List<User>> getUsersWithError();
}// lib/features/user/data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
UserRepositoryImpl({required this.remoteDataSource});
@override
Future<List<User>> getUsers() async {
// Calls data source, returns entities
return await remoteDataSource.getUsers();
}
}// lib/features/user/domain/usecases/get_users.dart
class GetUsers implements UseCase<List<User>, NoParams> {
final UserRepository repository;
GetUsers(this.repository);
@override
Future<List<User>> call(NoParams params) async {
return await repository.getUsers();
}
}// lib/features/user/presentation/bloc/user_bloc.dart
class UserBloc extends Bloc<UserEvent, UserState> {
final GetUsers getUsersUseCase;
final GetUsersWithError getUsersWithErrorUseCase;
UserBloc({required this.getUsersUseCase, required this.getUsersWithErrorUseCase})
: super(UserInitialState()) {
on<LoadUsersEvent>(_onLoadUsers);
}
Future<void> _onLoadUsers(LoadUsersEvent event, Emitter<UserState> emit) async {
emit(UserLoadingState());
try {
final users = await getUsersUseCase(NoParams()); // Calls use case!
emit(UserLoadedState(users));
} catch (error) {
emit(UserErrorState(error.toString()));
}
}
}// lib/features/user/presentation/screens/user_list_screen.dart
BlocBuilder<UserBloc, UserState>(
builder: (context, state) {
return switch (state) {
UserInitialState() => _buildInitialView(context),
UserLoadingState() => _buildLoadingView(),
UserLoadedState() => _buildLoadedView(state.users), // Uses entities
UserErrorState() => _buildErrorView(context, state.errorMessage),
};
},
)// Post Cubit calls use case directly
Future<void> loadPosts() async {
emit(PostLoadingState());
try {
final posts = await getPostsUseCase(NoParams()); // Calls GetPosts use case
emit(PostLoadedState(posts));
} catch (error) {
emit(PostErrorState(error.toString()));
}
}flutter_bloc: ^9.1.1- BLoC state management librarybloc: ^9.1.0- Core BLoC libraryintl: ^0.19.0- Internationalization and date formatting
- Clean Architecture: Domain, data, and presentation layers properly separated
- Dependency Inversion: BLoC/Cubit depends on use cases (abstractions), not concrete implementations
- Use Cases: Single Responsibility Principle - one use case per operation
- Repository Pattern: Abstract interfaces in domain, implementations in data layer
- Entities vs Models: Pure domain objects vs DTOs with serialization
- Separation of Concerns: Business logic is in domain layer, not in BLoC/Cubit or UI
- Immutable States: All states are immutable for predictability
- Error Handling: Proper error handling with user-friendly messages and Failure classes
- Loading States: Clear feedback during async operations
- Sealed Classes: Using sealed classes for type-safe state handling
- Dependency Injection: BLoC/Cubit receives use cases via constructor
- Pattern Selection: Choose the right tool (BLoC vs Cubit vs BlocConsumer) for the job
- Testability: Each layer can be tested independently with mocks
The refreshPosts() method shows an advanced pattern:
- Keeps showing current data while refreshing
- Shows loading indicator overlay
- Restores previous data if refresh fails
- Great for pull-to-refresh scenarios
Future<void> refreshPosts() async {
if (state is PostLoadedState) {
final currentPosts = (state as PostLoadedState).posts;
emit(PostRefreshingState(currentPosts));
} else {
emit(PostLoadingState());
}
try {
final posts = await postApiService.fetchPosts();
emit(PostLoadedState(posts));
} catch (error) {
if (state is PostRefreshingState) {
final previousPosts = (state as PostRefreshingState).currentPosts;
emit(PostLoadedState(previousPosts)); // Restore on error
} else {
emit(PostErrorState(error.toString()));
}
}
}Want to experiment? Try these:
- Change the delay duration in API services
- Add more fields to User or Post models
- Create a search feature using BLoC events or Cubit methods
- Implement pagination with LoadMoreEvent or loadMore() method
- Add a favorites feature with a separate BLoC/Cubit
- Compare the code size between BLoC and Cubit implementations
- ARCHITECTURE.md - Flow diagrams for all four examples
- QUICK_REFERENCE.md - Code snippets and common patterns
- CUBIT_GUIDE.md - Deep dive into Cubit vs BLoC
- BLOC_CONSUMER_TUTORIAL.md - Complete guide to BlocConsumer widget
- BLOCCONSUMER_IMPLEMENTATION_COMPLETE.md - BlocConsumer implementation details
- EXERCISES.md - Practice exercises for all patterns
- BEGINNERS_GUIDE.dart - Step-by-step explanation
- TUTORIAL_OVERVIEW.md - Complete package overview
- ✓ You need event tracking/logging
- ✓ Complex business logic with multiple events triggering same state
- ✓ Event transformations needed (debounce, throttle)
- ✓ Team prefers strict event-driven architecture
- ✓ Large applications with complex flows
- ✓ Standard CRUD operations
Examples: User management, data loading, traditional list views (User feature)
- ✓ Simple, straightforward state changes
- ✓ Prototyping or MVP development
- ✓ Less complex business logic
- ✓ Want to reduce boilerplate (~40% less code)
- ✓ Team prefers simplicity
- ✓ Direct method calls feel more natural
Examples: Simple lists, settings screens, basic forms (Post feature)
- ✓ Simple CRUD operations with user feedback
- ✓ Need both UI updates AND side effects
- ✓ Show user feedback (snackbars, toasts)
- ✓ Want simpler code than BLoC (no events)
- ✓ Basic interactive lists
Examples: Todo lists, note apps, simple CRUD with confirmations (Todo feature)
- ✓ Complex interactions with side effects
- ✓ Need event tracking AND user feedback
- ✓ Shopping cart or checkout workflows
- ✓ Multiple event types with rich UX
- ✓ Navigate based on state changes
- ✓ Trigger animations or haptic feedback
- ✓ Optimistic updates with rollback
Examples: Shopping carts, like/unlike buttons, add to favorites, complex forms (Product feature)
Need side effects (snackbars, navigation, haptics)?
├─ Yes
│ ├─ Simple logic, no event tracking needed?
│ │ └─ Use Cubit with BlocConsumer (Todo example)
│ └─ Complex logic, need event tracking?
│ └─ Use BLoC with BlocConsumer (Product example)
└─ No
├─ Need event tracking or complex logic?
│ └─ Use BLoC (User example)
└─ Simple CRUD?
└─ Use Cubit (Post example)
This is a tutorial project. Feel free to fork and modify it for your learning!
This project is open source and available for educational purposes.