Clean Architecture — a layered architecture proposed by Robert Martin (Uncle Bob) in 2012, dividing an application into independent layers: Domain (Entities, Use Cases), Data (Repositories, DataSources), and Presentation (ViewModels, Views). The main principle is the Dependency Rule: dependencies point inward, outer layers depend on inner ones, but not vice versa. Clean Architecture is used in mobile development for projects with high business logic complexity. More details in the book The Clean Architecture.
Key Takeaways
Clean Architecture — an architectural pattern formulated by Robert Martin (Uncle Bob) in 2012. The core idea is dividing an application into layers with a strict dependency rule: code inside a layer knows nothing about code outside. Outer layers (UI, frameworks, DB) are implementation details. Inner layers (business logic, enterprise rules) are the essence of the application.
Clean Architecture Layers in mobile development: 1) Domain — Entities (business objects) and Use Cases (usage scenarios); 2) Data — RepositoryImpl (repository implementations), DataSources (network, DB, cache); 3) Presentation — ViewModels, Views (Compose/SwiftUI). Domain is the innermost layer with no dependencies. Data depends on Domain (implements repository interfaces). Presentation depends on Domain (calls Use Cases, subscribes to results).
| Layer | Contains | Dependencies |
|---|---|---|
| Domain | Entities, Use Cases, Repository Interfaces | None (pure Kotlin/Swift) |
| Data | RepositoryImpl, DataSources (API, DB, Cache) | Domain, Retrofit, Room, Ktor |
| Presentation | ViewModels, Views, Composables | Domain, Jetpack, SwiftUI |
Dependency Rule — the only strict rule of Clean Architecture. Source code can only reference a layer inside itself or a layer below (closer to the center). Presentation imports Domain. Domain does NOT import Data or Presentation. This is achieved through Dependency Inversion Principle: Domain defines the Repository interface, Data implements it. Presentation depends on the UseCase abstraction, not on a specific repository.
Domain — the most stable layer of the application. Entities are business objects independent of frameworks: User, Product, Order. Use Cases are classes with a single invoke method (or operator fun invoke in Kotlin), implementing one scenario: GetUserUseCase, PlaceOrderUseCase, CalculateTotalUseCase. Repository Interfaces are data access abstractions defined in Domain, implemented in Data. Domain contains no Android SDK, iOS UIKit, Retrofit, Room — only pure Kotlin or Swift.
// Entity — business object (Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — data abstraction (Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — one scenario (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 — «a class with one method» is not a dogma but a practical recommendation. When a Use Case becomes more complex (validation + logging + repository call), its methods are grouped by meaning: UserUseCase.getUser, UserUseCase.searchUsers, UserUseCase.deleteUser. The main thing is that a Use Case should not know where data comes from (network, DB, cache) or who displays it (Compose, SwiftUI). At IT Sectr we allocate a Use Case for every operation that has a business rule, validation, or data combination from two sources.
Domain Purity is achieved through DTO mapping at layer boundaries. The Data layer receives JSON models (DTOs), maps them to Domain Entities. Presentation receives Domain Entities, maps them to ViewModels (DisplayItem). A Domain Entity never contains Retrofit, Room, or Codable annotations — this guarantees the layer will not need to change when switching DB from Room to Realm or replacing Retrofit with Ktor.
Data Layer — implementation of interfaces defined in Domain. Contains RepositoryImpl (classes implementing UserRepository) and DataSources (RemoteDataSource — API, LocalDataSource — DB, CacheDataSource — SharedPreferences/NSUserDefaults). The Data layer depends on Domain (imports repository interfaces and Entities) and on frameworks (Retrofit, Room, Ktor, CoreData). RepositoryImpl hides the data source from Domain — the Use Case does not know whether the data came from the network or cache.
// DTO — model for network (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 — implementation (Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// Trying to get from cache
localDataSource.getUser(id)?.let { return it.toDomain() }
// If not — load from network
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 conversion
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Caching Strategy in Data Layer: RepositoryImpl first checks the local storage; if no data is found, it loads from the network and saves locally. If the network is unavailable, it returns stale data with an isStale flag. The Use Case in Domain does not know about the strategy — it receives User via Repository.getUser(id). Changing the strategy (e.g., cache invalidation every 15 minutes) does not affect Domain or Presentation.
Modularity in Android — Kotlin Multiplatform allows extracting Domain into a separate KMP module without Android SDK dependencies. Data is a separate module with a dependency on Domain. Presentation is an Android module with a dependency on Domain. Gradle dependencies: domain (pure Kotlin), data (domain + Retrofit + Room), app (domain + presentation + Hilt). Such modularity is essential for large projects — CI builds Domain separately, Domain unit tests do not require an Android emulator.
Presentation Layer — the outermost layer of Clean Architecture. Contains ViewModels (Android) / ObservableObject (iOS) and Views (Compose/SwiftUI). The ViewModel calls a Use Case, receives the result, and transforms it into UI state. The View subscribes to the State and renders it. Presentation depends on Domain — it imports Use Cases and Entities. Presentation does not import the Data Layer — data comes through the Use Case, which internally uses a Repository.
ViewModel in Clean Architecture contains no business logic — it calls the Use Case. If a Use Case returns User, the ViewModel transforms it into UserDisplayItem (name, emailFormatted, avatarUrl) — a purely presentation model. The Use Case does not know about DisplayItem — it returns an Entity. This separation allows testing the Use Case without the UI and the ViewModel without the UseCase (via mocks). At IT Sectr we strictly follow: Use Case — business logic, ViewModel — only presentation, View — only display.
// Use Case (Domain) — pure business logic
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — only 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 in the Presentation layer is also part of the outer ring. Clean Architecture does not prescribe a navigation mechanism — it can be NavController (Compose), NavigationStack (SwiftUI), Coordinator (UIKit), or Router (VIPER). Importantly, navigation decisions are made by Presentation, but navigation should not penetrate into the Use Case. The Use Case returns a result, the ViewModel decides which screen to navigate to. In Clean Architecture, navigation is a detail that can be replaced without changing the Domain.
Clean Architecture on Android is implemented via Gradle modules: domain (pure Kotlin), data (domain + Retrofit + Room), presentation (domain + Compose). Folder structure: 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) connects the layers: UserRepositoryImpl is bound to the UserRepository interface in the domain module.
Clean Architecture on iOS uses SPM or Xcode groups without separate modules (due to Xcode limitations). Domain — a folder with files that do not import UIKit or SwiftUI. Data — a folder with APIClient, CoreDataStack, RepositoryImpl. Presentation — a folder with ViewModels and SwiftUI Views. DI via constructor or assembly in the App. The main call is async/await through UseCase.execute() with MainActor check for UI updates.
// 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 at IT Sectr projects — our standard for projects from 30 days onward. We have been using a three-layer architecture with Kotlin Multiplatform for Android/iOS since 2022. Domain — a shared KMP module, Data — platform modules (Retrofit on Android, URLSession on iOS), Presentation — native UI. This yields 60–80% shared business logic code between iOS and Android, reducing development time by 30–40% compared to two separate implementations.
Frequently Asked Questions
At minimum three: Domain, Data, Presentation. For large projects, Framework (Android SDK/iOS UIKit dependencies) and Device (GPS, camera, sensors) are added. The number of layers is not a strict rule but a matter of convenience. The main thing is to follow the Dependency Rule: dependencies point inward, toward Domain. You can start with three and add layers as the project grows.
Yes — by 30–50% compared to MVVM due to the extraction of repository interfaces, Use Cases, and mappers. This is excessive for a simple CRUD application. Clean Architecture is justified for projects with complex business logic where testability and layer isolation are more important than development speed. For MVP or prototypes, use MVVM — Clean Architecture will slow down the startup.
Yes, this is common practice. Use Cases remain in Domain, while Presentation uses the MVI cycle (Intent → Reducer → State). Data Layer remains the same, Domain remains the same. MVI in Presentation provides predictable screen state, Clean Architecture provides business logic isolation. This combination is used in large projects with dozens of developers.
A Use Case is needed when the operation involves a business rule: validation, combining data from two sources, calculation, logging, access permission checks. A simple getUser(id) request without additional logic can call the Repository directly from the ViewModel. However, for architectural consistency, many teams create a Use Case for every public Repository method — this adds 5–10% code but simplifies reading.
Domain: Unit tests for Use Cases with mock Repository — pure Kotlin/Swift without Android SDK. Data: Integration tests for RepositoryImpl with mock/fake DataSource. Presentation: ViewModel tests with mock UseCase. Thanks to the Dependency Rule, each layer is tested in isolation. At IT Sectr, Domain coverage reaches 95%, Data — 70–80%, Presentation — 60–70%.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also