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 OrderRepository → UserRepository → OrderRepository 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.
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.
| Decision | Why in the repository | What happens if it lives in the UI |
|---|---|---|
| Cache lifetime (TTL) | One decision for every screen | Each screen picks its own TTL and data diverges between screens |
| Serving stale cache when offline | In one place: every screen benefits automatically | Some screens work offline, others go blank |
| Coalescing concurrent requests | One request reaches the network | Three screens opening means three identical requests |
| Exception → `Failure` translation | The domain vocabulary is preserved | Every widget has to catch `SocketException` |
| The session (current user) | A single source; logout happens in one place | Two 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.