Building an Android App with Clean Architecture — Step-by-Step TutorialThis tutorial walks through building this repository's feature from an emptyAndroid project: **search musi...
Building an Android App with Clean Architecture — Step-by-Step Tutorial
1. What you'll build
A single-activity Compose app with two screens:
- Search screen — a text field that searches the [iTunes Search
API](https://performance-partners.apple.com/search-api) as you type
(debounced), showing artwork, track name, artist, and media kind in a list.
Results are cached locally so the last search for a term shows instantly,
even offline.
- Detail / player screen — tapping a result opens a screen that plays the
track's preview clip with ExoPlayer and shows its metadata.
2. Architecture at a glance
The app follows Clean Architecture's dependency rule: source code
dependencies only point inward. Domain knows nothing about data or
presentation; data and presentation both depend on domain.
flowchart TB
subgraph Presentation["presentation (Compose + ViewModel)"]
Screen["ITunesScreen / MediaDetailScreen"]
VM["ITunesViewModel / MediaDetailViewModel"]
Nav["ITunesNavHost"]
end
subgraph Domain["domain (pure Kotlin, no Android deps)"]
Model["ITunesMedia"]
RepoIface["ITunesRepository (interface)"]
UseCase["SearchMediaUseCase / GetMediaDetailUseCase"]
end
subgraph Data["data (frameworks & drivers)"]
RepoImpl["ITunesRepositoryImpl"]
Api["ITunesApi (Retrofit)"]
Dao["ITunesDao (Room)"]
Mapper["ITunesMapper"]
end
subgraph DI["di (Hilt modules)"]
NetMod["NetworkModule"]
DbMod["DatabaseModule"]
RepoMod["RepositoryModule"]
end
Screen --> VM --> UseCase --> RepoIface
RepoImpl -.implements.-> RepoIface
RepoImpl --> Api
RepoImpl --> Dao
RepoImpl --> Mapper
RepoMod -. binds impl to interface .-> RepoIface
NetMod --> Api
DbMod --> Dao
Nav --> ScreenKey rule enforced throughout: **domain never imports from data or
presentation**. The only place that knows both the interface
(ITunesRepository) and its concrete implementation
(ITunesRepositoryImpl) is RepositoryModule — the dependency-inversion
seam.
3. Prerequisites
- Android Studio (a recent version, e.g. Ladybird/Koala or newer)
- JDK 17
- Basic familiarity with Kotlin, Jetpack Compose, and coroutines
4. Final project structure
app/src/main/java/com/mahesh_prajapati/cleanarchitecture/
├── CleanArchApp.kt # @HiltAndroidApp Application
├── data/
│ ├── local/
│ │ ├── ITunesDao.kt
│ │ ├── ITunesDatabase.kt
│ │ └── ITunesEntity.kt
│ ├── mapper/
│ │ └── ITunesMapper.kt
│ ├── remote/
│ │ ├── ITunesApi.kt
│ │ └── ITunesDto.kt
│ └── repository/
│ └── ITunesRepositoryImpl.kt
├── di/
│ ├── DatabaseModule.kt
│ ├── NetworkModule.kt
│ └── RepositoryModule.kt
├── domain/
│ ├── model/
│ │ └── ITunesMedia.kt
│ ├── repository/
│ │ └── ITunesRepository.kt
│ └── usecase/
│ ├── GetMediaDetailUseCase.kt
│ └── SearchMediaUseCase.kt
└── presentation/
├── ITunesScreen.kt
├── ITunesViewModel.kt
├── MainActivity.kt
├── MediaDetailScreen.kt
├── MediaDetailViewModel.kt
├── VideoPlayer.kt
└── navigation/
├── ITunesDestinations.kt
└── ITunesNavHost.ktTests mirror this structure under app/src/test (JVM unit tests) and
app/src/androidTest (instrumented Compose UI tests).
Step 1 — Project & Gradle setup
1.1 Version catalog
All dependency versions live in gradle/libs.versions.toml so every module
references a single source of truth:
[versions]
agp = "9.2.1"
kotlin = "2.1.10"
ksp = "2.1.10-1.0.29"
hilt = "2.60.1"
retrofit = "2.11.0"
okhttp = "4.12.0"
room = "2.7.0-alpha13"
composeBom = "2025.01.01"
lifecycle = "2.8.7"
coil = "2.7.0"
navigationCompose = "2.9.8"
hiltNavigationCompose = "1.2.0"
media3 = "1.5.1"
mockk = "1.13.13"
mockwebserver = "4.12.0"
[libraries]
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# Retrofit / OkHttp
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Navigation & Media3
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
# Testing
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }1.2 Root build.gradle.kts
Declares plugins once, applied per-module (apply false at root):
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.compose.compiler) apply false
}1.3 app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.mahesh_prajapati.cleanarchitecture"
compileSdk = 37
defaultConfig {
applicationId = "com.mahesh_prajapati.cleanarchitecture"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true }
}
dependencies {
// Core / Compose
implementation(libs.androidx.core.ktx)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.material3)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Retrofit + Gson
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Image loading, Navigation, Media3
implementation(libs.coil.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
// Tests
testImplementation(libs.junit)
testImplementation(libs.mockwebserver)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.androidx.core.testing)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.mockk.android)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}Why KSP instead of kapt? KSP compiles Room/Hilt annotation processing
directly against the Kotlin compiler plugin API, which is noticeably
faster than kapt's Java-stub approach. Only reach for kotlin-kapt if alibrary you need doesn't yet ship a KSP processor.
Step 2 — Application class & manifest
Hilt needs an Application class annotated with @HiltAndroidApp to
generate its root dependency container:
// CleanArchApp.kt
@HiltAndroidApp
class CleanArchApp : Application()Register it (and the internet permission the API/video calls need) in the
manifest:
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".CleanArchApp"
...>
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:theme="@style/Theme.CleanArchitecture">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>Step 3 — Domain layer
Start here, not with the network call. The domain layer is **pure Kotlin —
no Android, Retrofit, or Room imports** — so it's trivial to unit test and
immune to churn in the frameworks around it.
3.1 The domain model
// domain/model/ITunesMedia.kt
data class ITunesMedia(
val trackId: Int,
val artistName: String,
val trackName: String,
val previewUrl: String,
val artworkUrl: String,
val kind: String
)This is deliberately not the same class as the network DTO or the Room
entity — each layer gets its own shape, connected later by mappers (Step 6).
3.2 The repository contract
The domain layer defines the interface it needs; it has no idea Retrofit or
Room exist behind it:
// domain/repository/ITunesRepository.kt
interface ITunesRepository {
fun searchMedia(term: String): Flow<Result<List<ITunesMedia>>>
suspend fun getMediaById(trackId: Int): ITunesMedia?
}Flow<Result<...>> is the shape that lets the repository emit more than one
value over time (cached results, then fresh network results) while still
carrying success/failure explicitly instead of throwing.
3.3 Use cases
Each use case is a single-responsibility, single-method class (`operator fun
invoke`) that owns one business rule and is what the presentation layer
actually depends on — never the repository directly.
// domain/usecase/SearchMediaUseCase.kt
class SearchMediaUseCase @Inject constructor(
private val repository: ITunesRepository
) {
operator fun invoke(term: String): Flow<Result<List<ITunesMedia>>> {
val trimmed = term.trim()
if (trimmed.isBlank()) {
return flowOf(Result.success(emptyList()))
}
return repository.searchMedia(trimmed)
}
}// domain/usecase/GetMediaDetailUseCase.kt
class GetMediaDetailUseCase @Inject constructor(
private val repository: ITunesRepository
) {
suspend operator fun invoke(trackId: Int): ITunesMedia? = repository.getMediaById(trackId)
}Putting the "blank search never hits the network" rule here (rather than
in the ViewModel) means it's testable with a mock repository and no Android
framework at all — see [Step 13](#step-13--testing-strategy).
Step 4 — Data layer: remote (Retrofit)
4.1 DTOs
DTOs mirror the JSON exactly and make every field nullable, since you never
fully trust a third-party API's contract:
// data/remote/ITunesDto.kt
data class ITunesResponseDto(
@SerializedName("resultCount") val resultCount: Int,
@SerializedName("results") val results: List<ITunesResultDto>
)
data class ITunesResultDto(
@SerializedName("trackId") val trackId: Int?,
@SerializedName("artistName") val artistName: String?,
@SerializedName("trackName") val trackName: String?,
@SerializedName("previewUrl") val previewUrl: String?,
@SerializedName("artworkUrl100") val artworkUrl100: String?,
@SerializedName("kind") val kind: String?
)4.2 The Retrofit service
// data/remote/ITunesApi.kt
interface ITunesApi {
@GET("search")
suspend fun search(
@Header("X-Requested-With") requestedWith: String = "CleanArchApp",
@Query("term") term: String,
@Query("media") media: String = "musicVideo"
): ITunesResponseDto
companion object {
const val BASE_URL = "https://itunes.apple.com/"
}
}suspend fun lets Retrofit hand back a coroutine-friendly call with no
extra adapter needed.
Step 5 — Data layer: local (Room)
Room backs the offline-first cache.
5.1 Entity
// data/local/ITunesEntity.kt
@Entity(tableName = "itunes_media")
data class ITunesEntity(
@PrimaryKey val trackId: Int,
val artistName: String,
val trackName: String,
val previewUrl: String,
val artworkUrl: String,
val kind: String,
val searchTerm: String // groups cached rows by the query that produced them
)5.2 DAO
// data/local/ITunesDao.kt
@Dao
interface ITunesDao {
@Query("SELECT * FROM itunes_media WHERE searchTerm = :searchTerm")
suspend fun getMediaBySearchTerm(searchTerm: String): List<ITunesEntity>
@Query("SELECT * FROM itunes_media WHERE trackId = :trackId LIMIT 1")
suspend fun getMediaById(trackId: Int): ITunesEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMediaList(mediaList: List<ITunesEntity>)
@Query("DELETE FROM itunes_media WHERE searchTerm = :searchTerm")
suspend fun deleteMediaBySearchTerm(searchTerm: String)
}5.3 Database
// data/local/ITunesDatabase.kt
@Database(entities = [ITunesEntity::class], version = 1)
abstract class ITunesDatabase : RoomDatabase() {
abstract val dao: ITunesDao
}Step 6 — Mappers
All conversions between DTO ⟷ domain and entity ⟷ domain live in one file,
kept as simple extension functions. This is what lets DTOs and entities
stay "dumb" data holders, with every default/fallback value for missing
JSON fields centralized in one obvious place:
// data/mapper/ITunesMapper.kt
fun ITunesResultDto.toDomain(): ITunesMedia = ITunesMedia(
trackId = trackId ?: 0,
artistName = artistName ?: "Unknown Artist",
trackName = trackName ?: "Unknown Track",
previewUrl = previewUrl ?: "",
artworkUrl = artworkUrl100 ?: "",
kind = kind ?: ""
)
fun ITunesEntity.toDomain(): ITunesMedia = ITunesMedia(
trackId = trackId,
artistName = artistName,
trackName = trackName,
previewUrl = previewUrl,
artworkUrl = artworkUrl,
kind = kind
)
fun ITunesMedia.toEntity(searchTerm: String): ITunesEntity = ITunesEntity(
trackId = trackId,
artistName = artistName,
trackName = trackName,
previewUrl = previewUrl,
artworkUrl = artworkUrl,
kind = kind,
searchTerm = searchTerm
)Step 7 — Repository implementation
This is where the network and cache are combined into the offline-first
Flow the domain interface promises:
// data/repository/ITunesRepositoryImpl.kt
class ITunesRepositoryImpl @Inject constructor(
private val api: ITunesApi,
private val dao: ITunesDao
) : ITunesRepository {
override fun searchMedia(term: String): Flow<Result<List<ITunesMedia>>> = flow {
// 1. Emit cached data first — instant feedback, works offline.
val cachedMedia = dao.getMediaBySearchTerm(term).map { it.toDomain() }
emit(Result.success(cachedMedia))
try {
// 2. Hit the network for fresh data.
val response = api.search(term = term)
val domainMedia = response.results.map { it.toDomain() }
// 3. Replace the cache for this term.
dao.deleteMediaBySearchTerm(term)
dao.insertMediaList(domainMedia.map { it.toEntity(term) })
// 4. Emit the fresh data as a second value.
emit(Result.success(domainMedia))
} catch (e: Exception) {
// Network failed — if we had nothing cached either, surface the error.
if (cachedMedia.isEmpty()) {
emit(Result.failure(e))
}
// Otherwise silently keep showing the cached results.
}
}
override suspend fun getMediaById(trackId: Int): ITunesMedia? {
return dao.getMediaById(trackId)?.toDomain()
}
}getMediaById deliberately reads from the local cache only: the detail
screen is always opened from a list item the user has already seen (and
which was therefore already cached by searchMedia), so no second network
round-trip is needed.
Step 8 — Dependency injection (Hilt modules)
Three small modules, one per concern, all installed in SingletonComponent
(app-wide singletons):
8.1 Network
// di/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl(ITunesApi.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideITunesApi(retrofit: Retrofit): ITunesApi = retrofit.create(ITunesApi::class.java)
}8.2 Database
// di/DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideITunesDatabase(@ApplicationContext context: Context): ITunesDatabase =
Room.databaseBuilder(context, ITunesDatabase::class.java, "itunes_db").build()
@Provides
@Singleton
fun provideITunesDao(db: ITunesDatabase): ITunesDao = db.dao
}8.3 Repository binding — the dependency-inversion seam
// di/RepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindITunesRepository(impl: ITunesRepositoryImpl): ITunesRepository
}This @Binds is the only place in the codebase that mentions both
ITunesRepository and ITunesRepositoryImpl in the same breath — domain
and presentation code only ever see the interface.
Step 9 — Presentation: Search screen
9.1 ViewModel + UI state
// presentation/ITunesViewModel.kt
data class ITunesState(
val mediaList: List<ITunesMedia> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class ITunesViewModel @Inject constructor(
private val searchMediaUseCase: SearchMediaUseCase
) : ViewModel() {
private val _state = mutableStateOf(ITunesState())
val state: State<ITunesState> = _state
private val _searchQuery = mutableStateOf("Michael Jackson")
val searchQuery: State<String> = _searchQuery
private var searchJob: Job? = null
init {
search("Michael Jackson")
}
fun onSearchQueryChange(query: String) {
_searchQuery.value = query
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(500L) // debounce so we don't hit the API on every keystroke
search(query)
}
}
private fun search(term: String) {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
searchMediaUseCase(term).collect { result ->
result.onSuccess {
_state.value = _state.value.copy(mediaList = it, isLoading = false, error = null)
}
result.onFailure {
_state.value = _state.value.copy(error = it.message ?: "Unknown Error", isLoading = false)
}
}
}
}
}Notes:
- Cancelling
searchJobon every keystroke and re-launching with a 500ms
delay is the debounce — cheap to write, no extra library needed.
(A snapshotFlow + debounce() operator is an alternative if you want it
to compose with other Flow operators.)
- Because
searchMediaUseCase(term)is a coldFlowthat can emit twice
(cache, then network), collect naturally updates the UI state each time
a new value arrives.
9.2 The Composable screen
// presentation/ITunesScreen.kt
@Composable
fun ITunesScreen(
viewModel: ITunesViewModel,
onMediaClick: (ITunesMedia) -> Unit = {}
) {
val state = viewModel.state.value
val searchQuery = viewModel.searchQuery.value
Scaffold(topBar = { TopAppBar(title = { Text("iTunes Search") }) }) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
TextField(
value = searchQuery,
viewModel.onSearchQueryChange(it) },
modifier = Modifier.fillMaxWidth().padding(16.dp),
placeholder = { Text("Search Artist...") },
singleLine = true
)
Box(modifier = Modifier.fillMaxSize()) {
if (state.isLoading && state.mediaList.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
state.error?.let {
if (state.mediaList.isEmpty()) {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.align(Alignment.Center))
}
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.mediaList) { media ->
MediaItem(media = media, onMediaClick(media) })
}
}
}
}
}
}MediaItem is a simple Card with a Coil AsyncImage for the artwork plus
three Texts for track/artist/kind — see ITunesScreen.kt in the repo for
the full composable.
Notice the screen takes a plain onMediaClick: (ITunesMedia) -> Unit
callback rather than a NavController — it knows nothing about navigation,
which keeps it reusable and trivially testable in isolation (Step 13).
Step 10 — Presentation: Detail screen & video player
10.1 ViewModel
The detail screen's ViewModel reads its argument straight from Hilt's
SavedStateHandle (populated by Navigation Compose from the route, see
Step 11) rather than taking a raw Int constructor parameter:
// presentation/MediaDetailViewModel.kt
data class MediaDetailState(
val media: ITunesMedia? = null,
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class MediaDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val getMediaDetailUseCase: GetMediaDetailUseCase
) : ViewModel() {
private val _state = mutableStateOf(MediaDetailState(isLoading = true))
val state: State<MediaDetailState> = _state
init {
val trackId = savedStateHandle.get<Int>(TRACK_ID_ARG)
if (trackId != null) loadMedia(trackId)
else _state.value = MediaDetailState(isLoading = false, error = "Media not found")
}
private fun loadMedia(trackId: Int) {
viewModelScope.launch {
val media = getMediaDetailUseCase(trackId)
_state.value = if (media != null) {
MediaDetailState(media = media, isLoading = false)
} else {
MediaDetailState(isLoading = false, error = "Media not found")
}
}
}
companion object {
const val TRACK_ID_ARG = "trackId"
}
}10.2 Video player (Media3 / ExoPlayer)
A thin Compose wrapper is the only place in the app that touches
ExoPlayer directly — the "framework & driver" for playback:
// presentation/VideoPlayer.kt
@Composable
fun VideoPlayer(videoUrl: String, modifier: Modifier = Modifier) {
val context = LocalContext.current
val exoPlayer = remember(videoUrl) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(videoUrl))
prepare()
playWhenReady = true
}
}
DisposableEffect(exoPlayer) {
onDispose { exoPlayer.release() }
}
AndroidView(
modifier = modifier.fillMaxSize(),
factory = {
PlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
}
}
)
}Two details matter here:
remember(videoUrl)rebuilds the player only when the URL actually
changes, not on every recomposition.
DisposableEffect(exoPlayer) { onDispose { exoPlayer.release() } }frees
the native player when the composable leaves composition — skipping this
is the most common ExoPlayer-in-Compose memory leak.
10.3 The screen
// presentation/MediaDetailScreen.kt
@Composable
fun MediaDetailScreen(viewModel: MediaDetailViewModel, onBackClick: () -> Unit) {
val state = viewModel.state.value
Scaffold(
topBar = {
TopAppBar(
title = { Text(state.media?.trackName ?: "Details") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
when {
state.isLoading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
state.error != null -> Text(state.error, color = MaterialTheme.colorScheme.error, modifier = Modifier.align(Alignment.Center))
state.media != null -> MediaDetailContent(media = state.media)
}
}
}
}MediaDetailContent lays out VideoPlayer (16:9 aspectRatio) above the
track metadata in a scrollable Column — see MediaDetailScreen.kt for the
full body.
Step 11 — Navigation
11.1 Route definitions
Route strings and their argument keys are centralized in one object so no
screen or NavHost code ever hand-builds/parses a route string itself:
// presentation/navigation/ITunesDestinations.kt
object ITunesDestinations {
const val TRACK_ID_ARG = "trackId"
const val SEARCH_ROUTE = "search"
const val DETAIL_ROUTE = "detail/{$TRACK_ID_ARG}"
fun detailRoute(trackId: Int) = "detail/$trackId"
}11.2 The NavHost
// presentation/navigation/ITunesNavHost.kt
@Composable
fun ITunesNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = ITunesDestinations.SEARCH_ROUTE) {
composable(ITunesDestinations.SEARCH_ROUTE) {
val viewModel: ITunesViewModel = hiltViewModel()
ITunesScreen(
viewModel = viewModel,
media -> navController.navigate(ITunesDestinations.detailRoute(media.trackId)) }
)
}
composable(
route = ITunesDestinations.DETAIL_ROUTE,
arguments = listOf(navArgument(ITunesDestinations.TRACK_ID_ARG) { type = NavType.IntType })
) {
val viewModel: MediaDetailViewModel = hiltViewModel()
MediaDetailScreen(viewModel = viewModel, navController.popBackStack() })
}
}
}hiltViewModel() resolves each screen's @HiltViewModel from the nav
graph's own `NavBackStackEntry` scope, and Navigation Compose automatically
populates `SavedStateHandle` with the `trackId` argument — which is exactly
what `MediaDetailViewModel` reads in Step 10.1.Step 12 — Wiring it up in MainActivity
// presentation/MainActivity.kt
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.background) {
ITunesNavHost()
}
}
}
}
}@AndroidEntryPoint lets Hilt inject into the activity (and, transitively,
into every hiltViewModel() created under it). That's the entire activity —
all real logic lives in the NavHost and screens below it.
Step 13 — Testing strategy
The app is tested at three levels, each isolating a different layer:
13.1 Domain: use case unit tests (no Android, no Hilt)
// test/domain/usecase/SearchMediaUseCaseTest.kt
@Test
fun `blank term resolves to empty success without touching the repository`() = runTest {
val result = useCase(" ").first()
assertTrue(result.isSuccess)
assertEquals(emptyList<ITunesMedia>(), result.getOrNull())
coVerify(exactly = 0) { repository.searchMedia(any()) }
}Only ITunesRepository is mocked (via MockK) — this test can't accidentally
depend on Retrofit, Room, or Android at all.
13.2 Data: Retrofit contract test with MockWebServer
// test/data/remote/ITunesApiTest.kt
@Test
fun `search should return successful response with valid data`() = runBlocking {
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(/* JSON */ "..."))
val result = api.search(term = "Michael Jackson")
val request = mockWebServer.takeRequest()
assertEquals("/search?term=Michael%20Jackson&media=musicVideo", request.path)
assertEquals("CleanArchApp", request.getHeader("X-Requested-With"))
assertEquals(1, result.resultCount)
}MockWebServer gives a real HTTP server backed by canned responses, so this
verifies the exact query string and headers Retrofit sends — no fake/mocked
Retrofit object involved.
13.3 Presentation: ViewModel unit tests with StandardTestDispatcher
// test/presentation/ITunesViewModelTest.kt
@Test
fun `search with debouncing should update state correctly`() = runTest {
coEvery { searchMediaUseCase("Michael Jackson") } returns flowOf(Result.success(emptyList()))
coEvery { searchMediaUseCase(query) } returns flowOf(Result.success(mediaList))
viewModel = ITunesViewModel(searchMediaUseCase)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.onSearchQueryChange(query)
advanceTimeBy(600) // let the 500ms debounce elapse
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(mediaList, viewModel.state.value.mediaList)
}Dispatchers.setMain(StandardTestDispatcher()) plus advanceTimeBy/
advanceUntilIdle let the test control the debounce's virtual clock
instead of sleeping in real time.
13.4 Compose UI test (instrumented)
// androidTest/presentation/ITunesScreenTest.kt
@Test
fun itunesScreen_clickMediaItem_triggersOnMediaClickWithThatMedia() {
var clicked: ITunesMedia? = null
composeTestRule.setContent {
ITunesScreen(viewModel = viewModel, clicked = it })
}
composeTestRule.onNodeWithText("Thriller").performClick()
assert(clicked?.trackName == "Thriller")
}Because ITunesScreen takes its ViewModel and click callback as plain
parameters (no hiltViewModel()/NavController inside the composable
itself), it can be dropped straight into composeTestRule.setContent with a
hand-built ViewModel — no Hilt test rule or navigation graph needed.
Run everything with:
./gradlew test # JVM unit tests (domain, data, presentation)
./gradlew connectedAndroidTest # instrumented Compose UI tests (needs a device/emulator)Step 14 — Build & run
./gradlew assembleDebug # build the debug APK
./gradlew installDebug # install it on a connected device/emulatorOr open the project in Android Studio and press Run. The app launches
straight into the search screen with "Michael Jackson" pre-searched; tap any
result to open the preview player.
Key design decisions (and why)
| Decision | Why |
|---|---|
| Domain has zero Android/Retrofit/Room imports | Keeps business rules (e.g. "blank search ⇒ empty result") testable as plain JVM unit tests and immune to churn in the frameworks around them. |
| Use cases instead of exposing the repository directly | Each use case is a single named business rule with its own test; the ViewModel only orchestrates state, it doesn't own logic. |
One RepositoryModule binds impl → interface | The only file in the whole codebase that names both types — the dependency-inversion seam is explicit and easy to audit. |
searchMedia returns a Flow that emits twice (cache, then network) | Offline-first UX: the UI shows something immediately, then upgrades to fresh data, and silently keeps the cached view if the network call fails. |
getMediaById reads the local cache only | The detail screen is only ever opened from an item the list screen already fetched and cached — a second network round-trip would be pure waste. |
| All DTO/entity ⇄ domain conversions live in one mapper file | DTOs and entities stay dumb data holders; every default/fallback for a missing JSON field has one obvious home. |
Screens take plain callbacks (onMediaClick, onBackClick), never a NavController | Keeps them reusable and unit-testable via composeTestRule.setContent without a real nav graph. |
Route strings centralized in ITunesDestinations | No screen or NavHost code hand-builds/parses a route string independently. |
Debounce via a cancel-and-relaunch Job + delay(500L) | Cheapest way to avoid hammering the API on every keystroke; testable deterministically with StandardTestDispatcher. |
VideoPlayer is the only file that touches ExoPlayer | Confines the playback "framework & driver" to one Compose wrapper; remember(videoUrl) + DisposableEffect release avoids the most common ExoPlayer-in-Compose leak. |
Extending the app
To add a new feature while keeping the same architecture:
- Domain first — add the model, repository method (if needed), and a
use case. No Android imports.
- Data — add/extend the DTO, entity, DAO query, and mapper functions;
implement the repository method.
- DI — wire any new Retrofit/Room providers in
NetworkModule/
DatabaseModule; extend RepositoryModule if you add a new repository
interface.
- Presentation — add a
@HiltViewModel+ state data class, a
composable screen that takes plain callbacks, and register its route in
ITunesDestinations + ITunesNavHost.
- Tests — a domain-level use case test, a data-level contract test
(MockWebServer or an in-memory Room DB), and a ViewModel test are the
minimum bar before wiring up the UI test.
Following that order (domain → data → DI → presentation → tests) is what
keeps the dependency rule intact as the app grows.