Series 1 of 2 — Part 4 of 5
Building a Flutter Shopping App — Part 4: The Big Refactor — Migrating to MVVM
Series: Building a Flutter Shopping App — From Zero to Architecture
By Part 3 the app was working — auth, Firestore, notifications, all functional. But the codebase was becoming uncomfortable to work in. Adding a feature meant touching multiple files in ways that felt wrong. The providers knew too much. Business logic was everywhere.
My internship mentor pointed me toward MVVM. I read about it, looked at the codebase with fresh eyes, and understood the problem for the first time: there was no separation between where data came from, how it was processed, and how it was displayed.
So I refactored the entire app to MVVM.
What MVVM means in Flutter with Riverpod
- Model: Pure data classes. No Flutter, no Riverpod, no Firebase — just data.
-
Repository: Where data comes from. A
ProductRepositorytalks to Firestore. ACartRepositoryreads from local storage. Repositories return Models. -
ViewModel: A
StateNotifierorAsyncNotifierthat calls repositories and exposes state to the UI. No direct API calls. - View: Screens and widgets that watch ViewModels. They know nothing about APIs, Firestore, or SharedPreferences.
Before and after
Before the refactor, a provider called Firestore directly and held business logic alongside state:
// ❌ BEFORE: Directly calling Firestore from inside state management
class ProductsNotifier extends StateNotifier<List<ProductModel>> {
Future<void> loadProducts() async {
final snapshot = await FirebaseFirestore.instance
.collection('products').get();
state = snapshot.docs.map((d) => ProductModel.fromJson(d.data())).toList();
}
}
After the refactor:
// ✅ AFTER: Repository — knows about Firestore
class ProductRepository {
Future<List<ProductModel>> fetchProducts() async {
final snapshot = await FirebaseFirestore.instance
.collection('products').get();
return snapshot.docs.map((d) => ProductModel.fromJson(d.data())).toList();
}
}
// ✅ AFTER: ViewModel — knows about the repository, not Firestore
class ProductViewModel extends AsyncNotifier<List<ProductModel>> {
@override
Future<List<ProductModel>> build() async {
return ref.read(productRepositoryProvider).fetchProducts();
}
}
Why this is better
- Testability: A ViewModel that takes a repository through DI can be tested with a mock repository. You cannot do this when the ViewModel calls Firestore directly.
- Clarity: Changing how products are fetched — adding a cache layer, switching from Firestore to a REST API — only touches the repository. The ViewModel and screens are unchanged.
- Onboarding: When I showed the refactored codebase to my mentor, they understood it immediately. The original required explanation.
The honest cost of refactoring mid-project
The refactor was slow. Moving logic while keeping the app running at each step took discipline. I refactored one feature at a time, tested it, committed, and moved on. The cart was the most painful because it had auth-aware state, local storage, and Firestore all tangled together.
The lesson: understanding MVVM before you start means you can make deliberate decisions about when to apply it. You should not always architect perfectly from day one — but you should know what proper architecture looks like so you can recognise when you are diverging from it.
What I learned
- Single Responsibility: MVVM is not about following rules — it is about keeping each layer focused on one job.
- Abstraction power: Repositories make testing possible and make swapping data sources easy.
- Pain is a teacher: Refactoring mid-project is painful, but you understand why the pattern matters when you have felt the cost of not having it.
Next: Part 5 — Cleaning Up DI with SharedPreferencesGetter
Top comments (0)