Sparround

The repository contract: abstract classes and dependency inversion

A repository contract is declared in the domain layer and answers exactly one question: what does the app want to do with this data? The "how" — HTTP, caching, a database — is the implementation's business.

Three rules when writing a contract:

  • Domain types only. Inputs and outputs are domain models, enums, primitives. Response, Map<String, dynamic>, DioException and DocumentSnapshot never appear in a contract.
  • Let the method name state the intent. fetchActive(), watchAll(), save(Order), cancel(String id) — naming the operation, not the source. A name like getFromApi() leaks the source.
  • One kind of data = one repository. Splitting by screen (HomeScreenRepository) is a mistake: the repository has to change when the screen changes, and two screens end up loading the same data twice.

In Dart the contract is an abstract class. In Dart 3 you can also write abstract interface class, which forbids extends and allows only implements. The practical difference: an implementation inherits no behaviour, so a "half implementation" becomes impossible.

dart
// ══ lib/domain/repositories/order_repository.dart ══ (müqavilə)
import 'package:my_app/domain/models/order.dart';

// `abstract interface class`: yalnız `implements` — `extends` qadağandır.
abstract interface class OrderRepository {
  /// Cari istifadəçinin sifarişləri, ən yenisi əvvəldə.
  Future<List<Order>> fetchMine({int page = 1, int pageSize = 20});

  /// Bir sifariş. Tapılmadıqda `OrderNotFoundFailure` qaytarır.
  Future<Order> fetchById(String id);

  /// Sifarişi ləğv edir və yenilənmiş obyekti qaytarır.
  Future<Order> cancel(String id);

  /// Siyahının canlı axını — lokal cache dəyişdikdə də yenilənir.
  Stream<List<Order>> watchMine();
}

// ══ lib/data/repositories/order_repository_remote.dart ══ (məhsul rejimi)
class OrderRepositoryRemote implements OrderRepository {
  OrderRepositoryRemote({required OrderApiClient apiClient})
      : _apiClient = apiClient;

  final OrderApiClient _apiClient;
  final _controller = StreamController<List<Order>>.broadcast();

  @override
  Future<List<Order>> fetchMine({int page = 1, int pageSize = 20}) async {
    final dtos = await _apiClient.getOrders(page: page, limit: pageSize);
    final orders = dtos.map((d) => d.toDomain()).toList();
    _controller.add(orders);          // canlı axını da yeniləyir
    return orders;
  }

  @override
  Future<Order> fetchById(String id) async =>
      (await _apiClient.getOrder(id)).toDomain();

  @override
  Future<Order> cancel(String id) async =>
      (await _apiClient.cancelOrder(id)).toDomain();

  @override
  Stream<List<Order>> watchMine() => _controller.stream;
}

// ══ test/fakes/fake_order_repository.dart ══ (test rejimi)
class FakeOrderRepository implements OrderRepository {
  FakeOrderRepository(this._orders);
  List<Order> _orders;

  @override
  Future<List<Order>> fetchMine({int page = 1, int pageSize = 20}) async =>
      _orders;

  @override
  Future<Order> fetchById(String id) async =>
      _orders.firstWhere((o) => o.id == id);

  @override
  Future<Order> cancel(String id) async {
    final cancelled = (await fetchById(id))
        .copyWith(status: OrderStatus.cancelled);
    _orders = [
      for (final o in _orders) if (o.id == id) cancelled else o,
    ];
    return cancelled;
  }

  @override
  Stream<List<Order>> watchMine() => Stream.value(_orders);
}

One contract, three implementations. The notifier only knows the contract — DI decides which implementation runs.

May appear in the contractMust not appear in the contractWhy
`Future<List<Order>>``Future<Response>``Response` is an HTTP detail — a local implementation could not return it
`Order`, `OrderStatus``OrderDto`, `Map<String, dynamic>`A DTO is the backend's shape; leaking it ties the whole UI to JSON
`String id`, `int page``Uri`, `QueryParameters`Building an endpoint is the service's job
Domain `Failure` types`DioException`, `SocketException`A library's exception ties the domain to that library
`Stream<List<Order>>``Stream<QuerySnapshot>`A database type leaks the source and makes leaving Firestore impossible

`Future` or `Stream`? The official offline-first page answers this concretely: `Stream` is preferable, because the repository can emit twice — fast local data first, then the refreshed server data. The UI receives both automatically.

In practice the balance is:

  • Reads: Stream when there is a cache or live updates; Future reads better for a simple one-shot request.
  • Writes: always Future — the operation happens once and has one outcome (success or failure).

On the Riverpod side both are comfortable: the notifier's build returns a Future in one case and a Stream in the other. AsyncValue is identical either way.

How many methods? A contract should be exactly as wide as the app's need. A "universal" method like fetchAll(filter, sort, page, size, includeArchived, ...) with eight parameters turns the contract into disguised SQL. Intent-based methods read far better: fetchMine(), fetchArchived(), search(String query).

The most common mistake: writing the contract after the implementation. Then the contract becomes a mirror of HTTP (getOrders, postOrder, patchOrderStatus) and adds no value.

The right order: first write what the notifier needs, derive the contract from that, then implement it.

Practice. Pick a repository in your project and rewrite its contract: make every method name intent-based and remove every library type from the signatures. Then write the Fake... implementation of that contract (over an in-memory list).

Done means: grep -E "Response|Dio|Map<String" lib/domain/repositories finds nothing, and the fake implementation is under 30 lines.

📚 Sources and documentation