skip to content

In Flutter's recommended MVVM architecture, which responsibilities belong in a view model and which belong in a repository?

level: middleimportance: must knowfreq 50%

answer

  1. UI state versus app data
  2. filter and sort for display
  3. caching, retry, error handling
  4. repositories never know each other
  5. raw data in, domain models out

basics

~20 s

A view model turns domain data into UI state, keeps view state such as filters, and exposes callbacks for the view. A repository owns one data type: fetching, caching, retry, error handling and mapping raw data into domain models.

solid answer

~40 s

Flutter's architecture guide splits the work by what each class may know. A **view model** knows its one view: it reads domain models from repositories, filters, sorts or combines them into UI state, keeps flags such as a selected filter, and exposes callbacks, which the guide calls commands, for the view's gestures. A **repository** knows one type of data and nothing about screens: it polls services, caches, retries, handles errors, refreshes and maps raw data into **domain models**. Repositories must never call each other; data from two repositories is combined in a view model, or in a use case when that logic is complex or reused.

code

dart · 66 lines
dart
import 'dart:async';

import 'package:flutter/foundation.dart';

class FlightOffer {
  const FlightOffer({required this.priceCents, required this.stops});
  final int priceCents;
  final int stops;
}

abstract class FlightApiService {
  Future<List<FlightOffer>> searchOffers(String route);
}

// Repository: data concerns only.
class FlightRepository {
  FlightRepository({required FlightApiService api}) : _api = api;
  final FlightApiService _api;
  final Map<String, List<FlightOffer>> _cache = {};

  Future<List<FlightOffer>> search(String route) async {
    final cached = _cache[route];
    if (cached != null) return cached;
    var attempt = 0;
    while (true) {
      attempt++;
      try {
        final offers = await _api.searchOffers(route);
        _cache[route] = offers;
        return offers;
      } on TimeoutException {
        if (attempt >= 3) rethrow;
      }
    }
  }
}

// View model: presentation concerns only.
class FlightSearchViewModel extends ChangeNotifier {
  FlightSearchViewModel({required FlightRepository flights})
      : _flights = flights;
  final FlightRepository _flights;

  List<FlightOffer> _all = const [];
  bool _nonstopOnly = false;
  bool get nonstopOnly => _nonstopOnly;

  List<FlightOffer> get visibleOffers {
    final list = [
      for (final o in _all)
        if (!_nonstopOnly || o.stops == 0) o,
    ];
    list.sort((a, b) => a.priceCents.compareTo(b.priceCents));
    return list;
  }

  void toggleNonstop() {
    _nonstopOnly = !_nonstopOnly;
    notifyListeners();
  }

  Future<void> load(String route) async {
    _all = await _flights.search(route);
    notifyListeners();
  }
}

go deeper

for a junior

Remember the split: view models shape data for a screen, repositories own the data itself and its caching and retries.

for a middle

Walk through a concrete case, such as a filter toggle in the view model and a retry loop in the repository, and explain why repositories never call each other.

for a senior

Point out the production cost of getting it wrong: caches lost on navigation, duplicated retry logic, and view models that cannot be tested without the network.

for a principal

Discuss where to draw the line for shared presentation logic across features, and when a use case earns its extra class.

## The dividing line Flutter's architecture guide gives both classes logic, so the question is not *whether* a class has logic but *which kind*. The test is what each class is allowed to know: - A **view model** knows exactly one view and the repositories it reads. It does not know where data comes from. - A **repository** knows exactly one type of app data and the services it reads. It does not know which screens exist. | Concern | View model | Repository | |---|---|---| | Shape data for one screen (filter, sort, aggregate) | yes | no | | Remember UI state such as a selected tab or a toggle | yes | no | | Expose callbacks for gestures | yes | no | | Cache and refresh data | no | yes | | Retry and handle failed calls | no | yes | | Map raw payloads into domain models | no | yes | | Hold app-wide session state shared by features | no | yes | ## View model responsibilities The guide lists three jobs for a view model: 1. **Transform app data into UI state.** Data from repositories is rarely shaped the way a screen shows it. In a flight-search app, the view model hides flights with stops when the user toggles *nonstop only* and sorts the rest by price. 2. **Keep the state the view needs** so the view can rebuild without losing it: the toggle itself, which result is expanded, whether a search is running. 3. **Expose callbacks** that the view attaches to buttons and form submissions, so the view runs logic without knowing its implementation. The guide wraps these in command objects, a separate topic. In the guide's sample the view model extends `ChangeNotifier` and calls `notifyListeners()` after its state changes, but Riverpod, BLoC or streams are listed as valid alternatives. ## Repository responsibilities The guide makes a repository the **source of truth** for one data type and gives it the business logic tied to services: - caching, and refreshing that cache - error handling and retry logic - polling services for new data, and refreshing after user actions - transforming raw service data into **domain models** the view models can consume In the flight app, `FlightRepository` caches offers per route, retries a timed-out fares call a few times, and returns `FlightOffer` objects instead of raw JSON maps. ## Combining data from two repositories Repositories should **never be aware of each other**. When the results screen must show which flights are already in the user's saved trips, `FlightRepository` does not call `TripRepository`. The view model reads both and merges them. If the same merge is needed by several view models, or grows complex, it moves into a **use case** in the optional domain layer. ## Common mistakes - Putting a `Map` cache in a view model: the cache dies with the screen and other screens refetch. - Giving a repository an `isLoading` flag for one screen: the repository now knows about UI. - Sorting inside the repository for one screen's needs: every other consumer inherits that order. - Making the view model's repositories public: the view can then reach the data layer directly.

  • The results screen and the checkout screen both need 'price plus loyalty discount'. Where does that logic go?
    It merges two repositories and is reused by two view models, which is exactly when the guide suggests a use case in the optional domain layer. The use case depends on both repositories; each view model depends on the use case and can still read other repositories directly.
  • Should a repository expose a Stream or return Futures?
    Either fits the guide. Its sample repositories return futures and view models reload after events, but the guide also shows a repository exposing a stream of the signed-in user that emits on sign-in and sign-out. A stream suits data that changes without a user action, such as polled fares.

saying these in an interview costs you the question

  • Repositories should call each other to assemble related data.
  • The view model should own the cache so the screen stays fast.
  • A repository should expose loading flags for the screen that uses it.
  • A repository is a thin pass-through to the API with no logic.
  • Views should read repositories directly when the view model has nothing to add.