Clean Architecture — багатошарова архітектура, запропонована Робертом Мартіном (Uncle Bob) у 2012 році, яка розділяє застосунок на незалежні шари: Domain (Entities, Use Cases), Data (Repositories, DataSources) і Presentation (ViewModels, Views). Головний принцип — Dependency Rule: залежності спрямовані всередину, зовнішні шари залежать від внутрішніх, але не навпаки. Clean Architecture застосовується в мобільній розробці для проєктів з високою складністю бізнес-логіки. Докладніше — у книзі The Clean Architecture.
Головне
Clean Architecture — архітектурний патерн, сформульований Робертом Мартіном (Uncle Bob) у 2012 році. Основна ідея — розділення застосунку на шари із жорстким правилом залежностей: код всередині шару не знає про код ззовні. Зовнішні шари (UI, фреймворки, БД) — деталі реалізації. Внутрішні шари (бізнес-логіка, правила підприємства) — суть застосунку.
Шари Clean Architecture в мобільній розробці: 1) Domain — Entities (бізнес-об'єкти) та Use Cases (сценарії використання); 2) Data — RepositoryImpl (реалізації репозиторіїв), DataSources (мережа, БД, кеш); 3) Presentation — ViewModels, Views (Compose/SwiftUI). Domain — найвнутрішніший шар, що не має залежностей. Data залежить від Domain (реалізує інтерфейси репозиторіїв). Presentation залежить від Domain (викликає Use Cases, підписується на результати).
| Шар | Містить | Залежності |
|---|---|---|
| Domain | Entities, Use Cases, Repository Interfaces | Немає (чистий Kotlin/Swift) |
| Data | RepositoryImpl, DataSources (API, БД, Кеш) | Domain, Retrofit, Room, Ktor |
| Presentation | ViewModels, Views, Composables | Domain, Jetpack, SwiftUI |
Dependency Rule — єдине жорстке правило Clean Architecture. Вихідний код може посилатися лише на шар усередині себе або на шар нижче (ближче до центру). Presentation імпортує Domain. Domain НЕ імпортує Data або Presentation. Це досягається через інверсію залежностей (Dependency Inversion Principle): Domain визначає інтерфейс Repository, Data реалізує його. Presentation залежить від абстракції UseCase, а не від конкретного репозиторію.
Domain — найстабільніший шар застосунку. Entities — бізнес-об'єкти, незалежні від фреймворків: User, Product, Order. Use Cases — класи з одним методом invoke (або operator fun invoke у Kotlin), що реалізують один сценарій: GetUserUseCase, PlaceOrderUseCase, CalculateTotalUseCase. Repository Interfaces — абстракції доступу до даних, що визначаються в Domain та реалізуються в Data. Domain не містить Android SDK, iOS UIKit, Retrofit, Room — лише чистий Kotlin або Swift.
// Entity — бізнес-об'єкт (Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — абстракція даних (Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — один сценарій (Domain)
final class GetUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: Int) async throws -> User {
return try await repository.getUser(id: id)
}
}
Use Case — «клас з одним методом» — не догма, а практична рекомендація. Коли Use Case стає складнішим (валідація + логування + виклик репозиторію), його методи групуються за змістом: UserUseCase.getUser, UserUseCase.searchUsers, UserUseCase.deleteUser. Головне — Use Case не повинен знати, звідки приходять дані (мережа, БД, кеш) і хто їх відображає (Compose, SwiftUI). В IT Sectr ми виділяємо Use Case для кожної операції, яка має бізнес-правило, перевірку або комбінування даних із двох джерел.
Чистота Domain досягається через DTO-маппінг на межі шарів. Data шар отримує JSON-моделі (DTO), маппить їх у Domain Entity. Presentation отримує Domain Entity, маппить у ViewModel (DisplayItem). Domain Entity ніколи не містить анотацій Retrofit, Room, Codable — це гарантує, що шар не доведеться змінювати при зміні БД з Room на Realm або при заміні Retrofit на Ktor.
Data Layer — реалізація інтерфейсів, визначених у Domain. Містить RepositoryImpl (класи, що реалізують UserRepository) та DataSources (RemoteDataSource — API, LocalDataSource — БД, CacheDataSource — SharedPreferences/NSUserDefaults). Data шар залежить від Domain (імпортує інтерфейси репозиторіїв та Entities) та від фреймворків (Retrofit, Room, Ktor, CoreData). RepositoryImpl приховує від Domain джерело даних — Use Case не знає, чи прийшли дані з мережі чи кешу.
// DTO — модель для мережі (Data)
data class UserDto(
@SerializedName("id") val id: Int,
@SerializedName("first_name") val firstName: String,
@SerializedName("last_name") val lastName: String,
@SerializedName("email") val email: String
)
// RepositoryImpl — реалізація (Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// Намагаємося дістати з кешу
localDataSource.getUser(id)?.let { return it.toDomain() }
// Якщо ні — завантажуємо з мережі
val dto = remoteDataSource.fetchUser(id)
val user = dto.toDomain()
localDataSource.saveUser(user)
return user
}
override suspend fun getUsers(): List<User> {
return remoteDataSource.fetchAllUsers().map { it.toDomain() }
}
}
// Mapper — перетворення DTO ↔ Domain
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Стратегія кешування в Data Layer: RepositoryImpl спочатку перевіряє локальне сховище; за відсутності даних — завантажує з мережі та зберігає локально. Якщо мережа недоступна — повертає застарілі дані з позначкою isStale. Use Case в Domain не знає про стратегію — він отримує User через Repository.getUser(id). Зміна стратегії (наприклад, інвалідація кешу кожні 15 хвилин) не зачіпає Domain та Presentation.
Модульність в Android — Kotlin Multiplatform дозволяє винести Domain в окремий KMP-модуль без залежностей від Android SDK. Data — окремий модуль із залежністю від Domain. Presentation — Android-модуль із залежністю від Domain. Gradle залежності: domain (pure Kotlin), data (domain + Retrofit + Room), app (domain + presentation + Hilt). Така модульність обов'язкова для великих проєктів — CI збирає Domain окремо, unit-тести Domain не потребують Android-емулятора.
Presentation Layer — найзовнішніший шар Clean Architecture. Містить ViewModels (Android) / ObservableObject (iOS) та Views (Compose/SwiftUI). ViewModel викликає Use Case, отримує результат і перетворює в UI-стан (State). View підписується на State та відображає. Presentation залежить від Domain — імпортує Use Cases та Entities. Presentation не імпортує Data Layer — дані приходять через Use Case, який всередині використовує Repository.
ViewModel в Clean Architecture не містить бізнес-логіки — вона викликає Use Case. Якщо Use Case повертає User, ViewModel перетворює його в UserDisplayItem (name, emailFormatted, avatarUrl) — чисто презентаційну модель. Use Case не знає про DisplayItem — він повертає Entity. Це розділення дозволяє тестувати Use Case без UI та ViewModel без UseCase (через mock). В IT Sectr ми суворо дотримуємося: Use Case — бізнес-логіка, ViewModel — тільки презентація, View — тільки відображення.
// Use Case (Domain) — чиста бізнес-логіка
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — тільки презентація
class UserViewModel(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow<UserScreenState>(UserScreenState.Loading)
val state: StateFlow<UserScreenState> = _state.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_state.value = UserScreenState.Loading
val user = getUserUseCase(id)
val displayItem = UserDisplayItem(
name = user.name,
email = user.email,
initials = user.name.split(" ").joinToString("") { it.first().toString() }
)
_state.value = UserScreenState.Success(displayItem)
}
}
}
data class UserDisplayItem(
val name: String,
val email: String,
val initials: String
)
sealed interface UserScreenState {
data object Loading : UserScreenState
data class Success(val displayItem: UserDisplayItem) : UserScreenState
data class Error(val message: String) : UserScreenState
}
Navigation в Presentation шарі — теж частина зовнішнього кільця. Clean Architecture не приписує механізм навігації — це може бути NavController (Compose), NavigationStack (SwiftUI), Coordinator (UIKit) або Router (VIPER). Важливо: рішення про навігацію приймає Presentation, але навігація не повинна проникати в Use Case. Use Case повертає результат, ViewModel вирішує, на який екран перейти. В Clean Architecture навігація — деталь, яку можна замінити без зміни Domain.
Clean Architecture на Android реалізується через модулі Gradle: domain (pure Kotlin), data (domain + Retrofit + Room), presentation (domain + Compose). Структура папок: domain/user/User.kt, GetUserUseCase.kt, UserRepository.kt; data/remote/UserRemoteDataSource.kt, local/UserDao.kt, repository/UserRepositoryImpl.kt; presentation/ui/user/UserViewModel.kt, UserScreen.kt. DI (Hilt) зв'язує шари: UserRepositoryImpl прив'язується до інтерфейсу UserRepository в domain-модулі.
Clean Architecture на iOS використовує SPM або Xcode групи без окремих модулів (через обмеження Xcode). Domain — папка з файлами, що не імпортують UIKit або SwiftUI. Data — папка з APIClient, CoreDataStack, RepositoryImpl. Presentation — папка з ViewModels та SwiftUI Views. DI через конструктор або збірку в App. Основний виклик — async/await через UseCase.execute() з перевіркою на MainActor для UI-оновлень.
// Data Layer: Remote DataSource (iOS)
final class UserRemoteDataSource {
private let apiClient: APIClient
func fetchUser(id: Int) async throws -> UserDTO {
return try await apiClient.get("/users/\(id)")
}
}
// Repository Implementation (Data)
final class UserRepositoryImpl: UserRepository {
private let remote: UserRemoteDataSource
private let local: UserLocalDataSource
func getUser(id: Int) async throws -> User {
if let cached = try await local.getUser(id) {
return cached
}
let dto = try await remote.fetchUser(id)
let user = dto.toDomain()
try await local.saveUser(user)
return user
}
}
// Presentation: ViewModel + SwiftUI View
@MainActor
final class UserViewModel: ObservableObject {
@Published private(set) var state: UserScreenState = .loading
private let getUserUseCase: GetUserUseCase
func loadUser(id: Int) {
Task {
state = .loading
if let user = try? await getUserUseCase.execute(id: id) {
state = .success(user)
} else {
state = .error("Failed to load")
}
}
}
}
Clean Architecture в проєктах IT Sectr — наш стандарт для проєктів від 30 днів. Ми використовуємо трьохшарову архітектуру з Kotlin Multiplatform для Android/iOS з 2022 року. Domain — спільний KMP-модуль, Data — платформені модулі (Retrofit на Android, URLSession на iOS), Presentation — нативні UI. Це дає 60–80% спільного коду бізнес-логіки між iOS та Android, скорочуючи час розробки на 30–40% порівняно з двома окремими реалізаціями.
Часто задавані питання
Мінімально три: Domain, Data, Presentation. Для великих проєктів додають Framework (Android SDK/iOS UIKit-залежності) та Device (GPS, камера, датчики). Кількість шарів — не жорстке правило, а питання зручності. Головне — дотримуватися Dependency Rule: залежності спрямовані всередину, до Domain. Можна почати з трьох і додати шари по мірі зростання проєкту.
Так — на 30–50% порівняно з MVVM за рахунок виділення інтерфейсів репозиторіїв, Use Cases та мапперів. Для простого CRUD-застосунку це надлишково. Clean Architecture виправданий для проєктів зі складною бізнес-логікою, де тестованість та ізоляція шарів важливіші за швидкість розробки. Для MVP або прототипу використовуйте MVVM — Clean Architecture уповільнить запуск.
Так, це поширена практика. Use Cases залишаються в Domain, а Presentation використовує MVI-цикл (Intent → Reducer → State). Data Layer — той самий, Domain — той самий. MVI в Presentation дає передбачуваний стан екрану, Clean Architecture — ізоляцію бізнес-логіки. Така комбінація використовується у великих проєктах з десятками розробників.
Use Case потрібен, коли операція включає бізнес-правило: валідацію, комбінування даних із двох джерел, розрахунок, логування, перевірку прав доступу. Простий запит getUser(id) без додаткової логіки може викликати Repository напряму з ViewModel. Однак для єдинообразності архітектури багато команд створюють Use Case для кожного публічного методу Repository — це додає 5–10% коду, але спрощує читання.
Domain: Unit-тести Use Cases з mock Repository — чистий Kotlin/Swift без Android SDK. Data: інтеграційні тести RepositoryImpl з mock/fake DataSource. Presentation: тести ViewModel з mock UseCase. Завдяки Dependency Rule кожен шар тестується ізольовано. В IT Sectr покриття Domain сягає 95%, Data — 70–80%, Presentation — 60–70%.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також