Слои, репозиторий и единый источник истины
Вопрос про слои задают почти всем — но отличают кандидатов по тому, могут ли они объяснить, что именно ломается без разделения.
Repository — слой, который скрывает от остального приложения, откуда взялись данные: из сети, из базы или из кэша. Наружу он отдаёт доменные модели, а не DTO и не ответы HTTP.
Вопрос 1: «Зачем нужны слои и какие они бывают?»
Что проверяет интервьюер
Не умение нарисовать три прямоугольника, а понимание направления зависимостей и того, какую цену вы платите за слои.
Развёрнутый ответ
Стандартная для Android разбивка — три слоя:
| Слой | Что внутри | О чём знает |
presentation | Activity, Fragment, Composable, ViewModel | о domain |
domain | модели, use case, интерфейсы репозиториев | ни о ком — чистый Kotlin |
data | Retrofit, 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: «Как репозиторий должен сообщать об ошибке?»
Два рабочих варианта, и оба принимаются, если вы объясните выбор.
- Исключения. Просто и идиоматично для Kotlin, но ошибки становятся невидимыми в сигнатуре: по
suspend fun load(): List<Article>нельзя понять, что она может упасть. - Обёртка результата. Ошибка попадает в тип, компилятор заставляет её обработать.
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-коды. Цена слоёв — мапперы и лишний код: на трёхэкранном приложении это оверинжиниринг.»