DEV Community

Cover image for Building a Flutter Shopping App — Part 4: The Big Refactor — Migrating to MVVM
Yadnesh Teli
Yadnesh Teli

Posted on AI-assisted

Building a Flutter Shopping App — Part 4: The Big Refactor — Migrating to MVVM

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 ProductRepository talks to Firestore. A CartRepository reads from local storage. Repositories return Models.
  • ViewModel: A StateNotifier or AsyncNotifier that 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this is better

  1. 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.
  2. 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.
  3. 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)