SHAKIL.DEV

SHAKIL.DEV

Building Clean Architecture in Android: A Practical Guide
← BACK TO TRANSMISSIONS
January 19, 2026

Building Clean Architecture in Android: A Practical Guide

A practical guide to Clean Architecture in Android: presentation, domain and data layers, use cases, repositories, Hilt and testing, with real Kotlin code.

Clean Architecture in Android is a way of organizing your app so that business logic doesn't depend on the UI, the database or the network. It makes apps easier to test, easier to change, and easier for a team to work on together. In this practical guide, I'll show how I structure real production apps with Clean Architecture, Kotlin, Hilt and Jetpack Compose, with code for every layer.

New to Android? Start with the Android for Beginners series first, then come back here.

What Is Clean Architecture?

Clean Architecture was introduced by Robert C. Martin ("Uncle Bob"). The idea is simple: split your code into layers, and let dependencies point in only one direction — inward, toward the business logic.

In Android, we usually use three layers:

LayerContainsKnows about
PresentationComposables, ViewModels, UI stateDomain
DomainModels, use cases, repository interfacesNothing (pure Kotlin)
DataRepository implementations, API (Retrofit), database (Room), DTOsDomain

The key rule is the dependency rule: the domain layer never imports anything from the presentation or data layers. It doesn't know Retrofit, Room or Compose exist.

Why Use Clean Architecture?

  1. Testability – the domain layer is plain Kotlin, so you can unit test business rules without an emulator.
  2. Maintainability – swap Retrofit for Ktor, or Room for another database, without touching your UI or business logic.
  3. Scalability – new features follow the same structure, so the codebase stays predictable as the team grows.
  4. Clear ownership – everyone knows where a piece of code belongs.

A Simple Package Structure

For small and medium apps, one module with clear packages is enough:

com.example.app
├── data
│   ├── remote        // Retrofit API + DTOs
│   ├── local         // Room DAO + entities
│   └── repository    // UserRepositoryImpl
├── domain
│   ├── model         // UserProfile
│   ├── repository    // UserRepository (interface)
│   └── usecase       // GetUserProfileUseCase
├── presentation
│   └── profile       // ProfileScreen, ProfileViewModel, ProfileUiState
└── di                // Hilt modules

For larger apps, split these into Gradle modules (for example :core:domain, :core:data, :feature:profile) so the compiler enforces the dependency rule for you.

Layer 1: Domain (The Heart of the App)

The domain layer holds your models, the repository contract, and use cases.

// domain/model/UserProfile.kt
data class UserProfile(
    val id: String,
    val name: String,
    val email: String
)

// domain/repository/UserRepository.kt
interface UserRepository {
    suspend fun getUserProfile(userId: String): Result<UserProfile>
}

// domain/usecase/GetUserProfileUseCase.kt
class GetUserProfileUseCase @Inject constructor(
    private val userRepository: UserRepository
) {
    suspend operator fun invoke(userId: String): Result<UserProfile> {
        if (userId.isBlank()) return Result.failure(IllegalArgumentException("User id is empty"))
        return userRepository.getUserProfile(userId)
    }
}

Notice that UserRepository is an interface that lives in the domain. The domain says what it needs; the data layer decides how.

A use case should do one thing. The operator fun invoke lets you call it like a function: getUserProfile(id).

Layer 2: Data (Where the Data Comes From)

The data layer implements the repository and maps network or database objects (DTOs) into domain models.

// data/remote/UserApi.kt
interface UserApi {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: String): UserDto
}

data class UserDto(val id: String, val full_name: String, val email: String)

fun UserDto.toDomain() = UserProfile(id = id, name = full_name, email = email)

// data/repository/UserRepositoryImpl.kt
class UserRepositoryImpl @Inject constructor(
    private val api: UserApi,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : UserRepository {

    override suspend fun getUserProfile(userId: String): Result<UserProfile> =
        withContext(ioDispatcher) {
            try {
                Result.success(api.getUser(userId).toDomain())
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
}

Two important details:

Layer 3: Presentation (ViewModel + Compose)

The ViewModel calls use cases and exposes a single UI state.

sealed interface ProfileUiState {
    data object Loading : ProfileUiState
    data class Success(val profile: UserProfile) : ProfileUiState
    data class Error(val message: String) : ProfileUiState
}

@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val getUserProfile: GetUserProfileUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()

    fun loadProfile(userId: String) {
        _uiState.value = ProfileUiState.Loading
        viewModelScope.launch {
            _uiState.value = getUserProfile(userId).fold(
                onSuccess = { ProfileUiState.Success(it) },
                onFailure = { ProfileUiState.Error(it.message ?: "Something went wrong") }
            )
        }
    }
}

And the screen simply renders that state:

@Composable
fun ProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    when (val s = state) {
        ProfileUiState.Loading -> CircularProgressIndicator()
        is ProfileUiState.Success -> Text("Hello, ${s.profile.name}")
        is ProfileUiState.Error -> Text("Error: ${s.message}")
    }
}

For more on exposing and collecting state in Compose, read Jetpack Compose: State Management Best Practices.

Wiring It Together with Hilt

Dependency injection connects the interface in the domain to the implementation in data:

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    @Singleton
    abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}

Now anything that asks for a UserRepository gets UserRepositoryImpl, and the domain never knows the difference.

Testing a Use Case

Because the domain is pure Kotlin, testing is fast and simple. Use a fake repository:

class FakeUserRepository : UserRepository {
    var result: Result<UserProfile> = Result.success(UserProfile("1", "Shakil", "s@example.com"))
    override suspend fun getUserProfile(userId: String) = result
}

class GetUserProfileUseCaseTest {
    private val repo = FakeUserRepository()
    private val useCase = GetUserProfileUseCase(repo)

    @Test
    fun `blank id returns failure`() = runTest {
        assertTrue(useCase("").isFailure)
    }

    @Test
    fun `valid id returns profile`() = runTest {
        assertEquals("Shakil", useCase("1").getOrThrow().name)
    }
}

No emulator, no network, no mocking library required.

Common Mistakes to Avoid

  • Android imports in the domain layer. If you see android.* or retrofit2.* in domain, something leaked.
  • Use cases that only forward calls. A use case with no logic is fine for consistency, but don't create ten layers of pass-through classes for a tiny app.
  • Exposing DTOs or Room entities to the UI. Always map to domain models.
  • One giant "god" repository. Split repositories by feature or data type.
  • Over-engineering small apps. For a weekend project, a ViewModel and a repository may be all you need. Add layers when complexity arrives.

Frequently Asked Questions

Is Clean Architecture the same as MVVM? No. MVVM is a presentation pattern (how the UI talks to the ViewModel). Clean Architecture is about the whole app's layers. They work well together: MVVM in the presentation layer, Clean Architecture overall.

Do I always need use cases? Not always. Google's architecture guide treats the domain layer as optional. Use cases shine when logic is reused across screens or is complex enough to test on its own.

Should I use multiple Gradle modules? For large teams, yes: modules speed up builds and enforce boundaries. For small apps, packages are enough.

Key Takeaways

  • Split code into presentation, domain and data.
  • Dependencies point inward; the domain is pure Kotlin.
  • Repository interfaces live in the domain, implementations in data.
  • Map DTOs to domain models, and expose one UI state from the ViewModel.
  • Use Hilt to wire layers and fakes to test them.

Clean Architecture takes a little more setup, but it pays off every time you add a feature, fix a bug, or onboard a new developer. You can see this structure in production apps on my projects page.