Clean Architecture — Fundamentals, Layers of Entities, Use Cases, and Gateways

Author: IT Sectr Published: 2026-02-17 Reading time: 10 min

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 — three layers: Domain (business logic), Data (data), Presentation (UI) with Dependency Rule
  • Dependency Rule — dependencies point inward, Domain knows nothing about Data and Presentation
  • Use Cases (Interactors) — business logic scenarios, each Use Case is one class with one method
  • Repository Interface — data abstraction in Domain, implementation in the Data layer
  • Testability — Domain and Use Cases are tested with unit tests without Android SDK and iOS UIKit

Clean Architecture — Fundamentals of Layered Architecture

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).

LayerContainsDependencies
DomainEntities, Use Cases, Repository InterfacesNone (pure Kotlin/Swift)
DataRepositoryImpl, DataSources (API, DB, Cache)Domain, Retrofit, Room, Ktor
PresentationViewModels, Views, ComposablesDomain, 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 Layer: Entities, Use Cases, and Repository Interfaces

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.

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: Repository Implementation and DataSources

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.

kotlin
// 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: ViewModels and Views

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.

kotlin
// 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 iOS and Android: Code Examples

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.

swift
// 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

How many layers should Clean Architecture have?

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.

Does Clean Architecture increase code volume?

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.

Can Clean Architecture be combined with MVI?

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.

Do I need Use Cases for every data request?

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.

How to test Clean Architecture?

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

  • Clean Architecture — three layers (Domain, Data, Presentation) with Dependency Rule pointing inward
  • Dependency Rule — Domain knows nothing about Data and Presentation, isolation through interfaces
  • Domain — Entities, Use Cases, Repository Interfaces — pure Kotlin/Swift without frameworks
  • Data — RepositoryImpl, DataSources (network, DB, cache) — implementation of Domain interfaces
  • Presentation — ViewModels, Views — only display, business logic in Use Cases
  • Testing — Domain covered by unit tests at 90–95%
  • KMP — Clean Architecture with Kotlin Multiplatform yields 60–80% shared iOS + Android code

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.

Discuss the project

Read also