Skip to content

The data layer of a feature is responsible for providing a concrete implementation of the domain layer’s repositories. How it does that is completely up to the data layer itself. It may use a Drift database for local storage and an http api as a remote source. It may introduce an in-memory cache if necessary. Those are implementation details handled by the data layer.

The different storage mechanisms are known as data sources. The repository is responsible for mixing these data sources to fulfil the contract, or interface, defined by the domain layer.

For example, to enable some offline browsing, our online store app may have a Drift database data source and a Shopify http api data source. These live within the data package of a feature.

The mealify feature-first app does exactly this. Its meals_data package holds two data sources side by side: a Drift database for local storage, and an http client for the remote API. Neither is visible outside the package: the repository combines them, and the domain layer only ever sees a Meal.

Each data source may return a DTO. For example, a ProductDatabase class might return a DbProduct generated by Drift. The DbProduct class knows a lot of information about the database, and is tightly coupled to Drift. We do not want to leak these classes to our domain layer, and eventually to our presentation layer.

Therefore, the data layer is responsible for converting DTOs from each data source into the appropriate domain model, such as a Product. This can be achieved in a variety of ways:

  • Hand or AI-written extension methods, such as dbProduct.toDomain().
  • Hand or AI-written Converter classes, such as class DbToDomainProduct extends Converter<DbProduct, Product>.
  • Libraries that generate the mapping for you using build_runner, such as auto_mappr.

A Converter keeps the mapping in one named, testable place, and it reads well at the call site:

product_data/lib/src/mappers/db_to_domain_product_converter.dart
/// Converts the Drift row into the domain model.
class DbToDomainProductConverter extends Converter<DbProduct, Product> {
/// Construct a converter from database products to domain products.
const DbToDomainProductConverter();
@override
Product convert(DbProduct dbProduct) {
return Product(
id: dbProduct.id,
title: dbProduct.title,
description: dbProduct.description,
// The database stores cents as an integer. The domain works in whole
// currency units, so the conversion belongs here, not in a Bloc.
price: dbProduct.priceInCents / 100,
);
}
}

The repository then applies it as data comes out of the source, so a DbProduct never escapes the package:

product_data/lib/src/repositories/products_repository.dart
@override
Future<Product?> getProductById(String id) async {
final dbProduct = await _database.getProduct(id);
return dbProduct == null ? null : _dbToDomainConverter.convert(dbProduct);
}

One converter per source and direction. A feature reading from both a database and an API has a DbToDomain... and an ApiToDomain..., which is what meals_data does.

For now, we do not believe it makes sense to define an abstract DTO for in-memory, local, and remote storage options. In general, it adds extra, unnecessary mapping to objects that are usually generated by Drift, Swagger, or other libraries.

Next, the presentation layer builds the UI on top of the domain.