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>,DioExceptionandDocumentSnapshotnever 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 likegetFromApi()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.
// ══ 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 contract | Must not appear in the contract | Why |
|---|---|---|
| `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:
Streamwhen there is a cache or live updates;Futurereads 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
- Architecture recommendations: abstract repositoriesofficialdocs.flutter.dev
Why abstract repositories are a top-priority recommendation: a different implementation per environment.
- Dart: class modifiersofficialdart.dev
What `abstract interface class` gives you: `implements` allowed, `extends` forbidden.
- Offline-first design patternofficialdocs.flutter.dev
Why a repository returning a `Stream` is preferable — local data first, then the server's.
- Case study: the data layerofficialdocs.flutter.dev
A repository contract and its `...Remote` / `...Local` implementations in a real project.