Слои, репозиторий и единый источник истины

Вопрос про слои задают почти всем — но отличают кандидатов по тому, могут ли они объяснить, что именно ломается без разделения.

Repository — слой, который скрывает от остального приложения, откуда взялись данные: из сети, из базы или из кэша. Наружу он отдаёт доменные модели, а не DTO и не ответы HTTP.

Вопрос 1: «Зачем нужны слои и какие они бывают?»

Что проверяет интервьюер

Не умение нарисовать три прямоугольника, а понимание направления зависимостей и того, какую цену вы платите за слои.

Развёрнутый ответ

Стандартная для Android разбивка — три слоя:

СлойЧто внутриО чём знает
presentationActivity, Fragment, Composable, ViewModelо domain
domainмодели, use case, интерфейсы репозиториевни о ком — чистый Kotlin
dataRetrofit, Room, DTO, реализации репозиториево domain

Ключевая идея — зависимости направлены внутрь: и presentation, и data зависят от domain, а domain не зависит ни от кого. Интерфейс репозитория объявлен в domain, реализация лежит в data. Это и есть инверсия зависимостей, которая позволяет подменять реализацию в тестах и менять источник данных без правки бизнес-логики.

// domain: чистый Kotlin, без единого импорта Android
data class Article(val id: Long, val title: String, val isFavorite: Boolean)

interface ArticleRepository {
    fun observeArticles(): Flow<List<Article>>
    suspend fun refresh()
}

class ToggleFavoriteUseCase(private val repo: ArticleRepository) {
    suspend operator fun invoke(id: Long) { /* правило домена */ }
}
// data: реализация знает про Retrofit и Room, наружу отдаёт домен
class ArticleRepositoryImpl(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val io: CoroutineDispatcher
) : ArticleRepository {

    override fun observeArticles(): Flow<List<Article>> =
        dao.observeAll().map { entities -> entities.map { it.toDomain() } }

    override suspend fun refresh() = withContext(io) {
        val fresh = api.getArticles()
        dao.replaceAll(fresh.map { it.toEntity() })
    }
}

Честный ответ включает и цену: слои — это мапперы (DTO → Entity → Domain → UI-модель), больше файлов и больше кода. На маленьком приложении из трёх экранов полная Clean Architecture себя не оправдывает, и это нормально сказать вслух.

Вопрос 2: «Что такое single source of truth?»

Что проверяет интервьюер

Умение спроектировать поведение приложения без сети — самая частая практическая задача.

Развёрнутый ответ

Единый источник истины означает, что у каждого куска данных есть ровно одно место, откуда UI их читает. В Android почти всегда это локальная база: UI подписан на Room, а сеть только обновляет базу.

UI  ←подписка←  Room (источник истины)  ←запись←  Network

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

class FeedViewModel(repo: ArticleRepository) : ViewModel() {

    // читаем только из базы — единый источник истины
    val articles = repo.observeArticles()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    // сеть лишь обновляет базу, UI отреагирует сам
    fun refresh() = viewModelScope.launch { repo.refresh() }
}

Вопрос 3: «Как репозиторий должен сообщать об ошибке?»

Два рабочих варианта, и оба принимаются, если вы объясните выбор.

  1. Исключения. Просто и идиоматично для Kotlin, но ошибки становятся невидимыми в сигнатуре: по suspend fun load(): List<Article> нельзя понять, что она может упасть.
  2. Обёртка результата. Ошибка попадает в тип, компилятор заставляет её обработать.
sealed interface Result<out T> {
    data class Success<T>(val data: T) : Result<T>
    data class Failure(val error: AppError) : Result<Nothing>
}

// доменная ошибка вместо HTTP-кодов и IOException наверху
sealed interface AppError {
    data object NoNetwork : AppError
    data object Unauthorized : AppError
    data class Unknown(val cause: Throwable) : AppError
}

Важная деталь, за которую ставят плюс: наверх не должны утекать детали транспорта. HttpException, Response<T> из Retrofit и SQLiteException — это подробности слоя data. Репозиторий обязан превратить их в доменные ошибки, иначе ViewModel начнёт разбирать HTTP-коды, и вся идея слоёв рассыпается.

Типичные ошибки кандидатов

  • Репозиторий возвращает Response<UserDto> — то есть слой data «протёк» в presentation.
  • Бизнес-правила живут во ViewModel, из-за чего их нельзя переиспользовать на другом экране.
  • Два источника истины: список читается из сети, детальный экран — из базы; данные расходятся.
  • Use case на каждый вызов ради галочки: GetUserUseCase, который просто делегирует репозиторию, ничего не добавляя. Use case оправдан, когда содержит правило или объединяет несколько источников.
  • Не могут назвать минусы слоёв — это выглядит как заученный ответ.

Как ответить кратко (20–30 секунд)

«Три слоя: presentation, domain, data. Зависимости идут внутрь — domain это чистый Kotlin без Android, интерфейс репозитория объявлен в нём, реализация лежит в data. Это даёт подмену реализаций в тестах и независимость бизнес-логики от Retrofit и Room. Репозиторий скрывает источник данных и отдаёт доменные модели, а не DTO и не Response. Источник истины делаю один — обычно база: UI подписан на Room, сеть только пишет в базу, поэтому приложение работает офлайн и не рассинхронизируется. Ошибки конвертирую в доменные типы через sealed-иерархию, чтобы наверх не текли HTTP-коды. Цена слоёв — мапперы и лишний код: на трёхэкранном приложении это оверинжиниринг.»

Проверьте себя
1. В какой слой должен быть объявлен интерфейс ArticleRepository при Clean Architecture?
Aв data, рядом с реализацией
Bв domain, чтобы зависимости были направлены внутрь
Cв presentation, ведь его использует ViewModel
Dв отдельном модуле di
2. Что означает принцип single source of truth в мобильном приложении?
Aвсе данные загружаются только с сервера, кэш не используется
Bв приложении должен быть ровно один репозиторий
CUI читает данные из одного места (обычно локальной БД), а сеть только обновляет это место
Dвсе ViewModel должны наследоваться от общего базового класса
3. Репозиторий возвращает во ViewModel retrofit2.Response<UserDto>. Что здесь плохо?
AResponse нельзя использовать в suspend-функциях
Bдетали транспортного слоя протекают в presentation, и ViewModel начинает разбирать HTTP-коды
CResponse не поддерживает сериализацию в Bundle
Dничего, это стандартный подход