Tutorials

Build a catalog data layer

Assemble a domain Repository, remote and cache DataSources, mapping, observation, and shutdown.

v1.1.0Intermediateark_data_layer

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.

Terminal
shell
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:

dart
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:

dart
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:

dart
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

dart
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

dart
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

dart
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, and CatalogItem.
  • Data code owns DTOs, source contracts, mapping, and the concrete Repository.
  • DataSourceContainer validates 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.