Android Clean Architecture
Patrons d'architecture propre (Clean Architecture) pour les projets Android et KMP. Couvre les limites de modules, l'inversion de dépendances, les patrons UseCase/Repository et la conception de la couche de données avec Room, SQLDelight et Ktor.
Quand activer
- Structurer des modules de projet Android ou KMP
- Implémenter des UseCases, Repositories ou DataSources
- Concevoir le flux de données entre les couches (domaine, données, présentation)
- Mettre en place l'injection de dépendances avec Koin ou Hilt
- Travailler avec Room, SQLDelight ou Ktor dans une architecture en couches
Structure des modules
Disposition recommandée
project/
├── 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 du domaine, interfaces Repository (Kotlin pur)
├── data/ # Implémentations Repository, 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épendances
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 d'aucun framework. Il contient uniquement du Kotlin pur.
Couche Domaine
Patron UseCase
Chaque UseCase représente une opération métier. Utilisez operator fun invoke pour des appels 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 du domaine
Les modèles du domaine sont des classes de données Kotlin simples — sans 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 Repository
Définies dans le domaine, implémentées dans la couche de 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 de Données
Implémentation Repository
Coordonne 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() }
}
}
}
Patron Mapper
Gardez les mappers comme fonctions d'extension près des modèles de données :
// Dans la couche de données
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 ContentNegotiation
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
Patron Result/Try
Utilisez Result<T> ou un type sealed personnalisé pour la propagation des 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 build :
// 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 DTOs à la couche UI — mappez toujours vers des modèles du 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 Repository trop chargées — divisez 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 le skill : compose-multiplatform-patterns pour les patrons UI.
Voir le skill : kotlin-coroutines-flows pour les patrons asynchrones.