Dart Tutorial

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.

RoleChangeNotifierRiverpodBloc
ViewModelChangeNotifierNotifier or AsyncNotifierCubit or Bloc
Provided byConstructor or providerA global providerBlocProvider
View listens withListenableBuilder or context.watchref.watchBlocBuilder

The repository, service and model are identical in all three.

Rules to keep #

  • A view model never holds a BuildContext and 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.

Practise in the playground Updated by Santosh Adhikari