Реальный пример: общий слой данных

Соберём изученное в один связный слой данных, который одинаково обслуживает Android и iOS.

Общий слой данных — связка из моделей (DTO/domain), API-клиента, базы и репозитория в общем коде, предоставляющая UI единый, кэшируемый источник данных.

Что строим

Соберём end-to-end слой для списка заказов: модель, сетевой запрос, локальный кэш в БД, репозиторий с политикой «сначала кэш, потом сеть». Весь этот код — в commonMain, оба нативных UI используют его как есть.

Модели

// commonMain — DTO от API и доменная модель
@Serializable
data class OrderDto(val id: Long, @SerialName("total_amount") val total: Double)

data class Order(val id: Long, val total: Double)

fun OrderDto.toDomain() = Order(id = id, total = total)

API-клиент

// commonMain
class OrderApi(private val client: HttpClient) {
    suspend fun fetchOrders(): List<OrderDto> =
        client.get("https://api.example.com/orders").body()
}

Репозиторий с кэшем

// commonMain
class OrderRepository(
    private val api: OrderApi,
    private val cache: OrderCache // поверх SQLDelight
) {
    fun orders(): Flow<List<Order>> = cache.observe()

    suspend fun refresh() {
        val fresh = api.fetchOrders().map { it.toDomain() }
        cache.replaceAll(fresh)
    }
}

UI подписывается на orders() и видит кэш мгновенно, а refresh() подтягивает свежие данные в фоне — обе платформы получают эту логику бесплатно.

Кэш на SQLDelight

// commonMain — обёртка над сгенерированными запросами
class OrderCache(private val db: AppDatabase) {
    fun observe(): Flow<List<Order>> =
        db.orderQueries.selectAll().asFlow().mapToList()
            .map { rows -> rows.map { Order(it.id, it.total) } }

    fun replaceAll(orders: List<Order>) {
        db.transaction {
            db.orderQueries.deleteAll()
            orders.forEach { db.orderQueries.insertOrder(it.id, it.total) }
        }
    }
}

Как работает под капотом

Связка реализует паттерн single source of truth: источник истины для UI — база (Flow из SQLDelight), а сеть лишь обновляет базу. UI никогда не ходит в сеть напрямую — он наблюдает кэш. Когда refresh() заменяет данные в транзакции, SQLDelight уведомляет подписчиков Flow, и оба UI перерисовываются. Вся эта механика — общая; платформам остаётся только отрисовка. Это наглядно показывает, какую долю приложения покрывает общий код.

Частые ошибки

Дать UI ходить и в сеть, и в кэш — тогда теряется единый источник истины и появляются гонки. UI должен наблюдать только кэш. Вторая ошибка — смешивать DTO и доменные модели; маппинг toDomain() изолирует формат API. Третья — не оборачивать replaceAll в транзакцию, рискуя показать UI промежуточное полупустое состояние.

Итоги

  • Общий слой данных: модели, API, кэш и репозиторий — всё в commonMain.
  • Паттерн single source of truth: UI наблюдает кэш, сеть обновляет кэш.
  • Оба нативных UI получают одинаковую логику загрузки и кэширования бесплатно.
  • Транзакции и маппинг DTO→domain держат данные консистентными.
Проверьте себя
1. Что является источником истины для UI в этом примере?
AСеть напрямую
BЛокальный кэш (Flow из SQLDelight); сеть лишь обновляет кэш
CSharedPreferences
DСам UI
2. Зачем маппинг OrderDto в Order (toDomain)?
AДля ускорения сети
BЧтобы изолировать формат API от доменных моделей приложения
CЧтобы включить шифрование
DЭто требование Ktor