VoteChain, c’est mon challenge Kotlin Multiplatform : une app de vote blockchain, construite sans une ligne de Swift. Comme toute app qui parle à un smart contract, elle a besoin d’une configuration. Chez moi, elle s’appelle ElectionHubConfig
Une configuration de module blockchain, ça peut grossir vite : l’URL du nœud RPC, l’adresse du contrat, le logging, le projectId WalletConnect, son deep link de retour, la politique de retry, le timeout… et la liste s’allonge à chaque nouvelle feature. Et pour construire ce genre d’objet, le réflexe est d’implémenter le Builder pattern.
Appliqué à la lettre, ça donne ça :
val config = ElectionHubConfig.Builder()
.setRpcUrl(BuildConfig.SEPOLIA_RPC_URL)
.setContractAddress(BuildConfig.CONTRACT_ADDRESS)
.setLogging(BuildConfig.DEBUG)
.setWalletConnectProjectId(BuildConfig.REOWN_PROJECT_ID)
.setWalletConnectRedirect("votechain://request")
.setRetryPolicy(RetryPolicy.EXPONENTIAL)
.setTimeoutMs(30_000L)
.build()
Ce pattern date de 1994, du livre Design Patterns du Gang of Four. Il a été pensé pour Java, un langage qui n’avait ni paramètres nommés, ni valeurs par défaut, ni lambdas.
Kotlin a les trois. Dans cet article, je vais te montrer la manière moderne de construire des objets complexes : le DSL Kotlin type-safe. Tu l’utilises déjà tous les jours dans ton build.gradle.kts sans forcément savoir le construire. Et je vais aussi te montrer deux choses contre-intuitives, que tu ne verras dans aucun tutoriel.
Ce que le builder classique te coûte vraiment

Reprenons notre builder à sept .set(). Il a trois défauts concrets, que tu paies en production :
- L’oubli silencieux. Rien n’oblige l’appelant à fournir les paramètres obligatoires.
ElectionHubConfig.Builder().build()compile parfaitement. La suite dépend entièrement de l’implémentation : une exception à la construction ou un objet à moitié configuré qui compile, qui tourne… et qui ne fonctionne pas. - Le bruit visuel. Chaque ligne commence par
.set. L’information utile (quoi est configuré avec quelle valeur) disparaît sous le boilerplate. En code review, ton œil ne scanne plus, il survole. Et ce qu’on survole, on ne le vérifie pas. - La structure plate. Un builder classique ne sait exprimer qu’une liste de propriétés. Regarde les préfixes :
setWalletConnectProjectId,setWalletConnectRedirect. La hiérarchie de cette configuration (WalletConnect a ses options, le retry a les siennes) est écrasée dans des noms de méthodes à rallonge. Dès que la config gagne un niveau de profondeur, le builder plat triche avec le nommage, ou t’impose des builders de builders à assembler à la main.
La manière moderne : le DSL Kotlin type-safe
Voici l’API que je viserais pour cette même configuration :
val config = electionHubConfig(
rpcUrl = BuildConfig.SEPOLIA_RPC_URL,
contractAddress = BuildConfig.CONTRACT_ADDRESS,
) {
logging = BuildConfig.DEBUG
walletConnect {
projectId = BuildConfig.REOWN_PROJECT_ID
redirect = "votechain://request"
}
retry {
policy = RetryPolicy.EXPONENTIAL
timeoutMs = 30_000L
}
}
Même configuration. Mais ton œil la lit comme un fichier de configuration : la structure est visible, la hiérarchie est explicite, le bruit a disparu. WalletConnect et le Retry ont retrouvé leur profondeur naturelle, dans des blocs dédiés.
C’est ce que la documentation officielle Kotlin appelle un type-safe builder. C’est le pattern qui fait tourner l’écosystème Kotlin :
buildString { },buildList { },buildMap { }dans la bibliothèque standard- Les scripts Gradle en Kotlin DSL (
build.gradle.kts) que tu ouvres tous les jours - Et l’exemple ultime : Jetpack Compose, où toute ton UI est un DSL
Si tu écris du Kotlin en 2026, tu consommes des DSL en permanence. La vraie question : sais-tu en construire un ?
Comment ça marche : la lambda avec receiver
Toute la magie tient en une signature :
public fun electionHubConfig(
rpcUrl: String,
contractAddress: String,
block: ElectionHubConfigBuilder.() -> Unit = {},
): ElectionHubConfig
Regarde bien le type du dernier paramètre : ElectionHubConfigBuilder.() -> Unit. Ce n’est pas une lambda ordinaire. C’est une lambda avec receiver (function type with receiver).
Concrètement : à l’intérieur du bloc { }, le this implicite est une instance de ElectionHubConfigBuilder. C’est pour ça que tu peux écrire logging = true directement. En réalité ça équivaut à this.logging = true, sur un builder que tu ne vois jamais. C’est exactement ce qui se passe dans ton build.gradle.kts : dans le bloc dependencies { }, le this implicite est un objet que tu n’as jamais instancié toi-même.
L’implémentation complète de la fonction d’entrée tient en une ligne :
public fun electionHubConfig(
rpcUrl: String,
contractAddress: String,
block: ElectionHubConfigBuilder.() -> Unit = {},
): ElectionHubConfig = ElectionHubConfigBuilder(rpcUrl, contractAddress).apply(block).build()
On crée le builder, on lui applique le bloc de l’utilisateur, on appelle build(). Trois opérations, une ligne.
Et c’est ici que j’arrête le tutoriel. Parce que si tu as bien lu cette ligne, tu as vu quelque chose.
Ce que cache ton DSL
ElectionHubConfigBuilder(rpcUrl, contractAddress).apply(block).build()
Relis lentement. Un objet qu’on instancie, qu’on remplit pas à pas, puis un build() final. Il y a un Builder pattern complet là-dedans. Voici l’implémentation :
public class ElectionHubConfigBuilder internal constructor(
private val rpcUrl: String,
private val contractAddress: String,
) {
public var logging: Boolean = false
internal var walletConnectConfig: WalletConnectConfig? = null
internal var retryConfig: RetryConfig = RetryConfig.DEFAULT
public fun walletConnect(block: WalletConnectConfigBuilder.() -> Unit) {
walletConnectConfig = WalletConnectConfigBuilder().apply(block).build()
}
public fun retry(block: RetryConfigBuilder.() -> Unit) {
retryConfig = RetryConfigBuilder().apply(block).build()
}
public fun build(): ElectionHubConfig = ElectionHubConfig(
rpcUrl = rpcUrl,
contractAddress = contractAddress,
logging = logging,
walletConnect = walletConnectConfig,
retry = retryConfig,
)
}
Tout y est : le builder, ses propriétés mutables, ses valeurs par défaut pour l’optionnel, son build() final. Exactement ce que le Gang of Four décrivait en 1994. La différence :
- Le constructeur est
internal: personne n’instancie ce builder à la main. - La seule porte d’entrée publique est
electionHubConfig(...) { }. - Les
.set()ont disparu de la surface, remplacés par des propriétés. - Les sous-configurations sont des fonctions imbriquées, pas des builders à assembler soi-même.
Le Builder pattern est devenu un détail d’implémentation. La vraie compétence senior n’est pas de connaître les patterns. C’est de savoir ce qu’on expose et ce qu’on cache. Le junior apprend le Builder et le montre. Le senior utilise le Builder et le rend invisible.
À l’école — on t’enseigne les design patterns comme des formes à reproduire : tu reconnais le problème, tu appliques le diagramme UML. Ce qu’on ne t’enseigne pas, c’est que les patterns du GoF sont des réponses aux limites d’un langage. Le Builder existe parce que Java n’avait ni paramètres nommés, ni valeurs par défaut, ni lambdas avec receiver. Quand le langage évolue, le pattern ne disparaît pas : il migre de ton API publique vers ton implémentation privée. Savoir faire cette migration, c’est ça, passer senior.
Les deux détails qui séparent un DSL de prod d’un DSL de tutoriel

Retour d’expérience production. Ces deux points, je ne les ai pas appris dans la doc. Je les ai appris en maintenant des SDK chez Ledger, utilisés par des équipes que je ne connaissais pas, puis en les appliquant dans mon propre challenge KMP.
1. Les paramètres obligatoires : hors du bloc, pas dedans
Tu as remarqué que dans electionHubConfig(...), rpcUrl et contractAddress ne sont pas des propriétés du DSL ? Ils sont des paramètres de la fonction d’entrée. Ce n’est pas un hasard, c’est la règle :
Obligatoire = paramètre de fonction. Optionnel = propriété du DSL.
Une alternative que tu peux trouver c’est var rpcUrl: String by Delegates.notNull() à l’intérieur du builder. Si l’utilisateur oublie de le définir, le premier accès lève une IllegalStateException. C’est mieux qu’un builder Java classique. L’erreur est explicit, explose au runtime et évite un dysfonctionnement silencieux découvert plus tard.
Avec des paramètres de fonction, oublier rpcUrl n’est plus un bug : c’est une erreur de compilation. L’appelant ne peut littéralement pas se tromper. C’est le compilateur qui fait le travail.
2. @DslMarker : le garde-fou que beaucoup de DSL oublient
Quand tes blocs sont imbriqués, Kotlin résout les receivers en cascade. Résultat : à l’intérieur de walletConnect { }, l’utilisateur peut écrire logging = true et modifier le builder parent sans s’en rendre compte. Ça compile, ça tourne, et c’est un bug.
La parade tient en quatre lignes :
@DslMarker
public annotation class ElectionHubDsl
@ElectionHubDsl
public class ElectionHubConfigBuilder internal constructor(/* ... */) { /* ... */ }
@ElectionHubDsl
public class WalletConnectConfigBuilder internal constructor() { /* ... */ }
Avec @DslMarker, accéder au receiver parent depuis un bloc enfant devient une erreur de compilation. Compose fait exactement ça. Si ton DSL a plus d’un niveau de profondeur et n’a pas de @DslMarker, il n’est pas fini.
La vérité sur VoteChain
Maintenant que tu sais construire un DSL dans les règles de l’art, je te dois la vérité. Voici la vraie ElectionHubConfig, celle qui est dans le code de VoteChain aujourd’hui :
ElectionHubConfig(
rpcUrl = BuildConfig.SEPOLIA_RPC_URL,
contractAddress = BuildConfig.CONTRACT_ADDRESS,
)
Pas de DSL. Une data class de deux propriétés. Et c’est tout.
Deux paramètres obligatoires, zéro option, zéro hiérarchie : des paramètres nommés font le travail, en zéro ligne de code supplémentaire. Construire un DSL pour ça, ce serait de l’over engineering. Le DSL que je t’ai montré, c’est l’API que je viserai le jour où cette configuration grossira. Pas avant.
Savoir construire un DSL, c’est bien. Savoir quand ne pas le faire, c’est ça, être senior. La grille de décision :
- Deux ou trois paramètres ? Paramètres nommés + valeurs par défaut. Point.
- Objet immuable simple ? Une
data classsuffit. Un DSL par-dessus ne serait que du décor
Le DSL se justifie quand la configuration a de la profondeur (des sous-objets), de la variabilité (beaucoup d’optionnel) ou une surface publique (un SDK, une lib, un module partagé par plusieurs équipes).
Au moins une case cochée ? Fonce. Aucune ? Range-le
FAQ
Non, mais il a changé de place.
En Kotlin moderne, le builder devient un détail d’implémentation caché derrière une fonction DSL (buildString { }, electionHubConfig { }), au lieu d’être exposé dans l’API publique (Builder().setX().build()).
C’est une API de configuration construite avec des lambdas avec receiver (Builder.() -> Unit), qui permet d’écrire du code déclaratif vérifié par le compilateur.
C’est le mécanisme derrière buildString { }, le Gradle Kotlin DSL, Jetpack Compose etc…
Elle empêche un bloc imbriqué d’accéder implicitement au receiver de son bloc parent. Sans elle, un utilisateur peut modifier le mauvais objet sans erreur de compilation. Tout DSL avec des blocs imbriqués devrait l’utiliser.
Non. Pour une configuration simple, deux ou trois paramètres ou pas de hiérarchie…, des paramètres nommés avec valeurs par défaut suffisent.
Le DSL se justifie quand la configuration a de la profondeur, beaucoup d’options ou est exposé publiquement (SDK, librairie…).
Ce que tu sais faire maintenant
Tu passes tes journées à consommer des DSL (Gradle, Compose, buildString….). À partir d’aujourd’hui, tu sais aussi les construire : une lambda avec receiver, les paramètres obligatoires dans la signature, un @DslMarker sur les blocs imbriqués.
Tu sais ce qu’ils cachent : un Builder devenu détail d’implémentation. Et surtout, tu sais quand ne pas en écrire. La vraie ElectionHubConfig tient en deux propriétés, et est suffisant
La prochaine fois que tu croiseras un Builder().setX().build() en code review, tu sauras exactement quoi proposer à la place.
Et si tu te demandes ce que devient un DSL Kotlin quand il traverse la frontière iOS et qu’un dev Swift le consomme… c’est exactement le sujet du prochain article.
Sanders
➡️ Rejoins la newsletter. Reçois du contenu que tu ne trouveras nulle part ailleurs.