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 --> Screen

Key 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.kt

Tests 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 a
library 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 searchJob on 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 cold Flow that 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/emulator

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

DecisionWhy
Domain has zero Android/Retrofit/Room importsKeeps 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 directlyEach 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 → interfaceThe 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 onlyThe 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 fileDTOs 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 NavControllerKeeps them reusable and unit-testable via composeTestRule.setContent without a real nav graph.
Route strings centralized in ITunesDestinationsNo 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 ExoPlayerConfines 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:

  1. Domain first — add the model, repository method (if needed), and a

use case. No Android imports.

  1. Data — add/extend the DTO, entity, DAO query, and mapper functions;

implement the repository method.

  1. DI — wire any new Retrofit/Room providers in NetworkModule/

DatabaseModule; extend RepositoryModule if you add a new repository

interface.

  1. Presentation — add a @HiltViewModel + state data class, a

composable screen that takes plain callbacks, and register its route in

ITunesDestinations + ITunesNavHost.

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