Android Clean Architecture
Modèles d'architecture Clean Architecture pour les projets Android et KMP. Couvre les limites des modules, l'inversion de dépendance, les motifs UseCase/Repository et la conception de la couche de données avec Room, SQLDelight et Ktor.
Quand activer
- Structure des modules d'un projet Android ou KMP
- Implémentation des UseCases, Repositories ou DataSources
- Conception du flux de données entre les couches (domaine, données, présentation)
- Mise en place de l'injection de dépendances avec Koin ou Hilt
- Travail avec Room, SQLDelight ou Ktor dans une architecture en couches
Structure des modules
Disposition recommandée
projet/
├── app/ # Point d'entrée Android, câblage DI, classe Application
├── core/ # Utilitaires partagés, classes de base, types d'erreur
├── domain/ # UseCases, modèles domaine, interfaces des repositories (Kotlin pur)
├── data/ # Implémentations des repositories, DataSources, base de données, réseau
├── presentation/ # Écrans, ViewModels, modèles UI, navigation
├── design-system/ # Composants Compose réutilisables, thème, typographie
└── feature/ # Modules fonctionnalités (optionnel, pour les grands projets)
├── auth/
├── settings/
└── profile/
Règles de dépendance
app → presentation, domain, data, core
presentation → domain, design-system, core
data → domain, core
domain → core (ou aucune dépendance)
core → (rien)
Critique : domain ne doit JAMAIS dépendre de data, presentation ou de tout framework. Il contient uniquement du Kotlin pur.
Couche Domaine
Motif UseCase
Chaque UseCase représente une opération métier. Utilisez operator fun invoke pour des sites d'appel propres :
class GetItemsByCategoryUseCase(
private val repository: ItemRepository
) {
suspend operator fun invoke(category: String): Result<List<Item>> {
return repository.getItemsByCategory(category)
}
}
// UseCase basé sur Flow pour les flux réactifs
class ObserveUserProgressUseCase(
private val repository: UserRepository
) {
operator fun invoke(userId: String): Flow<UserProgress> {
return repository.observeProgress(userId)
}
}
Modèles Domaine
Les modèles domaine sont des classes de données Kotlin simples — pas d'annotations de framework :
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 }
Interfaces des Repositories
Définies dans le domaine, implémentées dans les données :
interface ItemRepository {
suspend fun getItemsByCategory(category: String): Result<List<Item>>
suspend fun saveItem(item: Item): Result<Unit>
fun observeItems(): Flow<List<Item>>
}
Couche Données
Implémentation du Repository
Coordonne entre les sources de données locales et distantes :
class ItemRepositoryImpl(
private val localDataSource: ItemLocalDataSource,
private val remoteDataSource: ItemRemoteDataSource
) : ItemRepository {
override suspend fun getItemsByCategory(category: String): Result<List<Item>> {
return runCatching {
val remote = remoteDataSource.fetchItems(category)
localDataSource.insertItems(remote.map { it.toEntity() })
localDataSource.getItemsByCategory(category).map { it.toDomain() }
}
}
override suspend fun saveItem(item: Item): Result<Unit> {
return runCatching {
localDataSource.insertItems(listOf(item.toEntity()))
}
}
override fun observeItems(): Flow<List<Item>> {
return localDataSource.observeAll().map { entities ->
entities.map { it.toDomain() }
}
}
}
Motif Mapper
Gardez les mappers comme fonctions d'extension près des modèles de données :
// Dans la couche data
fun ItemEntity.toDomain() = Item(
id = id,
title = title,
description = description,
tags = tags.split("|"),
status = Status.valueOf(status),
category = category
)
fun ItemDto.toEntity() = ItemEntity(
id = id,
title = title,
description = description,
tags = tags.joinToString("|"),
status = status,
category = category
)
Base de données Room (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>>
}
SQLDelight (KMP)
-- Item.sq
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;
Client réseau Ktor (KMP)
class ItemRemoteDataSource(private val client: HttpClient) {
suspend fun fetchItems(category: String): List<ItemDto> {
return client.get("api/items") {
parameter("category", category)
}.body()
}
}
// Configuration HttpClient avec négociation de contenu
val httpClient = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(Logging) { level = LogLevel.HEADERS }
defaultRequest { url("https://api.example.com/") }
}
Injection de dépendances
Koin (compatible KMP)
// Module domaine
val domainModule = module {
factory { GetItemsByCategoryUseCase(get()) }
factory { ObserveUserProgressUseCase(get()) }
}
// Module données
val dataModule = module {
single<ItemRepository> { ItemRepositoryImpl(get(), get()) }
single { ItemLocalDataSource(get()) }
single { ItemRemoteDataSource(get()) }
}
// Module présentation
val presentationModule = module {
viewModelOf(::ItemListViewModel)
viewModelOf(::DashboardViewModel)
}
Hilt (Android uniquement)
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}
@HiltViewModel
class ItemListViewModel @Inject constructor(
private val getItems: GetItemsByCategoryUseCase
) : ViewModel()
Gestion des erreurs
Motif Result/Try
Utilisez Result<T> ou un type sealed personnalisé pour propager les erreurs :
sealed interface Try<out T> {
data class Success<T>(val value: T) : Try<T>
data class Failure(val error: AppError) : Try<Nothing>
}
sealed interface AppError {
data class Network(val message: String) : AppError
data class Database(val message: String) : AppError
data object Unauthorized : AppError
}
// Dans le ViewModel — mapper vers l'état UI
viewModelScope.launch {
when (val result = getItems(category)) {
is Try.Success -> _state.update { it.copy(items = result.value, isLoading = false) }
is Try.Failure -> _state.update { it.copy(error = result.error.toMessage(), isLoading = false) }
}
}
Plugins de convention (Gradle)
Pour les projets KMP, utilisez des plugins de convention pour réduire la duplication des fichiers de construction :
// build-logic/src/main/kotlin/kmp-library.gradle.kts
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
kotlin {
androidTarget()
iosX64(); iosArm64(); iosSimulatorArm64()
sourceSets {
commonMain.dependencies { /* dépendances partagées */ }
commonTest.dependencies { implementation(kotlin("test")) }
}
}
Appliquez dans les modules :
// domain/build.gradle.kts
plugins { id("kmp-library") }
Anti-patrons à éviter
- Importer des classes du framework Android dans
domain— gardez-le en Kotlin pur - Exposer les entités de base de données ou DTO à la couche UI — mappez toujours vers les modèles domaine
- Mettre la logique métier dans les ViewModels — extrayez-la dans des UseCases
- Utiliser
GlobalScopeou des coroutines non structurées — utilisezviewModelScopeou la concurrence structurée - Implémentations de repositories trop volumineuses — scindez en DataSources ciblées
- Dépendances circulaires entre modules — si A dépend de B, B ne doit pas dépendre de A
Références
Voir la compétence : compose-multiplatform-patterns pour les motifs UI.
Voir la compétence : kotlin-coroutines-flows pour les motifs asynchrones.