Advanced BLoC Architecture for Production Flutter Apps
As Flutter applications grow, state management is rarely the hardest problem. The difficult part is keeping business rules, networking, persistence, navigation, and UI concerns from becoming tightly coupled. BLoC works especially well when it is treated as an architectural boundary, not simply as a way to move variables out of widgets.
This tutorial presents a production-oriented structure that separates:
- Presentation
- State management
- Application/use-case logic
- Domain models
- Repository contracts
- Data sources
- Infrastructure
1. The architecture
A useful dependency direction is:
UI
โ
BLoC / Cubit
โ
Use Case
โ
Repository interface
โ
Repository implementation
โ
Remote / Local data source
The important rule is that dependencies should point inward toward stable business abstractions.
A typical project can look like:
lib/
โโโ core/
โ โโโ error/
โ โโโ network/
โ โโโ routing/
โ โโโ di/
โโโ features/
โ โโโ products/
โ โโโ data/
โ โ โโโ datasources/
โ โ โโโ models/
โ โ โโโ repositories/
โ โโโ domain/
โ โ โโโ entities/
โ โ โโโ repositories/
โ โ โโโ usecases/
โ โโโ presentation/
โ โโโ bloc/
โ โโโ pages/
โ โโโ widgets/
โโโ main.dart
2. Define the domain contract
The domain should not know whether data comes from Dio, SQLite, Firebase, or a mock.
class Product {
final int id;
final String name;
final double price;
const Product({
required this.id,
required this.name,
required this.price,
});
}
Create a repository contract:
abstract interface class ProductRepository {
Future<List<Product>> getProducts();
}
Now the business layer depends on an abstraction.
3. Add a use case
Use cases are useful when application rules become more complex than a single repository call.
class GetProducts {
final ProductRepository repository;
GetProducts(this.repository);
Future<List<Product>> call() {
return repository.getProducts();
}
}
The BLoC does not need to know how HTTP requests work.
4. Implement the data layer
A model converts transport data into domain data.
class ProductModel extends Product {
const ProductModel({
required super.id,
required super.name,
required super.price,
});
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
id: json['id'] as int,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
);
}
}
The repository implementation owns the data-source details:
abstract interface class ProductRemoteDataSource {
Future<List<ProductModel>> fetchProducts();
}
class ProductRepositoryImpl implements ProductRepository {
final ProductRemoteDataSource remote;
ProductRepositoryImpl(this.remote);
@override
Future<List<Product>> getProducts() {
return remote.fetchProducts();
}
}
5. Design explicit BLoC states
Avoid a single state containing many nullable fields. Explicit states make UI behavior predictable.
sealed class ProductState {
const ProductState();
}
final class ProductInitial extends ProductState {}
final class ProductLoading extends ProductState {}
final class ProductLoaded extends ProductState {
final List<Product> products;
const ProductLoaded(this.products);
}
final class ProductFailure extends ProductState {
final String message;
const ProductFailure(this.message);
}
6. Build the BLoC
sealed class ProductEvent {
const ProductEvent();
}
final class LoadProducts extends ProductEvent {}
class ProductBloc extends Bloc<ProductEvent, ProductState> {
final GetProducts getProducts;
ProductBloc(this.getProducts) : super(ProductInitial()) {
on<LoadProducts>(_onLoadProducts);
}
Future<void> _onLoadProducts(
LoadProducts event,
Emitter<ProductState> emit,
) async {
emit(ProductLoading());
try {
final products = await getProducts();
emit(ProductLoaded(products));
} catch (error) {
emit(ProductFailure(error.toString()));
}
}
}
For production systems, map low-level exceptions into application-level failures rather than exposing raw HTTP or database exceptions to the UI.
7. Keep widgets focused on presentation
BlocBuilder<ProductBloc, ProductState>(
builder: (context, state) {
return switch (state) {
ProductInitial() => const SizedBox.shrink(),
ProductLoading() => const Center(
child: CircularProgressIndicator(),
),
ProductLoaded(:final products) => ListView.builder(
itemCount: products.length,
itemBuilder: (_, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
subtitle: Text(product.price.toStringAsFixed(2)),
);
},
),
ProductFailure(:final message) => Center(
child: Text(message),
),
};
},
)
The widget renders state. It should not contain repository calls, JSON parsing, retry policies, or business rules.
8. Use BlocListener for side effects
Navigation, dialogs, snackbars, analytics, and similar effects generally belong in BlocListener.
BlocListener<ProductBloc, ProductState>(
listener: (context, state) {
if (state case ProductFailure(:final message)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
},
child: const ProductView(),
)
Use BlocBuilder for rendering and BlocListener for one-off effects.
9. Dependency injection
Create dependencies at the application boundary.
final repository = ProductRepositoryImpl(remoteDataSource);
final getProducts = GetProducts(repository);
runApp(
BlocProvider(
create: (_) => ProductBloc(getProducts),
child: const MyApp(),
),
);
In a larger project, use a dependency-injection package or a dedicated composition root. The key idea is the same: construct dependencies outside business classes.
10. Make BLoCs testable
Because the BLoC receives a use case instead of creating networking objects itself, testing becomes straightforward.
class FakeGetProducts implements GetProducts {
FakeGetProducts(this.result);
final List<Product> result;
@override
Future<List<Product>> call() async => result;
}
A test can verify:
Initial โ LoadProducts โ Loading โ Loaded
and failure:
Initial โ LoadProducts โ Loading โ Failure
For production applications, test state transitions, repository behavior, serialization, and important business rules independently.
11. Avoid common BLoC mistakes
Do not put networking directly in a BLoC
Bad:
on<LoadProducts>((event, emit) async {
final response = await Dio().get('/products');
// ...
});
Better:
BLoC โ Use Case โ Repository โ Data Source
Do not emit huge mutable state objects. Prefer immutable state and immutable collections.
Do not create multiple BLoCs for every widget. Create a BLoC around a meaningful feature or business boundary, not every screen component.
Do not over-engineer tiny features. Architecture should reduce complexity, not create it. A small screen may only need a Cubit and repository.
12. Production checklist
Before shipping a feature, check:
- UI does not contain business logic.
- BLoCs do not construct infrastructure dependencies.
- Repository interfaces hide data-source details.
- Errors are mapped to meaningful application failures.
- States are immutable and explicit.
- Side effects are separated from rendering.
- Important transitions have tests.
- Dependencies are created in one composition root.
- Loading, empty, error, retry, and success states are handled.
Conclusion
A production BLoC architecture is less about writing more classes and more about enforcing useful boundaries. The strongest structure is one where a UI can change without rewriting networking, a backend can change without rewriting widgets, and business rules can be tested without starting Flutter. The goal is not maximum abstraction. The goal is controlled change.
Useful Links
- Website: www.v-modal.com
- SDK Flutter: https://github.com/v-modal/vmodal_sdk_flutterter
- SDK Android: https://github.com/v-modal/vmodal_sdk_androidoid
- Discord: https://discord.gg/K72z28KUx
Comments
No comments yet. Start the discussion.