Sparround

Repository implementation: the source of truth

The repository is the decision-making part of the data layer. The official offline-first page states its role directly: repositories are the single source of truth and should be the only place where data can be modified.

A repository's jobs:

  • Call one or more services.
  • Convert DTOs into domain models (through a mapper).
  • Hold the cache and decide its lifetime.
  • Translate errors into the domain's language (Failure / Result).
  • Where needed: retries, coalescing concurrent requests, a live Stream.

A nuance worth noting: repositories do not know about each other. If you need data from two of them, the combining happens in a view model or a use case. That rule prevents circular dependencies: chains like OrderRepositoryUserRepositoryOrderRepository make testing and start-up impossible.

App-wide shared state such as the session also lives in a repository — because it is the single source of truth for data.

dart
class ProductRepositoryRemote implements ProductRepository {
  ProductRepositoryRemote({
    required ProductApiClient apiClient,
    Duration cacheTtl = const Duration(minutes: 5),
  })  : _apiClient = apiClient,
        _cacheTtl = cacheTtl;

  final ProductApiClient _apiClient;
  final Duration _cacheTtl;

  // Cache: məlumat + vaxt möhürü. Bu, repository-nin state-idir.
  List<Product>? _cache;
  DateTime? _cachedAt;

  // Paralel sorğuların birləşdirilməsi: eyni anda 3 ekran açılsa,
  // şəbəkəyə BİR sorğu gedir.
  Future<List<Product>>? _inFlight;

  final _controller = StreamController<List<Product>>.broadcast();

  bool get _isCacheFresh =>
      _cache != null &&
      _cachedAt != null &&
      DateTime.now().difference(_cachedAt!) < _cacheTtl;

  @override
  Future<List<Product>> fetchActive({bool forceRefresh = false}) {
    if (!forceRefresh && _isCacheFresh) return Future.value(_cache);
    return _inFlight ??= _load().whenComplete(() => _inFlight = null);
  }

  Future<List<Product>> _load() async {
    try {
      final dtos = await _apiClient.getActiveProducts();
      final products = dtos.map((dto) => dto.toDomain()).toList();

      _cache = products;
      _cachedAt = DateTime.now();
      _controller.add(products);          // dinləyiciləri xəbərdar edir
      return products;
    } on UnauthorizedException {
      // Texniki exception → domain dilinə çevrilir.
      throw const AuthFailure();
    } on SocketException {
      // Şəbəkə yoxdur: köhnə cache varsa, onu vermək daha yaxşıdır.
      final stale = _cache;
      if (stale != null) return stale;
      throw const NetworkFailure();
    }
  }

  @override
  Stream<List<Product>> watchActive() async* {
    if (_cache != null) yield _cache!;    // dərhal cache
    yield* _controller.stream;            // sonra yeniləmələr
  }

  @override
  Future<Product> fetchById(String id) async {
    // Cache-də varsa, şəbəkəyə çıxmır.
    final cached = _cache?.where((p) => p.id == id).firstOrNull;
    if (cached != null) return cached;
    return (await _apiClient.getProduct(id)).toDomain();
  }

  void dispose() => _controller.close();
}

A repository with a cache, coalescing of concurrent requests, and a live stream. All those decisions concentrate in one class — the UI knows none of them.

DecisionWhy in the repositoryWhat happens if it lives in the UI
Cache lifetime (TTL)One decision for every screenEach screen picks its own TTL and data diverges between screens
Serving stale cache when offlineIn one place: every screen benefits automaticallySome screens work offline, others go blank
Coalescing concurrent requestsOne request reaches the networkThree screens opening means three identical requests
Exception → `Failure` translationThe domain vocabulary is preservedEvery widget has to catch `SocketException`
The session (current user)A single source; logout happens in one placeTwo screens can show different users

Where to keep the cache. An in-memory cache (the example above) disappears when the app closes. That is enough for many cases and it is the simplest option.

If you need a persistent cache, the official docs give two design patterns: key-value storage (small data: settings, the last search) and SQL storage (lists, relational data). The important nuance: that storage mechanism is written as a service (PreferencesService, DatabaseService), while the repository decides which service to read from.

Who disposes. A repository using a StreamController needs a dispose method. In Riverpod that is wired inside the provider:

ref.onDispose(repository.dispose) — when the provider is torn down, the controller closes with it.

When not to cache. Caching is not always helpful: for fast-changing data (live prices, a notification count) showing a stale value confuses the user. There a Stream with a short TTL, or simply reading without a cache, is the better call.

Practice. Add three things to one repository in your project: (1) an in-memory cache with a TTL, (2) coalescing of concurrent requests (_inFlight), (3) a fallback to stale cache on network failure. Then write three tests: the service is not called while the cache is fresh; two concurrent calls produce one request; stale data is returned on a network error.

Done means: all three are green, and the tests use a fake service that counts calls (callCount).

📚 Sources and documentation

  • Offline-first design patternofficialdocs.flutter.dev

    The repository as single source of truth, plus the read/write strategies — the main source for this topic.

  • Case study: the data layerofficialdocs.flutter.dev

    How a repository uses services and converts DTOs to domain models in real code.

  • Key-value data storage patternofficialdocs.flutter.dev

    Persisting small data — written as a service, consumed by a repository.

  • SQL storage patternofficialdocs.flutter.dev

    The official approach to storing lists and relational data locally.