Архитектура
Clean Architecture на практике: границы слоёв, направление зависимостей, и три паттерна, которые в Android-коде встречаются чаще всего.
#Clean Architecture: суть
Единственное правило, из которого выводится всё остальное: зависимости направлены внутрь. Внешние слои знают о внутренних, внутренние о внешних — нет.
presentation ──▶ domain ◀── data
(UI, VM) (бизнес) (сеть, БД)
domain не зависит НИ ОТ ЧЕГО:
ни от Android SDK, ни от Retrofit, ни от Room
Обрати внимание на направление: data зависит от domain, а не наоборот. Это и есть инверсия зависимостей.
// domain: интерфейс объявлен здесь, реализации нет
interface UserRepository {
suspend fun user(id: String): User
}
// data: реализация зависит от domain, не наоборот
class UserRepositoryImpl(
private val api: UserApi,
private val dao: UserDao,
) : UserRepository {
override suspend fun user(id: String): User =
dao.find(id)?.toDomain() ?: api.user(id).toDomain().also { dao.save(it.toEntity()) }
}
Интерфейс живёт в том слое, который его использует, а реализация — в том, который знает детали. Это позволяет заменить сеть на фейк без правки бизнес-логики.
Практический признак, что границы держатся: в модуле domain нет ни одного import android.* и ни одной зависимости на Retrofit/Room в build.gradle.kts.
// у каждого слоя своя модель, между ними мапперы
data class UserDto(val user_name: String, val ava: String?) // data (сеть)
data class UserEntity(val id: String, val name: String) // data (БД)
data class User(val id: String, val name: String) // domain
data class UserUi(val title: String, val subtitle: String) // presentation
Соблазн переиспользовать DTO во всех слоях велик, но тогда изменение формата ответа сервера протекает в UI. Мапперы — плата за возможность менять слои независимо.
На маленьком проекте это оверхед. Разумный компромисс: разделять модели там, где формат внешнего источника реально отличается от нужного бизнесу.
| Слой | Что внутри | Знает о |
|---|---|---|
domain | модели, use case, интерфейсы репозиториев | ни о чём |
data | API, DAO, DTO, мапперы, реализации репозиториев | domain |
presentation | ViewModel, состояние экрана, Compose | domain |
app | сборка графа DI, навигация | обо всех |
#Use case
class GetActiveUsers(private val repo: UserRepository) {
suspend operator fun invoke(): List<User> =
repo.users().filter { it.isActive }.sortedBy { it.name }
}
// вызов выглядит как вызов функции
val users = getActiveUsers()
operator fun invoke — конвенция для use case из одного действия.
// антипаттерн: use case, который ничего не делает
class GetUsers(private val repo: UserRepository) {
suspend operator fun invoke() = repo.users() // просто проксирует
}
Если use case только пробрасывает вызов — он не нужен, ViewModel может обратиться к репозиторию напрямую. Use case оправдан, когда есть логика: комбинирование источников, правила, валидация.
#DI и владение объектами
Внедрение зависимостей — это то, где паттерны из следующих разделов встречаются в реальном коде: DI-контейнер и есть фабрика с управлением временем жизни.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton // один на всё приложение
fun okHttp(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor())
.build()
@Provides
@Singleton
fun api(client: OkHttpClient): UserApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_URL)
.client(client)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
.create(UserApi::class.java)
}
Здесь сразу три паттерна: Builder у OkHttpClient и Retrofit, Factory в addConverterFactory и create, Singleton — через область видимости контейнера.
@HiltViewModel
class UsersViewModel @Inject constructor(
private val getActiveUsers: GetActiveUsers,
) : ViewModel()
ViewModel получает use case, а не репозиторий и не API. Так presentation не знает про слой data вовсе.
// без DI-фреймворка: ручная фабрика ViewModel
class UsersViewModelFactory(
private val repo: UserRepository,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass == UsersViewModel::class.java)
@Suppress("UNCHECKED_CAST")
return UsersViewModel(repo) as T
}
}
ViewModelProvider.Factory — самый частый случай, где приходится писать фабрику руками: ViewModel создаёт фреймворк, а конструктор с аргументами ему неизвестен.
#Builder
Пошаговая сборка объекта с большим числом необязательных параметров. В Java был обязателен; в Kotlin у него появился сильный конкурент — именованные аргументы со значениями по умолчанию.
// проблема, которую решает Builder: конструктор на 8 аргументов
val client = OkHttpClient(15, 30, 30, null, listOf(), true, false, null)
// что здесь что?
Телескопические конструкторы нечитаемы и легко перепутать местами два параметра одного типа.
val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.addInterceptor(AuthInterceptor(tokenStore))
.build()
Каждый шаг назван, порядок свободный, необязательное можно опустить. Плюс объект получается неизменяемым: мутируется билдер, а не результат.
class Request private constructor(
val url: String,
val method: String,
val headers: Map<String, String>,
val timeoutMs: Long,
) {
class Builder(private val url: String) {
private var method: String = "GET"
private val headers = mutableMapOf<String, String>()
private var timeoutMs: Long = 15_000
fun method(m: String) = apply { method = m }
fun header(k: String, v: String) = apply { headers[k] = v }
fun timeout(ms: Long) = apply { timeoutMs = ms }
fun build(): Request {
require(url.startsWith("http")) { "url должен быть абсолютным" }
return Request(url, method, headers.toMap(), timeoutMs)
}
}
}
Своя реализация. Три важные детали: private constructor (создать можно только через билдер), apply для цепочки, и валидация в build() — объект не может существовать в невалидном состоянии.
// в Kotlin то же самое часто решается без билдера
data class Request(
val url: String,
val method: String = "GET",
val headers: Map<String, String> = emptyMap(),
val timeoutMs: Long = 15_000,
)
val r = Request(url = "https://api.example.com", timeoutMs = 30_000)
// и «изменение» — через copy
val retry = r.copy(timeoutMs = 60_000)
Именованные аргументы + copy покрывают большинство случаев короче и без лишнего класса.
// DSL-билдер — идиоматичный Kotlin-вариант
fun request(url: String, block: RequestSpec.() -> Unit = {}): Request =
RequestSpec(url).apply(block).build()
val r = request("https://api.example.com") {
method = "POST"
header("X-Trace", traceId)
timeoutMs = 30_000
}
Function-with-receiver вместо цепочки. Так устроены Gradle Kotlin DSL, buildString, конфигурация Ktor.
| Брать Builder | Обойтись без него |
|---|---|
| Много необязательных полей + валидация в конце | 3–4 поля с дефолтами → именованные аргументы |
| Публичное API библиотеки, нужна совместимость | внутренний класс приложения |
| Объект собирается в нескольких местах по частям | собирается целиком в одном месте |
| Java-совместимость обязательна | код только на Kotlin → data class + copy |
#Factory
Отделяет решение «какой объект создать» от места, где он используется. Вызывающий получает интерфейс и не знает конкретный класс.
// Factory Method: подтип выбирается по входным данным
interface Payment { fun pay(amount: Long) }
object PaymentFactory {
fun create(type: PaymentType): Payment = when (type) {
PaymentType.CARD -> CardPayment(gateway)
PaymentType.SBP -> SbpPayment(bankApi)
PaymentType.WALLET -> WalletPayment(walletSdk)
}
}
val payment = PaymentFactory.create(userChoice)
payment.pay(1_500) // вызывающий не знает, что именно внутри
Добавление нового способа оплаты меняет один when, а не десяток мест вызова.
// в Kotlin роль фабрики часто играет companion object
class Money private constructor(val minorUnits: Long, val currency: String) {
companion object {
fun rubles(rub: Long) = Money(rub * 100, "RUB")
fun fromMinor(units: Long, currency: String) = Money(units, currency)
}
}
Money.rubles(150)
Именованные фабричные методы вместо конструктора: читается лучше и позволяет иметь несколько «конструкторов» с одинаковой сигнатурой.
// Abstract Factory: семейство связанных объектов
interface ThemeFactory {
fun colors(): ColorScheme
fun typography(): Typography
}
class DarkThemeFactory : ThemeFactory { /* ... */ }
class LightThemeFactory : ThemeFactory { /* ... */ }
Когда объекты должны быть согласованы между собой — нельзя смешать тёмные цвета со светлой типографикой.
// где Factory встречается в Android
ViewModelProvider.Factory // создание ViewModel с аргументами
WorkerFactory // создание Worker для WorkManager
Retrofit.create(Api::class.java) // динамический прокси по интерфейсу
LayoutInflater.Factory2 // подмена View при инфляции
ThreadFactory // именование потоков в пуле
Общее у всех: объект создаёт фреймворк, а вам надо повлиять на то, как именно.
// антипаттерн: фабрика ради фабрики
class UserFactory {
fun create(name: String) = User(name) // ничего не решает
}
Если нет ветвления, нет сокрытия реализации и нет внешнего фреймворка — фабрика лишняя, конструктор справится.
#Singleton
Один экземпляр на приложение. Самый простой в написании и самый опасный в последствиях: глобальное состояние разрушает тестируемость.
object AppConfig {
val baseUrl: String = BuildConfig.API_URL
val isDebug: Boolean = BuildConfig.DEBUG
}
object в Kotlin — синглтон из коробки, потокобезопасный, ленивый. Годится для того, что не имеет изменяемого состояния и зависимостей.
// синглтон с ленивой инициализацией и параметром
class Database private constructor(context: Context) {
companion object {
@Volatile private var instance: Database? = null
fun get(context: Context): Database =
instance ?: synchronized(this) {
instance ?: Database(context.applicationContext).also { instance = it }
}
}
}
Double-checked locking. @Volatile обязателен, иначе другой поток может увидеть недоинициализированный объект.
context.applicationContext, а не context: синглтон, удерживающий Activity, — гарантированная утечка всего экрана.
// предпочтительно: синглтон как область видимости DI, а не как класс
@Provides
@Singleton
fun database(@ApplicationContext ctx: Context): Database = Room
.databaseBuilder(ctx, Database::class.java, "app.db")
.build()
Класс остаётся обычным, единственность обеспечивает контейнер. В тесте подставляется in-memory реализация — с object так не выйдет.
Это главный практический аргумент: @Singleton-провайдер даёт то же поведение, но не запаивает глобальное состояние в код.
// чем плох object с состоянием
object SessionManager {
var token: String? = null // тесты начинают влиять друг на друга
}
// тест A залогинил пользователя, тест B неожиданно тоже авторизован
// порядок тестов начинает менять результат
Изменяемое состояние в синглтоне — источник «флаки, которые проходят по одному, но падают набором». Тот же класс проблем, что и статические переменные.
// если без глобального состояния никак — дать способ сбросить
object SessionManager {
private val _token = MutableStateFlow<String?>(null)
val token: StateFlow<String?> = _token.asStateFlow()
fun set(value: String?) { _token.value = value }
@VisibleForTesting
fun reset() { _token.value = null }
}
Компромисс: явный reset для @Before в тестах.
| Случай | Решение |
|---|---|
| Константы, чистые функции | object — нормально |
| Тяжёлый ресурс: БД, HTTP-клиент | @Singleton в DI, класс обычный |
| Изменяемое состояние сессии | держать в DI-scope, дать reset для тестов |
Нужен Context | только applicationContext |
| Кеш, живущий по времени экрана | не синглтон — scope экрана |
#Признаки, что границы поехали
# grep, который быстро показывает протечки слоёв
grep -rn "import android\." domain/src/main/kotlin | head
grep -rn "import retrofit2\|import androidx.room" domain/ | head
grep -rn "import .*data\." presentation/src/main/kotlin | head
Если что-то нашлось — слой знает лишнее. На модульном проекте это лучше вынести в проверку сборки, чтобы ломалось само.
| Симптом | Что нарушено |
|---|---|
domain зависит от Retrofit или Room | инверсия зависимостей |
ViewModel принимает UserApi | presentation знает про data |
| DTO с сервера дошёл до Compose | нет мапперов, формат API протёк в UI |
Context в domain-классе | бизнес-логика привязана к Android |
| Тест домена требует эмулятора | в domain попали Android-зависимости |
| Use case только проксирует вызов | лишний слой, убрать |
| Фабрика без ветвлений | лишняя абстракция |
| Тесты падают набором, но проходят по одному | изменяемое состояние в синглтоне |