Flutter Lesson 74 of 83 5 min read
MVVM and the Repository Pattern in Flutter
Build a Flutter feature with MVVM: a model, a service, a repository with caching, a ChangeNotifier view model and a view, with tests.
On this page
This lesson builds one feature, a list of products, using the layers from the previous lesson. MVVM stands for Model, View, ViewModel.
1. The model #
A plain, immutable class. It knows nothing about widgets or HTTP.
// features/products/domain/product.dart
class Product {
const Product({required this.id, required this.name, required this.price});
final int id;
final String name;
final double price;
factory Product.fromJson(Map<String, dynamic> json) => Product(
id: json['id'] as int,
name: json['title'] as String,
price: (json['price'] as num).toDouble(),
);
}
2. The service #
It talks to one outside system and returns raw results. No caching, no rules.
// features/products/data/product_api.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class ProductApi {
ProductApi(this._client, {this.baseUrl = 'https://fakestoreapi.com'});
final http.Client _client;
final String baseUrl;
Future<List<Map<String, dynamic>>> fetchProducts() async {
final response = await _client
.get(Uri.parse('$baseUrl/products'))
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
return (jsonDecode(response.body) as List<dynamic>).cast<Map<String, dynamic>>();
}
}
3. The repository #
The repository is the only place the rest of the app gets products from. It decides whether to use the network or a cache, converts raw maps into models, and turns technical errors into something the app understands.
// features/products/data/product_repository.dart
class DataException implements Exception {
const DataException(this.message);
final String message;
}
abstract interface class ProductRepository {
Future<List<Product>> getProducts({bool forceRefresh = false});
}
class RemoteProductRepository implements ProductRepository {
RemoteProductRepository(this._api);
final ProductApi _api;
List<Product>? _cache;
@override
Future<List<Product>> getProducts({bool forceRefresh = false}) async {
final cached = _cache;
if (cached != null && !forceRefresh) return cached;
try {
final raw = await _api.fetchProducts();
final products = [for (final json in raw) Product.fromJson(json)];
_cache = products;
return products;
} catch (e) {
if (cached != null) return cached; // offline: fall back to what we have
throw const DataException('Could not load products. Check your connection.');
}
}
}
The interface is what the rest of the app depends on. Tests, and an offline or demo mode, can supply a different implementation.
4. The view model #
It holds the state of one screen and exposes the actions that screen can perform. It imports nothing from material.dart.
// features/products/ui/products_view_model.dart
import 'package:flutter/foundation.dart';
class ProductsViewModel extends ChangeNotifier {
ProductsViewModel(this._repository);
final ProductRepository _repository;
List<Product> _all = [];
String _query = '';
bool loading = false;
String? error;
List<Product> get products => _query.isEmpty
? _all
: _all.where((p) => p.name.toLowerCase().contains(_query)).toList();
bool get isEmpty => !loading && error == null && products.isEmpty;
Future<void> load({bool refresh = false}) async {
loading = _all.isEmpty; // keep showing the list during a refresh
error = null;
notifyListeners();
try {
_all = await _repository.getProducts(forceRefresh: refresh);
} on DataException catch (e) {
error = e.message;
} finally {
loading = false;
notifyListeners();
}
}
void search(String query) {
_query = query.trim().toLowerCase();
notifyListeners();
}
}
Notice what it does not contain: no BuildContext, no widgets, no navigation, no colours. That is what makes it testable.
5. The view #
It reads state and forwards events. It makes no decisions.
// features/products/ui/products_page.dart
import 'package:flutter/material.dart';
class ProductsPage extends StatefulWidget {
const ProductsPage({super.key, required this.viewModel});
final ProductsViewModel viewModel;
@override
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
@override
void initState() {
super.initState();
widget.viewModel.load();
}
@override
Widget build(BuildContext context) {
final vm = widget.viewModel;
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: const InputDecoration(
hintText: 'Search',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: vm.search,
),
),
Expanded(
child: ListenableBuilder(
listenable: vm,
builder: (context, child) {
if (vm.loading) {
return const Center(child: CircularProgressIndicator());
}
if (vm.error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(vm.error!),
const SizedBox(height: 12),
FilledButton(onPressed: vm.load, child: const Text('Try again')),
],
),
);
}
if (vm.isEmpty) {
return const Center(child: Text('No products found'));
}
return RefreshIndicator(
onRefresh: () => vm.load(refresh: true),
child: ListView.builder(
itemCount: vm.products.length,
itemBuilder: (context, i) {
final product = vm.products[i];
return ListTile(
title: Text(product.name, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Text('\$${product.price.toStringAsFixed(2)}'),
);
},
),
);
},
),
),
],
),
);
}
}
6. Putting it together #
// main.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() {
final api = ProductApi(http.Client());
final repository = RemoteProductRepository(api);
runApp(
MaterialApp(
home: ProductsPage(viewModel: ProductsViewModel(repository)),
),
);
}
Everything is created in one place and handed down. The next lesson shows tidier ways to do this as the app grows.
The payoff: tests #
The view model can be tested with no widgets, no network and no device.
import 'package:flutter_test/flutter_test.dart';
class FakeProductRepository implements ProductRepository {
FakeProductRepository({this.products = const [], this.fail = false});
final List<Product> products;
final bool fail;
@override
Future<List<Product>> getProducts({bool forceRefresh = false}) async {
if (fail) throw const DataException('Offline');
return products;
}
}
void main() {
const pen = Product(id: 1, name: 'Blue pen', price: 1.5);
const bag = Product(id: 2, name: 'Travel bag', price: 40);
test('loads products', () async {
final vm = ProductsViewModel(FakeProductRepository(products: [pen, bag]));
await vm.load();
expect(vm.products, [pen, bag]);
expect(vm.loading, isFalse);
expect(vm.error, isNull);
});
test('filters by search text, ignoring case', () async {
final vm = ProductsViewModel(FakeProductRepository(products: [pen, bag]));
await vm.load();
vm.search('BAG');
expect(vm.products, [bag]);
});
test('exposes an error message when loading fails', () async {
final vm = ProductsViewModel(FakeProductRepository(fail: true));
await vm.load();
expect(vm.error, 'Offline');
expect(vm.products, isEmpty);
});
}
These run in milliseconds and cover the logic that matters.
The same structure with other tools #
MVVM describes the shape, not the package.
| Role | ChangeNotifier | Riverpod | Bloc |
|---|---|---|---|
| ViewModel | ChangeNotifier | Notifier or AsyncNotifier | Cubit or Bloc |
| Provided by | Constructor or provider | A global provider | BlocProvider |
| View listens with | ListenableBuilder or context.watch | ref.watch | BlocBuilder |
The repository, service and model are identical in all three.
Rules to keep #
- A view model never holds a
BuildContextand never navigates. It exposes state, and the view reacts. - One view model per screen. Shared data belongs in a repository, not in a view model that two screens reach into.
- Repositories return models, never raw JSON or HTTP responses.
- State exposed to the view is read-only from the view’s side. Changes go through methods.
- A view model disposes what it creates, such as stream subscriptions.
Try it yourself #
Add a “favourites” feature to the example. Create a FavouritesRepository that stores product ids with shared_preferences, give the view model a toggleFavourite(id) method and an isFavourite(id) check, and show a heart on each row. Write a unit test for the view model using a fake favourites repository.