All skills

Build clean Android and Kotlin Multiplatform apps with clear modules, dependency rules, use cases, repositories, data sources, Room, SQLDelight, Ktor, Koin, or Hilt.

  • 1 file
  • 16.3 KB
  • Updated last month
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/android-clean-architecture-plus/skill

This session only. Nothing lands on disk.

SKILL.md

โ‰ˆ43 tokens always: the name and description. โ‰ˆ4.1k when used: this file.

Android Clean Architecture

This skill comes from ECC. Credit for the original work belongs to ECC.

Use this guide to build Android and Kotlin Multiplatform apps with clear parts and safe dependency rules.

Use This Skill When

Use this skill when you need to:

  • Plan modules for an Android or KMP app.
  • Add a use case, repository, or data source.
  • Move data between the domain, data, and UI layers.
  • Set up Koin or Hilt.
  • Use Room, SQLDelight, or Ktor in a layered app.
  • Fix code that mixes UI, app rules, and storage code.

Do not add layers only to make a small app look complex. A small app may start with fewer modules. Keep the same dependency rules inside its packages.

Core Rules

Follow these rules first:

  1. The domain layer holds app rules.
  2. The domain layer uses plain Kotlin.
  3. The data layer implements domain repository interfaces.
  4. The UI calls use cases. It does not call a database or web client.
  5. Data models do not leave the data layer.
  6. Dependencies point toward the domain layer.
  7. A module must not depend on a module that already depends on it.

Module Layout

Use this layout for a medium or large app:

project/
โ”œโ”€โ”€ app/                  # App entry point and dependency setup
โ”œโ”€โ”€ core/                 # Small shared types and tools
โ”œโ”€โ”€ domain/               # Models, use cases, and repository interfaces
โ”œโ”€โ”€ data/                 # Repository code, data sources, database, and network
โ”œโ”€โ”€ presentation/         # Screens, view models, UI state, and navigation
โ”œโ”€โ”€ design-system/        # Shared Compose parts, colors, text styles, and themes
โ””โ”€โ”€ feature/              # Optional modules for large app features
    โ”œโ”€โ”€ auth/
    โ”œโ”€โ”€ settings/
    โ””โ”€โ”€ profile/

Do not put unrelated code in core. A shared type belongs there only when two or more modules need it.

Allowed Dependencies

app -> presentation, domain, data, core
presentation -> domain, design-system, core
data -> domain, core
domain -> core, or nothing
design-system -> core, or nothing
core -> nothing

The domain module must not import Android, Room, SQLDelight, Ktor, Compose, Hilt, or UI types.

In KMP, put shared domain code in commonMain. Use platform source sets only for code that needs a platform API.

Domain Layer

Domain Models

Use plain Kotlin types. Do not add database, network, or UI tags.

data class Item(
    val id: String,
    val title: String,
    val description: String,
    val tags: List<String>,
    val status: Status,
    val category: String
)

enum class Status {
    DRAFT,
    ACTIVE,
    ARCHIVED
}

Keep domain models valid. Check bad input at the edge of the app or in a use case.

Repository Interfaces

Define repository interfaces in the domain layer. Keep them based on app needs, not database tables or web routes.

interface ItemRepository {
    suspend fun getItemsByCategory(category: String): AppResult<List<Item>>
    suspend fun saveItem(item: Item): AppResult<Unit>
    fun observeItems(): Flow<List<Item>>
}

Use Cases

Each use case should do one clear app task.

class GetItemsByCategoryUseCase(
    private val repository: ItemRepository
) {
    suspend operator fun invoke(
        category: String
    ): AppResult<List<Item>> {
        if (category.isBlank()) {
            return AppResult.Failure(AppError.InvalidInput("Category is required"))
        }

        return repository.getItemsByCategory(category.trim())
    }
}

Use Flow when values can change over time:

class ObserveItemsUseCase(
    private val repository: ItemRepository
) {
    operator fun invoke(): Flow<List<Item>> {
        return repository.observeItems()
    }
}

Do not create a use case that only hides one simple call unless it keeps the UI clean or gives one place for future app rules.

Error Handling

Use one result type across the domain and data layers. Do not mix Kotlin Result with a custom result type in the same feature.

sealed interface AppResult<out T> {
    data class Success<T>(val value: T) : AppResult<T>
    data class Failure(val error: AppError) : AppResult<Nothing>
}

sealed interface AppError {
    data class Network(val message: String) : AppError
    data class Database(val message: String) : AppError
    data class InvalidInput(val message: String) : AppError
    data object Unauthorized : AppError
    data object NotFound : AppError
    data object Unknown : AppError
}

Map low-level errors before they leave the data layer. Do not show raw server, SQL, or stack trace text to the user.

Do not catch CancellationException. It must keep moving up so coroutine cancel works.

private suspend fun <T> safeDataCall(
    block: suspend () -> T
): AppResult<T> {
    return try {
        AppResult.Success(block())
    } catch (error: CancellationException) {
        throw error
    } catch (error: IOException) {
        AppResult.Failure(
            AppError.Network(error.message ?: "Network request failed")
        )
    } catch (error: Exception) {
        AppResult.Failure(AppError.Unknown)
    }
}

Data Layer

Data Sources

Split local and remote work into focused types.

interface ItemLocalDataSource {
    suspend fun getByCategory(category: String): List<ItemEntity>
    suspend fun upsert(items: List<ItemEntity>)
    fun observeAll(): Flow<List<ItemEntity>>
}

interface ItemRemoteDataSource {
    suspend fun fetchItems(category: String): List<ItemDto>
}

Repository Code

The repository picks the data source, maps data, and applies cache rules.

class ItemRepositoryImpl(
    private val local: ItemLocalDataSource,
    private val remote: ItemRemoteDataSource
) : ItemRepository {

    override suspend fun getItemsByCategory(
        category: String
    ): AppResult<List<Item>> {
        return safeDataCall {
            val remoteItems = remote.fetchItems(category)
            local.upsert(remoteItems.map(ItemDto::toEntity))
            local.getByCategory(category).map(ItemEntity::toDomain)
        }
    }

    override suspend fun saveItem(item: Item): AppResult<Unit> {
        return safeDataCall {
            local.upsert(listOf(item.toEntity()))
        }
    }

    override fun observeItems(): Flow<List<Item>> {
        return local.observeAll().map { items ->
            items.map(ItemEntity::toDomain)
        }
    }
}

State the cache rule in code or docs. For example:

  • Remote first, then save and read local data.
  • Local first, then refresh in the background.
  • Local only when offline.

If the remote call fails, decide if old local data is safe to return. Do not make this choice by accident.

Mappers

Keep mappers near the data types they map.

fun ItemEntity.toDomain(): Item {
    return Item(
        id = id,
        title = title,
        description = description,
        tags = tags.split("|").filter(String::isNotBlank),
        status = status.toStatus(),
        category = category
    )
}

fun ItemDto.toEntity(): ItemEntity {
    return ItemEntity(
        id = id,
        title = title,
        description = description,
        tags = tags.joinToString("|"),
        status = status,
        category = category
    )
}

fun Item.toEntity(): ItemEntity {
    return ItemEntity(
        id = id,
        title = title,
        description = description,
        tags = tags.joinToString("|"),
        status = status.name,
        category = category
    )
}

private fun String.toStatus(): Status {
    return Status.entries.firstOrNull { it.name == this } ?: Status.DRAFT
}

Handle unknown enum values and missing fields. Old saved data or a new server value must not crash the app.

A text separator is simple, but it can break if a tag contains that mark. Use a linked table or JSON when tags may contain any text.

Room for Android

@Entity(tableName = "items")
data class ItemEntity(
    @PrimaryKey val id: String,
    val title: String,
    val description: String,
    val tags: String,
    val status: String,
    val category: String
)

@Dao
interface ItemDao {
    @Query("SELECT * FROM items WHERE category = :category")
    suspend fun getByCategory(category: String): List<ItemEntity>

    @Upsert
    suspend fun upsert(items: List<ItemEntity>)

    @Query("SELECT * FROM items")
    fun observeAll(): Flow<List<ItemEntity>>
}

Add a database migration when the schema changes. Test the migration before release.

Run large reads, writes, and mapping work off the main thread. Room handles suspend DAO calls, but other work may still need a worker dispatcher.

SQLDelight for KMP

CREATE TABLE ItemEntity (
    id TEXT NOT NULL PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT NOT NULL,
    tags TEXT NOT NULL,
    status TEXT NOT NULL,
    category TEXT NOT NULL
);

getByCategory:
SELECT *
FROM ItemEntity
WHERE category = ?;

upsert:
INSERT OR REPLACE INTO ItemEntity (
    id,
    title,
    description,
    tags,
    status,
    category
)
VALUES (?, ?, ?, ?, ?, ?);

observeAll:
SELECT * FROM ItemEntity;

Create the database driver in platform code. Pass it into shared code through an interface or setup function.

Add migration files when the schema changes. Do not delete user data to avoid a migration.

Ktor for KMP

class KtorItemRemoteDataSource(
    private val client: HttpClient
) : ItemRemoteDataSource {

    override suspend fun fetchItems(
        category: String
    ): List<ItemDto> {
        return client.get("api/items") {
            parameter("category", category)
        }.body()
    }
}
val httpClient = HttpClient {
    install(ContentNegotiation) {
        json(
            Json {
                ignoreUnknownKeys = true
                explicitNulls = false
            }
        )
    }

    defaultRequest {
        url("https://api.example.com/")
    }
}

Set time limits for requests. Check HTTP status codes. Add auth in one shared place.

Do not log auth headers, cookies, request bodies with private data, or full user data. Turn detailed network logs off in release builds.

Dependency Setup

Pick one tool for a given app. Koin works well in shared KMP code. Hilt is for Android code.

Koin

val domainModule = module {
    factory { GetItemsByCategoryUseCase(get()) }
    factory { ObserveItemsUseCase(get()) }
}

val dataModule = module {
    single<ItemRepository> {
        ItemRepositoryImpl(
            local = get(),
            remote = get()
        )
    }
    single<ItemLocalDataSource> { RoomItemLocalDataSource(get()) }
    single<ItemRemoteDataSource> { KtorItemRemoteDataSource(get()) }
}

val presentationModule = module {
    viewModelOf(::ItemListViewModel)
}

Use single for shared state and costly objects, such as a database or HTTP client. Use factory for small objects that do not hold shared state.

Hilt

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

    @Binds
    abstract fun bindItemRepository(
        implementation: ItemRepositoryImpl
    ): ItemRepository
}

@HiltViewModel
class ItemListViewModel @Inject constructor(
    private val getItems: GetItemsByCategoryUseCase
) : ViewModel()

Put Hilt tags and modules outside the domain layer.

UI Layer

A view model turns domain results into UI state.

data class ItemListState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val errorText: String? = null
)

class ItemListViewModel(
    private val getItems: GetItemsByCategoryUseCase
) : ViewModel() {

    private val _state = MutableStateFlow(ItemListState())
    val state: StateFlow<ItemListState> = _state.asStateFlow()

    fun load(category: String) {
        viewModelScope.launch {
            _state.update {
                it.copy(isLoading = true, errorText = null)
            }

            when (val result = getItems(category)) {
                is AppResult.Success -> _state.update {
                    it.copy(
                        isLoading = false,
                        items = result.value
                    )
                }

                is AppResult.Failure -> _state.update {
                    it.copy(
                        isLoading = false,
                        errorText = result.error.toUserText()
                    )
                }
            }
        }
    }
}

The UI should show loading, empty, success, and error states. Stop old work when a new search or load makes it stale.

Do not store an Android Context, screen, or view in a view model.

Gradle Setup for KMP

Use a build setup plugin when many modules share the same Gradle code.

// build-logic/src/main/kotlin/kmp-library.gradle.kts
plugins {
    id("org.jetbrains.kotlin.multiplatform")
}

kotlin {
    androidTarget()
    iosX64()
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

Apply it in a module:

// domain/build.gradle.kts
plugins {
    id("kmp-library")
}

Keep platform-only libraries out of commonMain.

Concrete Usage Example

Task: Add a screen that lists items in the books category.

  1. Add Item and ItemRepository to domain.
  2. Add GetItemsByCategoryUseCase to domain.
  3. Add ItemDto, ItemEntity, and their mappers to data.
  4. Add local and remote data sources to data.
  5. Add ItemRepositoryImpl to data.
  6. Bind ItemRepositoryImpl to ItemRepository.
  7. Give the use case to ItemListViewModel.
  8. Call viewModel.load("books") from the UI.
  9. Show loading, empty, item, and error states.
  10. Test the use case with a fake repository.
class FakeItemRepository(
    private val items: List<Item>
) : ItemRepository {

    override suspend fun getItemsByCategory(
        category: String
    ): AppResult<List<Item>> {
        return AppResult.Success(
            items.filter { it.category == category }
        )
    }

    override suspend fun saveItem(
        item: Item
    ): AppResult<Unit> {
        return AppResult.Success(Unit)
    }

    override fun observeItems(): Flow<List<Item>> {
        return flowOf(items)
    }
}

@Test
fun returns_only_items_in_the_given_category() = runTest {
    val repository = FakeItemRepository(
        listOf(
            Item("1", "Book", "", emptyList(), Status.ACTIVE, "books"),
            Item("2", "Game", "", emptyList(), Status.ACTIVE, "games")
        )
    )
    val useCase = GetItemsByCategoryUseCase(repository)

    val result = useCase("books")

    assertEquals(
        listOf("1"),
        (result as AppResult.Success).value.map(Item::id)
    )
}

Edge Cases to Check

Before the work is done, check these cases:

  • Blank IDs or category names.
  • Empty lists.
  • No network.
  • Slow or timed-out requests.
  • Bad server data.
  • Unknown enum values.
  • Missing saved fields after an app update.
  • Two writes at the same time.
  • A new request starts before the old one ends.
  • The screen closes during a request.
  • Old cache data exists.
  • A database migration fails.
  • The user is not signed in.
  • A flow throws an error.
  • A large list uses too much memory.
  • A tag contains the chosen text separator.

Avoid These Mistakes

  • Importing Android or other framework types in domain.
  • Sending database rows or network DTOs to the UI.
  • Putting app rules in a view model.
  • Calling Room, SQLDelight, or Ktor from the UI.
  • Using GlobalScope.
  • Catching and hiding CancellationException.
  • Making one repository handle many unrelated features.
  • Using core as a place for random code.
  • Creating a module cycle.
  • Logging private data or auth values.
  • Replacing the database when a migration is needed.
  • Adding layers that have no clear job.

Done Check

A change is ready when:

  • Module dependencies follow the allowed direction.
  • Domain code uses plain Kotlin.
  • Data types stay in the data layer.
  • Errors are mapped to app errors.
  • Cancel still works.
  • The UI handles loading, empty, success, and error states.
  • Key app rules have unit tests.
  • Database changes have migration tests.
  • No private data is written to logs.

Related Skills

Use compose-multiplatform-patterns for UI patterns.

Use kotlin-coroutines-flows for coroutine and Flow patterns.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 5f80183. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Activeupdated last month
origin
ECC

README badge

README badge for agenticluke/android-clean-architecture-plus