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. године. Саветоваћемо вас и предложити најбоље решење.
Прочитајте такође