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, DB, Cache) | 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 чрез конструктор или assembly в 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 г. Ще ви консултираме и ще предложим най-доброто решение.
Прочетете също