Tutorials
Build a catalog data layer
Assemble a domain Repository, remote and cache DataSources, mapping, observation, and shutdown.
Build a catalog data layer
This tutorial builds one complete feature boundary. The catalog starts with empty domain data, loads transport records from a remote source, maps them into domain entities, publishes one new immutable snapshot, and shuts down without transferring source ownership to the Repository.
Before you begin
Install the package and create a Dart console project. The example uses an in-memory remote source so the architectural boundaries remain visible; replacing it with HTTP does not change the Repository contract.
dart pub add ark_data_layer:^1.1.0
1. Define the domain-facing API
The rest of the application should see domain data and business operations, not DTOs or source clients:
abstract interface class CatalogRepository {
CatalogData get data;
Stream<CatalogData> get stream;
Future<void> synchronize();
}
final class CatalogData {
const CatalogData(this.items);
const CatalogData.empty() : items = const <CatalogItem>[];
final List<CatalogItem> items;
}
final class CatalogItem {
const CatalogItem({required this.id, required this.title});
final String id;
final String title;
}
The interface can live in the Domain layer. It does not extend the package Repository class and therefore does not force consumers to depend on Ark Data Layer.
2. Define one contract per technical responsibility
DTOs and source contracts belong to the Data layer:
abstract interface class CatalogRemoteDataSource implements DataSource {
Future<List<CatalogItemDto>> fetchCatalog();
Future<void> close();
}
final class CatalogItemDto {
const CatalogItemDto({required this.id, required this.label});
final String id;
final String label;
}
If the feature later adds a cache, declare a separate CatalogCacheDataSource. Do not distinguish unrelated responsibilities with string names such as remote and cache on one broad interface.
3. Implement the concrete Repository
Resolve required sources once during construction. The operation maps technical data before committing it:
final class CatalogRepositoryImpl extends Repository<CatalogData>
implements CatalogRepository {
CatalogRepositoryImpl({required super.dataSources})
: _remote = dataSources.get<CatalogRemoteDataSource>(),
super(initialData: const CatalogData.empty());
final CatalogRemoteDataSource _remote;
@override
Future<void> synchronize() async {
final records = await _remote.fetchCatalog();
final items = <CatalogItem>[
for (final record in records)
CatalogItem(id: record.id, title: record.label),
];
setData(CatalogData(List<CatalogItem>.unmodifiable(items)));
}
}
Until setData runs, listeners continue to observe the previous complete snapshot. A failed request therefore does not expose a half-mapped catalog.
4. Provide a concrete source
final class MemoryCatalogSource implements CatalogRemoteDataSource {
MemoryCatalogSource(this._records);
final List<CatalogItemDto> _records;
bool _closed = false;
@override
Future<List<CatalogItemDto>> fetchCatalog() async {
if (_closed) throw StateError('Catalog source is closed.');
return List<CatalogItemDto>.unmodifiable(_records);
}
@override
Future<void> close() async => _closed = true;
}
The source owns transport behavior only. It does not publish domain state and does not know which Repository consumes it.
5. Compose, observe, and run
final source = MemoryCatalogSource(<CatalogItemDto>[
const CatalogItemDto(id: 'ark-di', label: 'Ark DI'),
const CatalogItemDto(id: 'usecase-forge', label: 'UseCase Forge'),
]);
final repository = CatalogRepositoryImpl(
dataSources: DataSourceContainer(<DataSource>[source]),
);
final subscription = repository.stream.listen(
(data) => print(data.items.map((item) => item.title).join(', ')),
);
await repository.synchronize();
await Future<void>.delayed(Duration.zero);
A new listener first receives the current empty value and then the committed catalog. In a UI or UseCase, the consumer can render the current value immediately and react to later commits through the same contract.
6. Close by ownership
await subscription.cancel();
await repository.close();
await source.close();
The Repository closes its publication mechanism. The Composition Root closes source because it created and may share that object. If the Repository creates a private subscription or resource itself, release it from closeRepository().
What you have built
- Domain code depends on
CatalogRepository,CatalogData, andCatalogItem. - Data code owns DTOs, source contracts, mapping, and the concrete Repository.
DataSourceContainervalidates constructor wiring but never becomes runtime service lookup.- A commit publishes one complete domain snapshot.
- Lifecycle ownership remains visible at the Composition Root.
Next, read Coordinate overlapping refreshes before allowing several synchronize() calls to run concurrently. The package preserves commit order; it does not choose which remote result is still authoritative for your domain.