Перейти к основному содержимому
Мобилка29ГЛАВА 29Слои приложения: data, domain и репозиторийuidomaindataстрелки внутрь

Слои приложения: data, domain и репозиторий

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

В прошлой главе ViewModel получила состояние, но данные в примерах брались из воздуха. Пора ответить, откуда они приходят.

Самый короткий путь — сходить в сеть прямо из ViewModel. Он работает ровно один раз: пока приложение состоит из одного экрана и одного запроса. Дальше начинается знакомое: тот же запрос понадобился на втором экране, ответ надо кэшировать, сервер поменял название поля, а тестировать это невозможно, потому что для проверки нужен живой сервер.

Разделение на слои решает не «архитектурную красоту», а три конкретные задачи: где искать код, что менять при изменении сервера и как проверить работу без интернета.

Формулировка из конкурсного задания звучит буквально так: «Минимальное разделение на DOMAIN, PRESENTATION, DATA». Это первый и самый весомый критерий модуля архитектуры, а к нему привязан ещё и штраф — «минус за каждый файл, содержащийся вне соответствующей папки».

Три слоя и правило направления​

ЗАВИСИМОСТИ СМОТРЯТ В ОДНУ СТОРОНУuiэкраны, ViewModelзнает про domaindomainмодели и правилане знает ни про когоdataсеть, база, кэшзнает про domaindomain в середине и ни от кого не зависит: его можно читать, не открывая ни сеть, ни экраныесли Composable импортировал Retrofit — стрелка пошла в обратную сторону, слои сломаны«Минимальное разделение на DOMAIN, PRESENTATION, DATA» — формулировка из задания
ui и data знают про domain, domain не знает ни про кого — поэтому его можно читать отдельно от всего остального

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 не изменился ни на строку
ОДИН ЭКРАН, ДВА СПРИНТАViewModelне меняетсяProductRepositoryинтерфейс — шовFakeRepositoryспринт 1: зашитый списокNetworkRepositoryспринт 2: настоящий сервер«Экран Главная» и «Экран Главная (Network)» — это замена одной реализацииесли репозитория нет, второй спринт превращается в переписывание экрана
ViewModel зависит от интерфейса, поэтому подмена реализации не затрагивает ни её, ни экран

Это ровно та механика, по которой устроены конкурсные задания. В спринте идёт задача «Экран Главная», а следом отдельной карточкой — «Экран Главная (Network)». Если репозиторий стоит с самого начала, вторая задача сводится к замене одной реализации. Если его нет — второй спринт превращается в переписывание готового экрана под сроком.

Нажми на часть выражения
Отвечено вопросов: 0 из 2
Зачем нужен репозиторий, если данные пока зашиты прямо в коде?
Почему в репозитории сразу пишут suspend, даже когда ждать нечего?
Отвечено верно: 0 из 2

Три модели одного товара​

Второй вопрос, который возникает сразу: почему нельзя обойтись одним классом 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)
экран не упал — потому что пустоту разобрали на границе
ТРИ МОДЕЛИ ОДНОГО ТОВАРАProductDtoproduct_name: String?как прислал серверProducttitle: Stringкак думает приложениеProductUiprice: "89.90 ₽"как видит человекtoDomain()toUi()пустота с сервера разбирается в мапперe: null превращается в 0 и пустую строкудальше по коду поля уже не могут быть пустыми — проверять их больше негде и незачемпереименовали поле на сервере — правится один маппер, а не весь проект
DTO повторяет формат сервера, доменная модель удобна приложению, UI-модель готова к показу

Три вещи, которые это даёт.

Имена полей сервера не расползаются по коду. 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 в этот минимум не входит. Заводите его там, где он снимает дублирование, и не заводите ради галочки.

Отвечено вопросов: 0 из 2
Почему интерфейс репозитория лежит в domain, а реализация в data?
Когда UseCase не нужен?
Отвечено верно: 0 из 2

Как собрать это без 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. Оценивают это в каждом спринте, а не один раз.

Что делать в первый час:

  1. Создать три пакета до первого файла. Перекладывать потом — терять баллы и время.
  2. Объявить интерфейсы репозиториев в domain, реализации — в data.
  3. Начать с фейковых реализаций: экран собирается и работает на зашитых данных, пока сети ещё нет.
  4. Комментарий к каждому классу верхнего уровня — с описанием, датой и номером участника. По требованиям 2026 года это множитель на весь балл, а не отдельный аспект.

Отдельно про SOLID: в критериях он идёт своим аспектом, и буква D — «зависеть от абстракции, а не от реализации» — это ровно то, что делает интерфейс репозитория. Когда эксперт спрашивает, где у вас SOLID, показывать нужно сюда.

Отвечено вопросов: 0 из 2
Composable-функция импортирует Retrofit. Что не так?
Задача спринта: «Экран Каталога (Network)», экран уже сделан на зашитых данных. Что менять?
Отвечено верно: 0 из 2

Что дальше​

Слои расставлены, репозиторий отдаёт зашитый список. Следующая глава меняет его внутренности на настоящий сетевой запрос — и это будет тот самый переход от «Экран Главная» к «Экран Главная (Network)», ради которого всё и строилось.

Отвечено вопросов: 0 из 1
Зачем DTO и доменная модель — это два разных класса?
Отвечено верно: 0 из 1

Углубиться​

  • Чистая архитектура Роберта Мартина — первоисточник правила направления зависимостей.
  • Многомодульность: те же слои, но отдельными Gradle-модулями — тогда правило проверяет компилятор, а не человек.
  • Result и свои типы ошибок: как репозиторий сообщает о неудаче, не бросая исключение сквозь слои.
  • Единый источник истины: когда данных два источника — сеть и база — и репозиторий решает, кто из них главный.
  • MapStruct и подобные генераторы мапперов: когда полей становится столько, что писать перевод руками дороже.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоArchitecture: The data layer - MAD SkillsAndroid Developers
ВидеоArchitecture: The Domain Layer - MAD SkillsAndroid Developers
ВидеоThe Ultimate Beginner's Roadmap to Android App ArchitecturePhilipp Lackner
ВидеоDo You Have to Learn Clean Architecture as a Beginner?Philipp Lackner
ВидеоRepository Setup (MVVM Jetpack Compose)CodingWithMitch

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.