Modèles de développement Kotlin
Modèles Kotlin idiomatiques et meilleures pratiques pour construire des applications robustes, efficaces et maintenables.
Quand l'utiliser
- Écrire du nouveau code Kotlin
- Réviser du code Kotlin
- Refactoriser du code Kotlin existant
- Concevoir des modules ou bibliothèques Kotlin
- Configurer des builds Gradle Kotlin DSL
Comment ça fonctionne
Ce skill applique des conventions Kotlin idiomatiques dans sept domaines clés : la sécurité null via le système de types et les opérateurs d'appel sécurisé, l'immuabilité via val et copy() sur les data classes, les classes et interfaces sealed pour des hiérarchies de types exhaustives, la concurrence structurée avec coroutines et Flow, les fonctions d'extension pour ajouter des comportements sans héritage, les builders DSL type-safe utilisant @DslMarker et les récepteurs lambda, et le Gradle Kotlin DSL pour la configuration de build.
Exemples
Sécurité null avec l'opérateur Elvis :
fun getUserEmail(userId: String): String {
val user = userRepository.findById(userId)
return user?.email ?: "[email protected]"
}
Classe sealed pour des résultats exhaustifs :
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val error: AppError) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
Concurrence structurée avec async/await :
suspend fun fetchUserWithPosts(userId: String): UserProfile =
coroutineScope {
val user = async { userService.getUser(userId) }
val posts = async { postService.getUserPosts(userId) }
UserProfile(user = user.await(), posts = posts.await())
}
Principes fondamentaux
1. Sécurité null
Le système de types de Kotlin distingue les types nullables et non nullables. Exploitez-le pleinement.
// Bon : Utiliser les types non nullables par défaut
fun getUser(id: String): User {
return userRepository.findById(id)
?: throw UserNotFoundException("Utilisateur $id non trouvé")
}
// Bon : Appels sécurisés et opérateur Elvis
fun getUserEmail(userId: String): String {
val user = userRepository.findById(userId)
return user?.email ?: "[email protected]"
}
// Mauvais : Forçage de déballage de types nullables
fun getUserEmail(userId: String): String {
val user = userRepository.findById(userId)
return user!!.email // Provoque un NPE si null
}
2. Immuabilité par défaut
Préférez val à var, les collections immuables aux collections mutables.
// Bon : Données immuables
data class User(
val id: String,
val name: String,
val email: String,
)
// Bon : Transformer avec copy()
fun updateEmail(user: User, newEmail: String): User =
user.copy(email = newEmail)
// Bon : Collections immuables
val users: List<User> = listOf(user1, user2)
val filtered = users.filter { it.email.isNotBlank() }
// Mauvais : État mutable
var currentUser: User? = null // Éviter l'état global mutable
val mutableUsers = mutableListOf<User>() // Éviter sauf si vraiment nécessaire
3. Corps d'expression et fonctions à expression unique
Utilisez des corps d'expression pour des fonctions concises et lisibles.
// Bon : Corps d'expression
fun isAdult(age: Int): Boolean = age >= 18
fun formatFullName(first: String, last: String): String =
"$first $last".trim()
fun User.displayName(): String =
name.ifBlank { email.substringBefore('@') }
// Bon : When comme expression
fun statusMessage(code: Int): String = when (code) {
200 -> "OK"
404 -> "Non trouvé"
500 -> "Erreur interne du serveur"
else -> "Statut inconnu : $code"
}
// Mauvais : Corps de bloc inutile
fun isAdult(age: Int): Boolean {
return age >= 18
}
4. Data classes pour les objets valeur
Utilisez des data classes pour les types qui stockent principalement des données.
// Bon : Data class avec copy, equals, hashCode, toString
data class CreateUserRequest(
val name: String,
val email: String,
val role: Role = Role.USER,
)
// Bon : Value class pour la sécurité de type (zéro surcharge à l'exécution)
@JvmInline
value class UserId(val value: String) {
init {
require(value.isNotBlank()) { "UserId ne peut pas être vide" }
}
}
@JvmInline
value class Email(val value: String) {
init {
require('@' in value) { "Email invalide : $value" }
}
}
fun getUser(id: UserId): User = userRepository.findById(id)
Classes et interfaces sealed
Modélisation de hiérarchies restreintes
// Bon : Classe sealed pour un when exhaustif
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val error: AppError) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
fun <T> Result<T>.getOrNull(): T? = when (this) {
is Result.Success -> data
is Result.Failure -> null
is Result.Loading -> null
}
fun <T> Result<T>.getOrThrow(): T = when (this) {
is Result.Success -> data
is Result.Failure -> throw error.toException()
is Result.Loading -> throw IllegalStateException("Chargement en cours")
}
Interfaces sealed pour les réponses API
sealed interface ApiError {
val message: String
data class NotFound(override val message: String) : ApiError
data class Unauthorized(override val message: String) : ApiError
data class Validation(
override val message: String,
val field: String,
) : ApiError
data class Internal(
override val message: String,
val cause: Throwable? = null,
) : ApiError
}
fun ApiError.toStatusCode(): Int = when (this) {
is ApiError.NotFound -> 404
is ApiError.Unauthorized -> 401
is ApiError.Validation -> 422
is ApiError.Internal -> 500
}
Fonctions de portée
Quand utiliser chacune
// let : Transformer un résultat nullable ou de portée
val length: Int? = name?.let { it.trim().length }
// apply : Configurer un objet (retourne l'objet)
val user = User().apply {
name = "Alice"
email = "[email protected]"
}
// also : Effets de bord (retourne l'objet)
val user = createUser(request).also { logger.info("Utilisateur créé : ${it.id}") }
// run : Exécuter un bloc avec récepteur (retourne le résultat)
val result = connection.run {
prepareStatement(sql)
executeQuery()
}
// with : Forme non-extension de run
val csv = with(StringBuilder()) {
appendLine("nom,email")
users.forEach { appendLine("${it.name},${it.email}") }
toString()
}
Anti-patrons
// Mauvais : Imbrication de fonctions de portée
user?.let { u ->
u.address?.let { addr ->
addr.city?.let { city ->
println(city) // Difficile à lire
}
}
}
// Bon : Enchaîner les appels sécurisés à la place
val city = user?.address?.city
city?.let { println(it) }
Fonctions d'extension
Ajouter des fonctionnalités sans héritage
// Bon : Extensions spécifiques au domaine
fun String.toSlug(): String =
lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
.trim('-')
fun Instant.toLocalDate(zone: ZoneId = ZoneId.systemDefault()): LocalDate =
atZone(zone).toLocalDate()
// Bon : Extensions de collections
fun <T> List<T>.second(): T = this[1]
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)
// Bon : Extensions limitées à une portée (ne polluent pas l'espace global)
class UserService {
private fun User.isActive(): Boolean =
status == Status.ACTIVE && lastLogin.isAfter(Instant.now().minus(30, ChronoUnit.DAYS))
fun getActiveUsers(): List<User> = userRepository.findAll().filter { it.isActive() }
}
Coroutines
Concurrence structurée
// Bon : Concurrence structurée avec coroutineScope
suspend fun fetchUserWithPosts(userId: String): UserProfile =
coroutineScope {
val userDeferred = async { userService.getUser(userId) }
val postsDeferred = async { postService.getUserPosts(userId) }
UserProfile(
user = userDeferred.await(),
posts = postsDeferred.await(),
)
}
// Bon : supervisorScope quand les enfants peuvent échouer indépendamment
suspend fun fetchDashboard(userId: String): Dashboard =
supervisorScope {
val user = async { userService.getUser(userId) }
val notifications = async { notificationService.getRecent(userId) }
val recommendations = async { recommendationService.getFor(userId) }
Dashboard(
user = user.await(),
notifications = try {
notifications.await()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
emptyList()
},
recommendations = try {
recommendations.await()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
emptyList()
},
)
}
Flow pour les flux réactifs
// Bon : Flow froid avec une gestion d'erreur appropriée
fun observeUsers(): Flow<List<User>> = flow {
while (currentCoroutineContext().isActive) {
val users = userRepository.findAll()
emit(users)
delay(5.seconds)
}
}.catch { e ->
logger.error("Erreur lors de l'observation des utilisateurs", e)
emit(emptyList())
}
// Bon : Opérateurs Flow
fun searchUsers(query: Flow<String>): Flow<List<User>> =
query
.debounce(300.milliseconds)
.distinctUntilChanged()
.filter { it.length >= 2 }
.mapLatest { q -> userRepository.search(q) }
.catch { emit(emptyList()) }
Annulation et nettoyage
// Bon : Respect de l'annulation
suspend fun processItems(items: List<Item>) {
items.forEach { item ->
ensureActive() // Vérifier l'annulation avant un travail coûteux
processItem(item)
}
}
// Bon : Nettoyage avec try/finally
suspend fun acquireAndProcess() {
val resource = acquireResource()
try {
resource.process()
} finally {
withContext(NonCancellable) {
resource.release() // Toujours libérer, même en cas d'annulation
}
}
}
Délégation
Délégation de propriétés
// Initialisation paresseuse
val expensiveData: List<User> by lazy {
userRepository.findAll()
}
// Propriété observable
var name: String by Delegates.observable("initial") { _, old, new ->
logger.info("Nom changé de '$old' à '$new'")
}
// Propriétés basées sur une Map
class Config(private val map: Map<String, Any?>) {
val host: String by map
val port: Int by map
val debug: Boolean by map
}
val config = Config(mapOf("host" to "localhost", "port" to 8080, "debug" to true))
Délégation d'interface
// Bon : Déléguer l'implémentation d'une interface
class LoggingUserRepository(
private val delegate: UserRepository,
private val logger: Logger,
) : UserRepository by delegate {
// Ne surcharger que ce qui est nécessaire pour ajouter du logging
override suspend fun findById(id: String): User? {
logger.info("Recherche de l'utilisateur par id : $id")
return delegate.findById(id).also {
logger.info("Utilisateur trouvé : ${it?.name ?: "null"}")
}
}
}
Builders DSL
Builders type-safe
// Bon : DSL avec @DslMarker
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HTML {
private val children = mutableListOf<Element>()
fun head(init: Head.() -> Unit) {
children += Head().apply(init)
}
fun body(init: Body.() -> Unit) {
children += Body().apply(init)
}
override fun toString(): String = children.joinToString("\n")
}
fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
// Utilisation
val page = html {
head { title("Ma Page") }
body {
h1("Bienvenue")
p("Bonjour le monde !")
}
}
DSL de configuration
data class ServerConfig(
val host: String = "0.0.0.0",
val port: Int = 8080,
val ssl: SslConfig? = null,
val database: DatabaseConfig? = null,
)
data class SslConfig(val certPath: String, val keyPath: String)
data class DatabaseConfig(val url: String, val maxPoolSize: Int = 10)
class ServerConfigBuilder {
var host: String = "0.0.0.0"
var port: Int = 8080
private var ssl: SslConfig? = null
private var database: DatabaseConfig? = null
fun ssl(certPath: String, keyPath: String) {
ssl = SslConfig(certPath, keyPath)
}
fun database(url: String, maxPoolSize: Int = 10) {
database = DatabaseConfig(url, maxPoolSize)
}
fun build(): ServerConfig = ServerConfig(host, port, ssl, database)
}
fun serverConfig(init: ServerConfigBuilder.() -> Unit): ServerConfig =
ServerConfigBuilder().apply(init).build()
// Utilisation
val config = serverConfig {
host = "0.0.0.0"
port = 443
ssl("/certs/cert.pem", "/certs/key.pem")
database("jdbc:postgresql://localhost:5432/mydb", maxPoolSize = 20)
}
Sequences pour l'évaluation paresseuse
// Bon : Utiliser des sequences pour les grandes collections avec plusieurs opérations
val result = users.asSequence()
.filter { it.isActive }
.map { it.email }
.filter { it.endsWith("@company.com") }
.take(10)
.toList()
// Bon : Générer des sequences infinies
val fibonacci: Sequence<Long> = sequence {
var a = 0L
var b = 1L
while (true) {
yield(a)
val next = a + b
a = b
b = next
}
}
val first20 = fibonacci.take(20).toList()
Gradle Kotlin DSL
Configuration build.gradle.kts
// Vérifier les dernières versions : https://kotlinlang.org/docs/releases.html
plugins {
kotlin("jvm") version "2.3.10"
kotlin("plugin.serialization") version "2.3.10"
id("io.ktor.plugin") version "3.4.0"
id("org.jetbrains.kotlinx.kover") version "0.9.7"
id("io.gitlab.arturbosch.detekt") version "1.23.8"
}
group = "com.example"
version = "1.0.0"
kotlin {
jvmToolchain(21)
}
dependencies {
// Ktor
implementation("io.ktor:ktor-server-core:3.4.0")
implementation("io.ktor:ktor-server-netty:3.4.0")
implementation("io.ktor:ktor-server-content-negotiation:3.4.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.0")
// Exposed
implementation("org.jetbrains.exposed:exposed-core:1.
…
[tronqué pour la traduction]