Налаштування Clean Architecture для Android-додатків
Android-проект без чіткої архітектури виглядає передбачуваним: Activity на 800 рядків, Retrofit-інтерфейс викликається прямо з onClick, Room DAO повертає LiveData<List<User>> напрямо у Fragment. Працює до першої вимоги: «додайте кеш», «напишіть тести», «виділіть спільний модуль для wear OS». Тоді виявляється, що всі склеєно намертво.
Clean Architecture вирішує це через інверсію залежностей: внутрішні шари не знають зовнішніх. Retrofit та Room можуть бути замінені без зміни бізнес-логіки.
Три шари та їх межі
Domain — ядро. Чистий Kotlin без Android-імпортів. Тут Entity-моделі, інтерфейси Repository, класи UseCase. Цей модуль компілюється в JVM-бібліотеку й тестується без емулятора.
Data — реалізації репозиторіїв. Retrofit DTO, Room Entity, маппінг DTO → Domain. UserRepositoryImpl реалізує UserRepository з Domain та знає про обидва джерела даних:
class UserRepositoryImpl @Inject constructor(
private val api: UserApi,
private val dao: UserDao,
private val mapper: UserMapper
) : UserRepository {
override fun getUser(id: String): Flow<User> = flow {
dao.getUser(id)?.let { emit(mapper.fromEntity(it)) }
try {
val remote = api.getUser(id)
dao.upsert(mapper.toEntity(remote))
emit(mapper.fromDto(remote))
} catch (e: HttpException) {
if (dao.getUser(id) == null) throw e
}
}
}
Стратегія: спочатку вертаємо кеш, паралельно оновлюємо з сервера. Якщо мережа впала, але кеш є — користувач не бачить помилку.
Presentation — ViewModel, UI (Compose або XML). Залежить тільки від Domain: викликає UseCase, отримує Flow, перетворює в UI-стан. Не знає, звідки дані — з Room або Retrofit.
UseCase: коли потрібен, коли ні
UseCase виправданий, коли:
- Оркеструє кілька репозиторіїв
- Містить нетривіальну бізнес-логіку
- Переиспользуется у кількох ViewModel
GetUserUseCase, який робить тільки return userRepository.getUser(id) — лишній шар. Якщо ViewModel працює з одним репозиторієм без логіки — інжектуємо репозиторій напрямо.
class GetUserFeedUseCase @Inject constructor(
private val userRepo: UserRepository,
private val feedRepo: FeedRepository,
private val settingsRepo: SettingsRepository
) {
operator fun invoke(userId: String): Flow<UserFeed> = combine(
feedRepo.getFeed(userId),
settingsRepo.getContentFilters()
) { feed, filters ->
feed.filter { filters.allows(it) }
}
}
Ось це — справжній UseCase: об'єднує три джерела, застосовує фільтрацію.
Multi-module: коли моноліт заважає
Для невеликого додатку три пакети в одному модулі — достатньо. Для великого проекту (5+ фіч, кілька команд) переходимо на multi-module:
:core:domain
:core:data
:feature:profile:domain (опціонально)
:feature:profile:presentation
:feature:feed:presentation
:app
Multi-module прискорює інкрементальну збірку: зміна у :feature:profile не пересобирає :feature:feed. Gradle api vs implementation між модулями — окрема тема налаштування.
Hilt + Clean Architecture
Hilt генерує Dagger-граф за анотаціями. @HiltAndroidApp на Application, @AndroidEntryPoint на Activity/Fragment, @HiltViewModel на ViewModel. Bindings між інтерфейсами Domain та реалізаціями Data:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
Помилка неправильного scope виявляється на етапі компіляції, не в runtime.
Тестування за шарами
| Шар | Інструменти | Залежність від Android |
|---|---|---|
| Domain UseCase | JUnit 5 + Mockk | Ні |
| Data Repository | JUnit 5 + Mockk + MockWebServer | Ні (мінімально з Room) |
| ViewModel | Turbine + Coroutines Test | Ні |
| UI | Espresso / Compose UI Test | Так (емулятор/пристрій) |
Більшість тестів запускаються на JVM — швидко й дешево.
Часті помилки при впровадженні
Domain-моделі з @Entity або @SerialName. Це витік Data-шару в Domain. Окремі DTO, окремий маппер.
UseCase з Context. Context — Android-залежність. UseCase у Domain не повинен її знати. Для рядків ресурсів — абстракція StringProvider у Domain з реалізацією в Presentation.
Flow у Domain з Android-типами. LiveData у Domain — порушення. Тільки kotlinx.coroutines.flow.Flow.
Що робимо при налаштуванні
Проектуємо модульну структуру під розмір проекту. Налаштовуємо Hilt з правильними scope. Реалізуємо першу фіч-модуль як зразок: UseCase + Repository + ViewModel + тести всіх шарів. Пишемо Gradle convention plugins для одноманітності конфігурації між модулями.
Терміни
Налаштування з нуля (однмодульний проект): 3–5 днів. Multi-module з нуля: 1–2 тижні. Міграція існуючого моноліту: 3–8 тижнів залежно від обсягу. Вартість розраховується після аудиту.







