DimaSOS

Архитектура

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, интерфейсы репозиториевни о чём
dataAPI, DAO, DTO, мапперы, реализации репозиториевdomain
presentationViewModel, состояние экрана, Composedomain
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 принимает UserApipresentation знает про data
DTO с сервера дошёл до Composeнет мапперов, формат API протёк в UI
Context в domain-классебизнес-логика привязана к Android
Тест домена требует эмуляторав domain попали Android-зависимости
Use case только проксирует вызовлишний слой, убрать
Фабрика без ветвленийлишняя абстракция
Тесты падают набором, но проходят по одномуизменяемое состояние в синглтоне