Uma das funcionalidades mais poderosas do Kotlin é a capacidade de criar Domain-Specific Languages (DSLs) — mini-linguagens especializadas que tornam o código mais expressivo e legível. Neste tutorial, vamos explorar todos os recursos que fazem isso possível: builder pattern, lambda with receiver, a annotation @DslMarker, type-safe builders, e construir exemplos práticos do zero. Também vamos entender como o Gradle utiliza esses mesmos conceitos internamente.

O que é uma DSL?

Uma DSL (Domain-Specific Language) é uma linguagem projetada para um domínio específico. Diferente de uma linguagem de propósito geral como Kotlin ou Java, uma DSL oferece uma sintaxe focada que se lê quase como linguagem natural para aquele contexto. Exemplos clássicos incluem SQL para consultas a banco de dados, HTML para markup e expressões regulares para padrões de texto.

Em Kotlin, podemos criar DSLs internas — código Kotlin válido que se parece com uma linguagem customizada graças a recursos como extension functions, lambdas com receiver, operadores infix e conventions de nomeação. O resultado é uma API que é ao mesmo tempo type-safe e extremamente legível.

Passo 1: Entendendo Lambda with Receiver

O alicerce de qualquer DSL em Kotlin é a lambda with receiver. É uma lambda que executa no contexto de um objeto receptor, permitindo acessar suas propriedades e métodos sem qualificação:

// Tipo da lambda: StringBuilder.() -> Unit
fun construirString(bloco: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.bloco()  // ou bloco(sb)
    return sb.toString()
}

val resultado = construirString {
    append("Olá, ")      // 'this' é o StringBuilder
    append("Kotlin ")
    append("Brasil!")
}
println(resultado) // Olá, Kotlin Brasil!

Dentro da lambda, this referencia o StringBuilder. Como Kotlin permite omitir this, chamamos append() diretamente, criando uma sintaxe limpa e fluente. Esse é exatamente o padrão usado por apply, with e run da biblioteca padrão.

Passo 2: Builder Pattern com Lambdas

Vamos construir um builder para configurar um servidor HTTP. Primeiro, definimos as classes de dados:

data class ConfigServidor(
    val host: String,
    val porta: Int,
    val ssl: ConfigSSL?,
    val rotas: List<Rota>
)

data class ConfigSSL(
    val certificado: String,
    val chavePrivada: String
)

data class Rota(
    val caminho: String,
    val metodo: String,
    val handler: String
)

Agora, criamos os builders com lambdas with receiver:

class ServidorBuilder {
    var host: String = "localhost"
    var porta: Int = 8080
    private var ssl: ConfigSSL? = null
    private val rotas = mutableListOf<Rota>()

    fun ssl(bloco: SSLBuilder.() -> Unit) {
        ssl = SSLBuilder().apply(bloco).build()
    }

    fun rota(caminho: String, metodo: String = "GET", handler: String) {
        rotas.add(Rota(caminho, metodo, handler))
    }

    fun rotas(bloco: RotasBuilder.() -> Unit) {
        RotasBuilder(rotas).apply(bloco)
    }

    fun build(): ConfigServidor = ConfigServidor(host, porta, ssl, rotas)
}

class SSLBuilder {
    var certificado: String = ""
    var chavePrivada: String = ""
    fun build(): ConfigSSL = ConfigSSL(certificado, chavePrivada)
}

class RotasBuilder(private val rotas: MutableList<Rota>) {
    fun get(caminho: String, handler: String) {
        rotas.add(Rota(caminho, "GET", handler))
    }
    fun post(caminho: String, handler: String) {
        rotas.add(Rota(caminho, "POST", handler))
    }
    fun delete(caminho: String, handler: String) {
        rotas.add(Rota(caminho, "DELETE", handler))
    }
}

fun servidor(bloco: ServidorBuilder.() -> Unit): ConfigServidor {
    return ServidorBuilder().apply(bloco).build()
}

O uso final fica limpo e expressivo:

val config = servidor {
    host = "api.kotlinbrasil.com"
    porta = 443

    ssl {
        certificado = "/certs/server.crt"
        chavePrivada = "/certs/server.key"
    }

    rotas {
        get("/api/usuarios", "UsuarioController::listar")
        post("/api/usuarios", "UsuarioController::criar")
        delete("/api/usuarios/{id}", "UsuarioController::remover")
    }
}

Esse código parece uma linguagem de configuração dedicada, mas é Kotlin puro, com verificação de tipos e autocompletion do IDE.

Passo 3: @DslMarker para Escopo Controlado

Um problema com DSLs aninhadas é que lambdas internas podem acessar receivers externos, causando confusão. A annotation @DslMarker resolve isso restringindo o escopo:

@DslMarker
annotation class HtmlDsl

@HtmlDsl
class HTML {
    private val children = mutableListOf<HtmlElement>()

    fun head(bloco: Head.() -> Unit) {
        children.add(Head().apply(bloco))
    }

    fun body(bloco: Body.() -> Unit) {
        children.add(Body().apply(bloco))
    }

    override fun toString(): String = "<html>\n${children.joinToString("\n")}\n</html>"
}

@HtmlDsl
class Head : HtmlElement {
    var titulo: String = ""

    fun title(texto: String) { titulo = texto }

    override fun toString(): String = "  <head>\n    <title>$titulo</title>\n  </head>"
}

@HtmlDsl
class Body : HtmlElement {
    private val elementos = mutableListOf<String>()

    fun h1(texto: String) { elementos.add("    <h1>$texto</h1>") }
    fun p(texto: String) { elementos.add("    <p>$texto</p>") }
    fun div(bloco: Body.() -> Unit) {
        val inner = Body().apply(bloco)
        elementos.add("    <div>\n${inner.elementos.joinToString("\n") { "  $it" }}\n    </div>")
    }

    override fun toString(): String = "  <body>\n${elementos.joinToString("\n")}\n  </body>"
}

interface HtmlElement

fun html(bloco: HTML.() -> Unit): HTML = HTML().apply(bloco)

Com @DslMarker, dentro do bloco body { } você não pode acessar diretamente os métodos de HTML. Isso previne erros como chamar head { } dentro de body { }:

val pagina = html {
    head {
        title("Kotlin Brasil")
        // body { } // ERRO de compilação! @DslMarker impede isso
    }
    body {
        h1("Bem-vindo ao Kotlin Brasil")
        p("Aprenda Kotlin em português")
        div {
            p("Conteúdo dentro de uma div")
            // head { } // ERRO! Não está no escopo de HTML
        }
    }
}
println(pagina)

O @DslMarker é essencial para DSLs robustas. Se realmente precisar acessar um receiver externo, use this@html explicitamente.

Passo 4: DSL de Configuração Prática

Vamos criar uma DSL para configuração de aplicação, um caso de uso muito comum em projetos reais:

@DslMarker
annotation class ConfigDsl

@ConfigDsl
class AppConfig {
    var nome: String = ""
    var versao: String = "1.0.0"
    var ambiente: String = "desenvolvimento"
    private var _banco: BancoConfig? = null
    private var _cache: CacheConfig? = null
    private val _features = mutableMapOf<String, Boolean>()

    fun banco(bloco: BancoConfig.() -> Unit) {
        _banco = BancoConfig().apply(bloco)
    }

    fun cache(bloco: CacheConfig.() -> Unit) {
        _cache = CacheConfig().apply(bloco)
    }

    fun features(bloco: FeatureFlags.() -> Unit) {
        FeatureFlags(_features).apply(bloco)
    }

    fun build(): Map<String, Any?> = mapOf(
        "nome" to nome,
        "versao" to versao,
        "ambiente" to ambiente,
        "banco" to _banco,
        "cache" to _cache,
        "features" to _features
    )
}

@ConfigDsl
class BancoConfig {
    var url: String = "jdbc:postgresql://localhost:5432/app"
    var usuario: String = "postgres"
    var senha: String = ""
    var poolSize: Int = 10
    var timeout: Long = 30_000
}

@ConfigDsl
class CacheConfig {
    var provedor: String = "redis"
    var host: String = "localhost"
    var porta: Int = 6379
    var ttlSegundos: Long = 3600
}

@ConfigDsl
class FeatureFlags(private val flags: MutableMap<String, Boolean>) {
    infix fun String.habilitada(valor: Boolean) {
        flags[this] = valor
    }
}

fun appConfig(bloco: AppConfig.() -> Unit): Map<String, Any?> {
    return AppConfig().apply(bloco).build()
}

O uso dessa DSL demonstra como a configuração se torna autodocumentada:

val config = appConfig {
    nome = "Kotlin Brasil API"
    versao = "2.1.0"
    ambiente = "producao"

    banco {
        url = "jdbc:postgresql://db.exemplo.com:5432/producao"
        usuario = "app_user"
        senha = System.getenv("DB_SENHA") ?: ""
        poolSize = 20
        timeout = 15_000
    }

    cache {
        provedor = "redis"
        host = "cache.exemplo.com"
        ttlSegundos = 1800
    }

    features {
        "novo-dashboard" habilitada true
        "beta-relatorios" habilitada false
        "notificacoes-push" habilitada true
    }
}

Note o uso da função infix para habilitada, criando uma sintaxe natural para feature flags. Funções infix eliminam a necessidade de ponto e parênteses, aproximando o código de prosa.

Passo 5: Como o Gradle DSL Funciona Internamente

O build.gradle.kts que usamos diariamente é construído exatamente com essas técnicas. Vamos desmistificar:

// Simplificação do que o Gradle faz internamente
class Project {
    fun dependencies(bloco: DependencyHandler.() -> Unit) {
        DependencyHandler().apply(bloco)
    }

    fun repositories(bloco: RepositoryHandler.() -> Unit) {
        RepositoryHandler().apply(bloco)
    }
}

class DependencyHandler {
    fun implementation(dependencia: String) {
        println("Adicionando: $dependencia ao classpath de compilação")
    }
    fun testImplementation(dependencia: String) {
        println("Adicionando: $dependencia ao classpath de teste")
    }
}

class RepositoryHandler {
    fun mavenCentral() { println("Repositório: Maven Central") }
    fun google() { println("Repositório: Google") }
}

Quando você escreve dependencies { implementation("...") } no Gradle, está usando exatamente o mesmo padrão de lambda with receiver que construímos neste tutorial. O bloco plugins { }, repositories { }, tasks { } — todos seguem esse padrão. Agora você entende a magia por trás do build.gradle.kts.

Erros Comuns

1. Não usar @DslMarker: Sem ele, DSLs aninhadas permitem acessar receivers de escopos externos, levando a configurações incorretas que compilam sem erros. Sempre crie uma annotation marcada com @DslMarker para sua DSL.

2. Builders mutáveis expostos: Nunca exponha o builder diretamente como resultado. Sempre crie um método build() que retorna um objeto imutável (data class ou similar). Isso garante que a configuração não pode ser alterada após a construção.

3. Excesso de DSL: Nem tudo precisa ser uma DSL. Use DSLs quando a mesma estrutura será configurada repetidamente e a legibilidade é crítica. Para configurações simples, um construtor com parâmetros nomeados pode ser suficiente.

4. Esquecer de chamar apply: Um erro sutil é escrever Builder().bloco() ao invés de Builder().apply(bloco). Com apply, o this dentro da lambda é o Builder. Sem ele, dependendo do tipo da lambda, o comportamento pode ser diferente.

5. Lambda with receiver vs lambda comum: (String) -> Unit e String.() -> Unit são tipos diferentes. O primeiro recebe String como parâmetro, o segundo usa String como receiver. Confundir os dois causa erros de tipo difíceis de diagnosticar para iniciantes.

Conclusão e Próximos Passos

Neste tutorial, exploramos os fundamentos de DSLs em Kotlin: lambdas with receiver para contextos fluentes, builder pattern para construção declarativa, @DslMarker para escopo seguro, funções infix para sintaxe natural e type-safe builders para configurações robustas. Esses conceitos são a espinha dorsal de bibliotecas como Ktor, Gradle, Compose e Exposed.

Como próximos passos, experimente criar uma DSL para o domínio do seu projeto — seja para configuração de testes, definição de rotas de API ou regras de validação. Estude o código-fonte do Ktor e do Compose para ver como DSLs são usadas em escala de produção. Confira também nosso tutorial sobre Gradle com Kotlin DSL para ver essas técnicas aplicadas no sistema de build, e o glossário de DSL para uma referência rápida do conceito.