Patrons de Développement Kotlin
Patrons idiomatiques Kotlin 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 avec Gradle Kotlin DSL
Comment ça marche
Ce skill applique des conventions idiomatiques Kotlin 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 classes de données, les classes et interfaces scellées pour des hiérarchies exhaustives, la concurrence structurée avec coroutines et Flow, les fonctions d'extension pour ajouter du comportement sans héritage, les builders DSL type-safe utilisant @DslMarker et les récepteurs lambda, et le DSL Kotlin Gradle 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 scellée 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 nullable et non-nullable. Exploitez-le pleinement.
// Bon : Utiliser des 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 : Forcer le déballage de types nullables
fun getUserEmail(userId: String): String {
val user = userRepository.findById(userId)
return user!!.email // Lève une 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 Mono-Expression
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. Classes de Données pour les Objets Valeur
Utilisez des classes de données pour les types qui contiennent principalement des données.
// Bon : Classe de données avec copy, equals, hashCode, toString
data class CreateUserRequest(
val name: String,
val email: String,
val role: Role = Role.USER,
)
// Bon : Classe valeur 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 Scellées
Modélisation de Hiérarchies Restreintes
// Bon : Classe scellée 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("Encore en chargement")
}
Interfaces Scellées 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 mis en 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 secondaires (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 collection
fun <T> List<T>.second(): T = this[1]
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)
// Bon : Extensions limitées (ne pollue pas l'espace de noms 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 : Flux froid avec gestion d'erreurs 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 : Respecter 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é
// 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'interface
class LoggingUserRepository(
private val delegate: UserRepository,
private val logger: Logger,
) : UserRepository by delegate {
// Ne surcharger que ce qui est nécessaire pour ajouter de la journalisation
override suspend fun findById(id: String): User? {
logger.info("Recherche d'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 les sequences pour les grandes collections avec de multiples 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érifiez 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.
...
[truncated for translation]