Слои приложения: data, domain и репозиторий
- Зачем это нужно
- Три слоя и правило направления
- Репозиторий: шов, ради которого всё затевалось
- Три модели одного товара
- Как это выглядит в проекте
- UseCase: когда нужен, а когда лишний
- Как собрать это без DI-фреймворка
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
В прошлой главе ViewModel получила состояние, но данные в примерах брались из воздуха. Пора ответить, откуда они приходят.
Самый короткий путь — сходить в сеть прямо из ViewModel. Он работает ровно один раз: пока приложение состоит из одного экрана и одного запроса. Дальше начинается знакомое: тот же запрос понадобился на втором экране, ответ надо кэшировать, сервер поменял название поля, а тестировать это невозможно, потому что для проверки нужен живой сервер.
Разделение на слои решает не «архитектурную красоту», а три конкретные задачи: где искать код, что менять при изменении сервера и как проверить работу без интернета.
Формулировка из конкурсного задания звучит буквально так: «Минимальное разделение на DOMAIN, PRESENTATION, DATA». Это первый и самый весомый критерий модуля архитектуры, а к нему привязан ещё и штраф — «минус за каждый файл, содержащийся вне соответствующей папки».
Три слоя и правило направления
ui — то, что видит пользователь: экраны, компоненты, ViewModel. Мы это уже написали.
data — откуда берутся данные: сеть, база, кэш, хранилище настроек.
domain — то, что приложение знает о предметной области: что такое товар, заказ, пользователь. Здесь нет ни Compose, ни Retrofit — только Kotlin.
Правило одно: зависимости смотрят внутрь. ui знает про domain, data знает про domain, а domain не знает ни про кого. Проверить его легко: откройте файл из domain и посмотрите на импорты. Если там androidx или retrofit — правило нарушено.
Практическая польза не в чистоте, а в том, что domain читается без контекста. Чтобы понять, что такое заказ в этом приложении, не нужно открывать ни экраны, ни сетевой код.
Штраф за файл не в своей папке — реальный критерий, а не придирка. Разложите пакеты в первый день, до того как появятся файлы: ui/, domain/, data/, а внутри data — remote/, local/, repository/. Перекладывать сорок файлов на защите дороже, чем создать три папки сразу.
Репозиторий: шов, ради которого всё затевалось
Репозиторий — посредник между ViewModel и источниками данных. Звучит как лишний уровень, пока не посмотришь, что он даёт:
data class Product(val id: Int, val title: String)
interface ProductRepository { suspend fun getProducts(): List<Product> }
class FakeRepository : ProductRepository {
override suspend fun getProducts() = listOf(Product(1, "Молоко"), Product(2, "Хлеб"))
}
class NetworkRepository : ProductRepository {
override suspend fun getProducts(): List<Product> {
println(" (сходил в сеть)")
return listOf(Product(1, "Молоко"), Product(2, "Хлеб"), Product(3, "Сыр"))
}
}
class CatalogViewModel(private val repo: ProductRepository) {
suspend fun load() = println(" экран показывает: ${repo.getProducts().map { it.title }}")
}
suspend fun main() {
println("спринт 1 — данные зашиты:")
CatalogViewModel(FakeRepository()).load()
println("спринт 2 — тот же экран, настоящий сервер:")
CatalogViewModel(NetworkRepository()).load()
println()
println("код CatalogViewModel не изменился ни на строку")
}
Живой вывод:
спринт 1 — данные зашиты:
экран показывает: [Молоко, Хлеб]
спринт 2 — тот же экран, настоящий сервер:
(сходил в сеть)
экран показывает: [Молоко, Хлеб, Сыр]
код CatalogViewModel не изменился ни на строку
Это ровно та механика, по которой устроены конкурсные задания. В спринте идёт задача «Экран Главная», а следом отдельной карточкой — «Экран Главная (Network)». Если репозиторий стоит с самого начала, вторая задача сводится к замене одной реализации. Если его нет — второй спринт превращается в переписывание готового экрана под сроком.
Три модели одного товара
Второй вопрос, который возникает сразу: почему нельзя обойтись одним классом Product везде. Ответ — потому что у сервера, у приложения и у экрана разные представления об одном и том же:
data class ProductDto(val product_id: Int?, val product_name: String?, val price_kopecks: Long?)
data class Product(val id: Int, val title: String, val priceRub: Double)
data class ProductUi(val title: String, val price: String)
fun ProductDto.toDomain() = Product(
id = product_id ?: 0,
title = product_name.orEmpty(),
priceRub = (price_kopecks ?: 0L) / 100.0,
)
fun Product.toUi() = ProductUi(title = title, price = "%.2f ₽".format(priceRub))
fun main() {
val fromServer = ProductDto(product_id = 7, product_name = "Молоко", price_kopecks = 8990)
println("1. с сервера: $fromServer")
val domain = fromServer.toDomain()
println("2. в приложении: $domain")
println("3. на экране: ${domain.toUi()}")
println()
val broken = ProductDto(product_id = null, product_name = null, price_kopecks = null)
println("сервер прислал пустоту: $broken")
println("после маппера: ${broken.toDomain()}")
println("экран не упал — потому что пустоту разобрали на границе")
}
1. с сервера: ProductDto(product_id=7, product_name=Молоко, price_kopecks=8990)
2. в приложении: Product(id=7, title=Молоко, priceRub=89.9)
3. на экране: ProductUi(title=Молоко, price=89.90 ₽)
сервер прислал пустоту: ProductDto(product_id=null, product_name=null, price_kopecks=null)
после маппера: Product(id=0, title=, priceRub=0.0)
экран не упал — потому что пустоту разобрали на границе
Три вещи, которые это даёт.
Имена полей сервера не расползаются по коду. product_name живёт ровно в одном классе. Переименовали на сервере — правится один маппер.
Пустота разбирается на границе. DTO допускает null в каждом поле, потому что сервер может прислать что угодно. Доменная модель — нет. После маппера остальной код работает с гарантированно заполненными полями, и проверять их больше негде.
Форматирование живёт в UI-модели. Цена превращается в «89.90 ₽» один раз, а не при каждой отрисовке карточки в списке — мы разбирали в главе про списки, почему это важно.
Три модели — не догма. Если DTO и доменная модель совпадают поле в поле, а форматирование сводится к одной строке, промежуточную UI-модель заводить не надо. Но DTO отдельно от доменной модели держите всегда: это единственная защита от того, что чужой формат данных расползётся по вашему коду.
Как это выглядит в проекте
Структура пакетов, которую не стыдно показать эксперту:
com.example.shop/
├── ui/
│ ├── catalog/ CatalogScreen.kt, CatalogViewModel.kt, CatalogUiState.kt
│ ├── cart/
│ └── theme/ Color.kt, Type.kt, Theme.kt
├── domain/
│ ├── model/ Product.kt, Order.kt
│ └── repository/ ProductRepository.kt ← интерфейс
└── data/
├── remote/ ProductApi.kt, ProductDto.kt
├── local/ ProductDao.kt, ProductEntity.kt
├── mapper/ ProductMappers.kt
└── repository/ ProductRepositoryImpl.kt ← реализация
Обратите внимание на несимметричность: интерфейс репозитория лежит в domain, а реализация — в data. Это не педантизм. domain объявляет, что ему нужно («дайте мне список товаров»), а data решает, откуда это взять. Стрелка зависимости остаётся направленной внутрь: data знает про domain, но не наоборот.
UseCase: когда нужен, а когда лишний
В строгой чистой архитектуре между ViewModel и репозиторием стоит ещё один слой — сценарии использования:
class GetDiscountedProductsUseCase(private val repo: ProductRepository) {
suspend operator fun invoke(): List<Product> =
repo.getProducts().filter { it.discount > 0 }.sortedBy { it.priceRub }
}
Он оправдан, когда логика повторяется на нескольких экранах или когда правило сложное само по себе: подсчёт итога корзины со скидками и доставкой, проверка права на покупку.
Он лишний, когда состоит из одной строки repo.getProducts(). Такой файл ничего не добавляет, кроме ещё одного места, куда надо заглянуть при отладке.
Требование задания — «минимальное разделение на три слоя». UseCase в этот минимум не входит. Заводите его там, где он снимает дублирование, и не заводите ради галочки.
Как собрать это без DI-фреймворка
Объекты нужно как-то создать и связать. Самый простой способ, который прекрасно работает и на чемпионате — значения по умолчанию в конструкторе:
class ProductRepositoryImpl(
private val api: ProductApi = ApiFactory.productApi,
) : ProductRepository { /* ... */ }
class CatalogViewModel(
private val repo: ProductRepository = ProductRepositoryImpl(),
) : ViewModel() { /* ... */ }
Одна строка поднимает всю цепочку: ViewModel создаёт репозиторий, репозиторий создаёт клиент API. В обычной работе ничего передавать не надо. А в тесте передаётся фейк — тем же параметром:
val viewModel = CatalogViewModel(repo = FakeRepository())
Никакой библиотеки для этого не требуется. Библиотеки внедрения зависимостей — Hilt, Koin — начинают окупаться, когда объектов много и часть из них обязана существовать в единственном экземпляре. До этого момента дефолтные параметры честно делают ту же работу и читаются без обучения.
Типичные ошибки
Retrofit прямо во ViewModel. Слои формально есть, но ui импортирует сетевую библиотеку — стрелка зависимости пошла в обратную сторону. По критериям это минус, а по сути — экран нельзя протестировать без сервера.
DTO уехал в UiState. На экране оказываются поля вроде product_name, а null из ответа сервера приходится проверять в разметке. Пустоту разбирают в маппере, а не в Composable.
Репозиторий возвращает Response<T> из Retrofit. Тип из сетевой библиотеки протёк через все слои наверх. Репозиторий обязан отдавать доменную модель или свой результат.
Файлы не в своих папках. Прямой штраф по критерию: минус за каждый файл.
Слои есть, а интерфейса нет. ViewModel зависит от ProductRepositoryImpl напрямую. Формально три папки, а подменить реализацию в тесте нельзя.
Логика в мапперe. Маппер переводит одну форму в другую и ничего больше. Фильтрация и подсчёты — в UseCase или репозитории.
Слова главы
ui— экраны,ViewModel, состояние экрана.domain— модели и правила предметной области; не зависит ни от кого.data— источники данных: сеть, база, хранилище.- Репозиторий — посредник, скрывающий, откуда пришли данные.
- DTO — модель в формате сервера, поля допускают
null. - Entity — модель в формате базы данных.
- Доменная модель — то, чем оперирует приложение.
- Маппер — перевод одной модели в другую.
UseCase— один сценарий; заводится, когда логика повторяется.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
interface ProductRepository { suspend fun getProducts(): List<Product> }
На чемпионате
Архитектура — первый модуль и один из самых весомых. В критериях он разложен по слоям, и каждый проверяется отдельно: «слой DOMAIN не содержит других слоёв», то же про PRESENTATION и DATA. Оценивают это в каждом спринте, а не один раз.
Что делать в первый час:
- Создать три пакета до первого файла. Перекладывать потом — терять баллы и время.
- Объявить интерфейсы репозиториев в
domain, реализации — вdata. - Начать с фейковых реализаций: экран собирается и работает на зашитых данных, пока сети ещё нет.
- Комментарий к каждому классу верхнего уровня — с описанием, датой и номером участника. По требованиям 2026 года это множитель на весь балл, а не отдельный аспект.
Отдельно про SOLID: в критериях он идёт своим аспектом, и буква D — «зависеть от абстракции, а не от реализации» — это ровно то, что делает интерфейс репозитория. Когда эксперт спрашивает, где у вас SOLID, показывать нужно сюда.
Что дальше
Слои расставлены, репозиторий отдаёт зашитый список. Следующая глава меняет его внутренности на настоящий сетевой запрос — и это будет тот самый переход от «Экран Главная» к «Экран Главная (Network)», ради которого всё и строилось.
Углубиться
- Чистая архитектура Роберта Мартина — первоисточник правила направления зависимостей.
- Многомодульность: те же слои, но отдельными Gradle-модулями — тогда правило проверяет компилятор, а не человек.
Resultи свои типы ошибок: как репозиторий сообщает о неудаче, не бросая исключение сквозь слои.- Единый источник истины: когда данных два источника — сеть и база — и репозиторий решает, кто из них главный.
- MapStruct и подобные генераторы мапперов: когда полей становится столько, что писать перевод руками дороже.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Архитектура приложения — developer.android.com — первоисточник по слоям и их обязанностям.
- Слой данных — репозитории, источники данных и правила их устройства.
- Слой предметной области — когда
UseCaseнужен, а когда лишний. - Now in Android — образцовое приложение Google, где эти слои разложены по модулям.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.