---
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."
metadata:
  source: multi-agent-pipeline
---

# 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)
- [Feature api/impl Split](#feature-apiimpl-split)
- [Domain Layer](#domain-layer)
- [Data Layer](#data-layer)
- [Presentation Layer](#presentation-layer)
- [Hilt Dependency Injection](#hilt-dependency-injection)
- [Cross-Module Navigation Graph Aggregation](#cross-module-navigation-graph-aggregation)
- [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")
```

## Feature api/impl Split

For features that navigate to each other, split each one into two modules:

- `:feature:x:api` -- only the cross-feature contract: `@Serializable` route
  definitions and the entry-param models a caller needs to launch the feature.
- `:feature:x:impl` -- screens, ViewModels, UI state, and DI wiring.

A feature depends only on the `:api` of the features it navigates to, never
their `:impl`:

```kotlin
// feature:home:impl/build.gradle.kts
dependencies {
    implementation(project(":feature:home:api"))
    implementation(project(":feature:detail:api"))   // route + entry params only
    // NEVER depend on :feature:detail:impl
}
```

Because no `impl` module can see another `impl`, they stay mutually invisible,
compile in parallel, and cannot form dependency cycles. The `:app` module wires
them together (see
[Cross-Module Navigation Graph Aggregation](#cross-module-navigation-graph-aggregation)).
Full module layout and route contract:
[references/patterns.md#feature-apiimpl-split](references/patterns.md#feature-apiimpl-split).

## 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 Order(
    val id: String,
    val store: Location,
    val region: Location,
    val placedAt: Instant,
    val deliveredAt: Instant,
    val price: Price,
    val status: OrderStatus,
)

enum class OrderStatus { PLACED, SHIPPED, CANCELLED, DELIVERED }

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 GetOrdersUseCase @Inject constructor(
    private val orderRepository: OrderRepository,
) {
    operator fun invoke(store: String, region: String): Flow<List<Order>> =
        orderRepository.getOrders(store, region)
}
```

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

```kotlin
class SearchOrdersUseCase @Inject constructor(
    private val repository: OrderRepository,
) {
    operator fun invoke(params: Params): Flow<Result<List<Order>>> =
        repository.searchOrders(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 OrderRepository {
    fun getOrders(store: String, region: String): Flow<List<Order>>
    fun searchOrders(query: String, date: LocalDate): Flow<List<Order>>
    suspend fun payOrder(orderId: String): Result<Payment>
}
```

## Data Layer

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

### Repository Implementation

```kotlin
class OrderRepositoryImpl @Inject constructor(
    private val remoteDataSource: OrderRemoteDataSource,
    private val localDataSource: OrderLocalDataSource,
    private val orderMapper: OrderMapper,
) : OrderRepository {

    override fun getOrders(store: String, region: String): Flow<List<Order>> =
        localDataSource.getOrders(store, region)
            .map { entities -> entities.map(orderMapper::toDomain) }

    override fun searchOrders(query: String, date: LocalDate): Flow<List<Order>> = flow {
        val dtos = remoteDataSource.searchOrders(query, date.toString())
        localDataSource.insertOrders(dtos.map(orderMapper::toEntity))
        emitAll(
            localDataSource.getOrders(query, query)
                .map { entities -> entities.map(orderMapper::toDomain) }
        )
    }

    override suspend fun payOrder(orderId: String): Result<Payment> = runCatching {
        val response = remoteDataSource.payOrder(orderId)
        orderMapper.toPaymentDomain(response)
    }
}
```

### Data Sources

```kotlin
// Remote
class OrderRemoteDataSource @Inject constructor(
    private val api: OrderApiService,
) {
    suspend fun searchOrders(query: String, date: String): List<OrderDto> =
        api.searchOrders(query, date)

    suspend fun payOrder(orderId: String): PaymentResponse =
        api.payOrder(PaymentRequest(orderId))
}

// Local
class OrderLocalDataSource @Inject constructor(
    private val dao: OrderDao,
) {
    fun getOrders(store: String, region: String): Flow<List<OrderEntity>> =
        dao.getOrders(store, region)

    suspend fun insertOrders(entities: List<OrderEntity>) =
        dao.upsertOrders(entities)
}
```

## Presentation Layer

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

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

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

    fun loadOrders(store: String, region: String) {
        viewModelScope.launch {
            getOrdersUseCase(store, region)
                .map<List<Order>, 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 orders: List<Order>) : HomeUiState
    data class Error(val message: String) : HomeUiState
}
```

For the reusable base ViewModel seam these screen ViewModels extend -- immutable
state as `StateFlow`, one-shot events, a single intent entry point, and
centralized use-case execution -- see the `android-mvi-viewmodel` skill. Do not
reimplement that base class here.

## 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 bindOrderRepository(
        impl: OrderRepositoryImpl,
    ): OrderRepository
}
```

### 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 provideOrderApiService(retrofit: Retrofit): OrderApiService =
        retrofit.create(OrderApiService::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
}
```

## Cross-Module Navigation Graph Aggregation

Each `:impl` module contributes its destinations to a shared `Set<NavGraph>` via
Hilt multibinding; the host registers the whole set into one `NavHost`. The
`:app` module never depends on any `:impl`.

```kotlin
fun interface NavGraph {
    fun NavGraphBuilder.build(navController: NavHostController)
}

// In each :impl module
@Module
@InstallIn(SingletonComponent::class)
abstract class HomeNavModule {
    @Binds
    @IntoSet
    abstract fun bindHomeNavGraph(impl: HomeNavGraph): NavGraph
}

// In :app -- register every contributed graph, naming no feature
NavHost(navController, startDestination = HomeRoute) {
    navGraphs.forEach { graph -> with(graph) { build(navController) } }
}
```

This wires many feature modules into a single `NavHost` without the app module
depending on every `impl`. Full host injection and per-feature contribution:
[references/patterns.md#cross-module-navigation-graph-aggregation](references/patterns.md#cross-module-navigation-graph-aggregation).

## Mapper Pattern

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

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

    fun toDomain(entity: OrderEntity): Order = Order(
        id = entity.id,
        store = Location(entity.storeCode, entity.storeName),
        region = Location(entity.regionCode, entity.regionName),
        placedAt = Instant.parse(entity.placedAt),
        deliveredAt = Instant.parse(entity.deliveredAt),
        price = Price(entity.priceAmount, entity.priceCurrency),
        status = OrderStatus.valueOf(entity.status),
    )

    fun toEntity(dto: OrderDto): OrderEntity = OrderEntity(
        id = dto.id,
        storeCode = dto.store.code,
        storeName = dto.store.name,
        regionCode = dto.region.code,
        regionName = dto.region.name,
        placedAt = dto.placedAt,
        deliveredAt = dto.deliveredAt,
        priceAmount = dto.price.amount,
        priceCurrency = dto.price.currency,
        status = dto.status,
    )

    fun toPaymentDomain(response: PaymentResponse): Payment = Payment(
        id = response.paymentId,
        confirmationCode = response.confirmationCode,
        status = PaymentStatus.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
- Split each feature into `:api` (routes + entry params) and `:impl` (screens, ViewModels, DI)
- Aggregate feature nav graphs into the host with Hilt `@IntoSet` multibinding
- Let a repository implementation depend on data sources only; the remote data source owns the Retrofit service and unwraps the response envelope
- Run remote calls through one shared request helper (IO dispatcher, error mapping); leave local and in-memory calls unwrapped
- Map domain models to feature-local `@Immutable` UI models in the ViewModel (a domain class from another module is unstable in Compose)
- Default feature and data classes to `internal`; only routes and domain contracts are public

### Don'ts
- Do not let feature modules depend on each other directly
- Do not depend on another feature's `:impl` module -- depend on its `:api` only
- Do not make the `:app` module reference feature `impl` modules in code
- 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
- Do not inject a Retrofit `*ApiService` into a repository implementation, or let a DTO or `Response` type leave the data layer
- Do not call a use case outside the ViewModel's executor; that bypasses shared loading and error routing

## 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
- [ ] Each feature is split into `:api` (routes/entry params) and `:impl` (screens/ViewModels/DI)
- [ ] Feature `impl` modules depend only on other features' `api`, never their `impl`
- [ ] Feature nav graphs aggregate into the host via Hilt `@IntoSet` multibinding
- [ ] Mappers separate DTO/Entity/Domain models
- [ ] Repository implementations depend on data sources, never on the API service
- [ ] No DTO, `Response` or domain entity reaches a composable; UI models are feature-local
- [ ] Feature and data internals are `internal`
- [ ] Coroutine dispatchers are injectable via `DispatcherProvider`
- [ ] No `GlobalScope` usage; all coroutines use structured concurrency
