---
name: android-architecture
description: "Design and implement Clean Architecture with multi-module structure on Android. Covers domain/data/presentation layers, Hilt dependency injection with @Module/@Provides/@Binds, UseCase pattern with operator invoke, Repository pattern with Kotlin Flow, and mapper pattern between layers. Use when structuring a new Android project, adding feature modules, setting up DI, or reviewing architecture compliance."
---

# Android Clean Architecture

Guidance for structuring Android apps with Clean Architecture, multi-module
builds, Hilt DI, and idiomatic Kotlin patterns. Targets Kotlin 2.0+, AGP 8.x,
and Jetpack libraries from 2024-2025.

## Contents

- [Multi-Module Structure](#multi-module-structure)
- [Domain Layer](#domain-layer)
- [Data Layer](#data-layer)
- [Presentation Layer](#presentation-layer)
- [Hilt Dependency Injection](#hilt-dependency-injection)
- [Mapper Pattern](#mapper-pattern)
- [Do's and Don'ts](#dos-and-donts)
- [Troubleshooting](#troubleshooting)
- [Review Checklist](#review-checklist)

## Multi-Module Structure

Organize by feature and layer. Each feature module depends on `:core` modules
but never on other feature modules.

```
:app                          # Application class, NavHost, Hilt setup
:core:model                   # Domain models shared across features
:core:data                    # Repository interfaces, data source contracts
:core:network                 # Retrofit setup, API service interfaces
:core:database                # Room setup, DAOs, entities
:core:common                  # Extensions, Result wrapper, DispatcherProvider
:core:ui                      # Shared Compose components, theme
:core:testing                 # Test doubles, rules, helpers
:feature:home                 # Home screen: UI + ViewModel
:feature:search               # Search screen: UI + ViewModel
:feature:detail               # Detail screen: UI + ViewModel
```

### Module Dependency Rules

```kotlin
// feature:home/build.gradle.kts
dependencies {
    implementation(project(":core:model"))
    implementation(project(":core:data"))
    implementation(project(":core:ui"))
    implementation(project(":core:common"))
    // NEVER depend on another :feature module
}
```

### settings.gradle.kts

```kotlin
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

include(":app")
include(":core:model")
include(":core:data")
include(":core:network")
include(":core:database")
include(":core:common")
include(":core:ui")
include(":core:testing")
include(":feature:home")
include(":feature:search")
include(":feature:detail")
```

## Domain Layer

The domain layer contains business logic with zero Android dependencies. It
lives in `:core:model` (models) and feature modules (use cases).

### Domain Models

```kotlin
// :core:model
data class Flight(
    val id: String,
    val origin: Airport,
    val destination: Airport,
    val departureTime: Instant,
    val arrivalTime: Instant,
    val price: Price,
    val status: FlightStatus,
)

enum class FlightStatus { SCHEDULED, DELAYED, CANCELLED, LANDED }

data class Price(val amount: Double, val currency: String)
```

### UseCase with operator invoke

Each use case has a single responsibility. Use `operator fun invoke` so call
sites read like function calls.

```kotlin
class GetFlightsUseCase @Inject constructor(
    private val flightRepository: FlightRepository,
) {
    operator fun invoke(origin: String, destination: String): Flow<List<Flight>> =
        flightRepository.getFlights(origin, destination)
}
```

For use cases with parameters, define a data class or use function parameters:

```kotlin
class SearchFlightsUseCase @Inject constructor(
    private val repository: FlightRepository,
) {
    operator fun invoke(params: Params): Flow<Result<List<Flight>>> =
        repository.searchFlights(params.query, params.date)
            .map { Result.success(it) }
            .catch { emit(Result.failure(it)) }

    data class Params(val query: String, val date: LocalDate)
}
```

### Repository Interface (Domain Contract)

```kotlin
// :core:data
interface FlightRepository {
    fun getFlights(origin: String, destination: String): Flow<List<Flight>>
    fun searchFlights(query: String, date: LocalDate): Flow<List<Flight>>
    suspend fun bookFlight(flightId: String): Result<Booking>
}
```

## Data Layer

The data layer implements repository interfaces. It coordinates between remote
(API) and local (Room) data sources.

### Repository Implementation

```kotlin
class FlightRepositoryImpl @Inject constructor(
    private val remoteDataSource: FlightRemoteDataSource,
    private val localDataSource: FlightLocalDataSource,
    private val flightMapper: FlightMapper,
) : FlightRepository {

    override fun getFlights(origin: String, destination: String): Flow<List<Flight>> =
        localDataSource.getFlights(origin, destination)
            .map { entities -> entities.map(flightMapper::toDomain) }

    override fun searchFlights(query: String, date: LocalDate): Flow<List<Flight>> = flow {
        val dtos = remoteDataSource.searchFlights(query, date.toString())
        localDataSource.insertFlights(dtos.map(flightMapper::toEntity))
        emitAll(
            localDataSource.getFlights(query, query)
                .map { entities -> entities.map(flightMapper::toDomain) }
        )
    }

    override suspend fun bookFlight(flightId: String): Result<Booking> = runCatching {
        val response = remoteDataSource.bookFlight(flightId)
        flightMapper.toBookingDomain(response)
    }
}
```

### Data Sources

```kotlin
// Remote
class FlightRemoteDataSource @Inject constructor(
    private val api: FlightApiService,
) {
    suspend fun searchFlights(query: String, date: String): List<FlightDto> =
        api.searchFlights(query, date)

    suspend fun bookFlight(flightId: String): BookingResponse =
        api.bookFlight(BookingRequest(flightId))
}

// Local
class FlightLocalDataSource @Inject constructor(
    private val dao: FlightDao,
) {
    fun getFlights(origin: String, destination: String): Flow<List<FlightEntity>> =
        dao.getFlights(origin, destination)

    suspend fun insertFlights(entities: List<FlightEntity>) =
        dao.upsertFlights(entities)
}
```

## Presentation Layer

ViewModels expose UI state as `StateFlow` and handle user actions.

```kotlin
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getFlightsUseCase: GetFlightsUseCase,
) : ViewModel() {

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

    fun loadFlights(origin: String, destination: String) {
        viewModelScope.launch {
            getFlightsUseCase(origin, destination)
                .map<List<Flight>, HomeUiState> { HomeUiState.Success(it) }
                .catch { emit(HomeUiState.Error(it.message ?: "Unknown error")) }
                .collect { _uiState.value = it }
        }
    }
}

sealed interface HomeUiState {
    data object Loading : HomeUiState
    data class Success(val flights: List<Flight>) : HomeUiState
    data class Error(val message: String) : HomeUiState
}
```

## Hilt Dependency Injection

### Application Setup

```kotlin
// :app
@HiltAndroidApp
class MyApplication : Application()

// MainActivity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { AppNavHost() }
    }
}
```

### Module with @Binds (Interface Binding)

```kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindFlightRepository(
        impl: FlightRepositoryImpl,
    ): FlightRepository
}
```

### Module with @Provides (Instance Creation)

```kotlin
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient =
        OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
            .build()

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder()
            .baseUrl(BuildConfig.BASE_URL)
            .client(client)
            .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
            .build()

    @Provides
    @Singleton
    fun provideFlightApiService(retrofit: Retrofit): FlightApiService =
        retrofit.create(FlightApiService::class.java)
}
```

### DispatcherProvider (Testable Dispatchers)

```kotlin
// :core:common
interface DispatcherProvider {
    val main: CoroutineDispatcher
    val io: CoroutineDispatcher
    val default: CoroutineDispatcher
}

class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider {
    override val main: CoroutineDispatcher = Dispatchers.Main
    override val io: CoroutineDispatcher = Dispatchers.IO
    override val default: CoroutineDispatcher = Dispatchers.Default
}

@Module
@InstallIn(SingletonComponent::class)
abstract class DispatcherModule {
    @Binds
    @Singleton
    abstract fun bindDispatcherProvider(impl: DefaultDispatcherProvider): DispatcherProvider
}
```

## Mapper Pattern

Mappers isolate layer boundaries. Each layer has its own model type.

```kotlin
class FlightMapper @Inject constructor() {

    fun toDomain(entity: FlightEntity): Flight = Flight(
        id = entity.id,
        origin = Airport(entity.originCode, entity.originName),
        destination = Airport(entity.destinationCode, entity.destinationName),
        departureTime = Instant.parse(entity.departureTime),
        arrivalTime = Instant.parse(entity.arrivalTime),
        price = Price(entity.priceAmount, entity.priceCurrency),
        status = FlightStatus.valueOf(entity.status),
    )

    fun toEntity(dto: FlightDto): FlightEntity = FlightEntity(
        id = dto.id,
        originCode = dto.origin.code,
        originName = dto.origin.name,
        destinationCode = dto.destination.code,
        destinationName = dto.destination.name,
        departureTime = dto.departureTime,
        arrivalTime = dto.arrivalTime,
        priceAmount = dto.price.amount,
        priceCurrency = dto.price.currency,
        status = dto.status,
    )

    fun toBookingDomain(response: BookingResponse): Booking = Booking(
        id = response.bookingId,
        confirmationCode = response.confirmationCode,
        status = BookingStatus.valueOf(response.status),
    )
}
```

## Do's and Don'ts

### Do's
- Keep domain layer free of Android dependencies (no Context, no R references)
- Use `operator fun invoke` on use cases for clean call sites
- Expose `StateFlow` from ViewModels, not `LiveData` in new Compose projects
- Use `@Binds` for interface-to-implementation binding (generates less code than `@Provides`)
- Define a `DispatcherProvider` interface for testable coroutine dispatchers
- One use case per business operation
- Use `sealed interface` for UI state to guarantee exhaustive `when` handling

### Don'ts
- Do not let feature modules depend on each other directly
- Do not put Android framework classes in the domain layer
- Do not expose `MutableStateFlow` from ViewModels (expose read-only `StateFlow`)
- Do not inject `Activity` or `Fragment` into ViewModels
- Do not use `GlobalScope` -- always use `viewModelScope` or a structured scope
- Do not put business logic in ViewModels -- delegate to use cases
- Do not use `LiveData` for new Compose-first projects

## Troubleshooting

| Problem | Cause | Fix |
|---------|-------|-----|
| `MissingBinding` Hilt error | Missing `@Binds` or `@Provides` for a dependency | Add the binding in the correct Hilt module |
| `ComponentProcessingStep was unable to process` | Circular dependency or missing `@InstallIn` | Check module annotations and break circular chains with `@Lazy` or Provider |
| ViewModel not injected | Missing `@HiltViewModel` or `@AndroidEntryPoint` | Add both annotations |
| `IllegalStateException: LifecycleOwner` | Accessing lifecycle before `onCreate` | Collect flows in `repeatOnLifecycle` or use Compose's `collectAsStateWithLifecycle` |
| Module not found in multi-module | Missing `implementation(project(":core:..."))` | Add module dependency in `build.gradle.kts` |
| KSP not running Hilt processor | Missing KSP plugin or Hilt compiler dependency | Add `id("com.google.devtools.ksp")` and `ksp(libs.hilt.compiler)` |

## Review Checklist

- [ ] Domain models have no Android imports
- [ ] Use cases have single responsibility with `operator fun invoke`
- [ ] Repository interfaces are in domain/core, implementations in data
- [ ] ViewModels expose `StateFlow`, not `MutableStateFlow`
- [ ] UI state is a `sealed interface` with Loading/Success/Error
- [ ] Hilt modules use `@Binds` for interfaces, `@Provides` for instances
- [ ] Feature modules do not depend on each other
- [ ] Mappers separate DTO/Entity/Domain models
- [ ] Coroutine dispatchers are injectable via `DispatcherProvider`
- [ ] No `GlobalScope` usage; all coroutines use structured concurrency
