wandres.dev
DSLS · lambdas con receptor

Diseñar un DSL propio

Escribir un DSL es fácil y decidir si hace falta es difícil. Esta lección propone criterios explícitos para saber cuándo un DSL vence a una API ordinaria y cuándo la pierde, confronta la propuesta con el competidor honesto que son los parámetros nombrados y los valores por defecto, examina el DSL como interfaz de usuario donde el autocompletado y el ámbito cerrado son la documentación real, y estudia el coste que casi nadie mide de antemano: la calidad de los mensajes de error que el compilador produce cuando el usuario se equivoca dentro del bloque.

⏱ 20 min

Al terminar de aprender esta técnica ocurre casi siempre lo mismo: aparece la tentación de convertir en DSL todo lo que tenga más de dos parámetros. La tentación es comprensible, porque el coste de escribir un builder con receptor es ridículamente bajo y el resultado luce bien en el archivo de ejemplo. Lo que no se ve desde ahí es que un DSL no es una forma de llamar a una función, es una superficie pública con su propio vocabulario, su propia gramática implícita, su propia curva de aprendizaje y, sobre todo, sus propios modos de fallo; y que todo eso hay que mantenerlo durante años mientras el dominio cambia. La pregunta de diseño relevante nunca fue si se puede hacer un DSL, sino si el problema tiene la forma que justifica pagar ese precio. Esta lección cierra el nivel con los criterios para responderla y con el examen del coste que casi nadie estima antes de empezar.

🎯 Al terminar esta lección sabrás
  • Aplicar criterios explícitos para decidir si un problema justifica un DSL frente a una API ordinaria.
  • Comparar la propuesta con la alternativa de parámetros nombrados, valores por defecto y funciones de construcción.
  • Diseñar el ámbito de cada nivel pensando en el descubrimiento desde el entorno de desarrollo y no en la elegancia del ejemplo.
  • Anticipar y mitigar los mensajes de error que un DSL provoca cuando el usuario se equivoca dentro del bloque.

Cuándo el problema tiene forma de DSL

Hay tres condiciones que, cuando se dan juntas, hacen que un DSL casi siempre gane. La primera es que la estructura que se describe sea un árbol y no una lista: si hay anidamiento genuino, con niveles que tienen vocabularios distintos, ninguna otra forma de expresión lo representa con la misma claridad. La segunda es que el punto de escritura se repita mucho, porque el coste de diseño es fijo y se amortiza con el uso; un DSL con tres puntos de llamada en toda la organización es una inversión perdida. La tercera es que el vocabulario sea razonablemente cerrado y estable, ya que cada palabra nueva es un miembro público más y cada palabra retirada es un cambio incompatible.

Y hay tres señales contrarias que conviene tomarse en serio. Cuando lo que se describe es una secuencia lineal de pasos sin anidamiento, un DSL solo añade llaves. Cuando el conjunto de opciones cambia cada mes, el DSL congela en tipos algo que quería ser datos. Y cuando el objetivo real es evitar escribir el nombre de una variable unas cuantas veces, el problema no es de expresividad sino de longitud, y se resuelve con una variable local.

flowchart TD
A[Quiero un DSL] --> B{La estructura es un arbol con niveles distintos}
B -- No --> C{Son muchos parametros opcionales}
C -- Si --> D[Usa parametros nombrados y valores por defecto]
C -- No --> E[Usa una funcion ordinaria]
B -- Si --> F{Habra muchos puntos de escritura}
F -- No --> E
F -- Si --> G{El vocabulario es estable}
G -- No --> H[Modela con datos y validacion en ejecucion]
G -- Si --> I[Un DSL esta justificado]

El competidor honesto

Antes de aceptar la propuesta hay que enfrentarla a la alternativa más fuerte, que en Kotlin no es un constructor telescópico sino la combinación de parámetros nombrados, valores por defecto y funciones de construcción de nivel superior. Esa combinación cubre buena parte de lo que la gente cree necesitar de un DSL, y lo hace sin introducir ningún tipo nuevo ni ningún estado mutable intermedio.

// Alternativa sin DSL: legible, inmutable, sin builder
data class Servidor(
    val host: String = "0.0.0.0",
    val puerto: Int = 8080,
    val hilos: Int = 4,
    val tls: Boolean = false,
)

val s = Servidor(puerto = 9090, tls = true)

// Version con DSL: solo gana si hay anidamiento real
val t = servidor {
    puerto = 9090
    tls { certificado = "server.pem" }
    rutas { get("/salud") }
}

Hay un punto intermedio que resuelve muchos casos y que casi nadie considera, porque queda fuera de las dos etiquetas: una función de nivel superior que acepte parámetros nombrados para lo plano y un único bloque con receptor para la parte que de verdad es un árbol. Mezclar ambos registros suele producir mejores APIs que elegir uno de los dos en estado puro.

fun servidor(
    puerto: Int = 8080,
    tls: Boolean = false,
    rutas: RutaBuilder.() -> Unit,
): Servidor = Servidor(puerto, tls, RutaBuilder().apply(rutas).construir())

La comparación es instructiva porque muestra dónde está exactamente la frontera. Con una lista plana de opciones, la versión con parámetros nombrados es superior en todos los frentes medibles: el resultado es inmutable por construcción, las opciones obligatorias se distinguen de las opcionales por la ausencia de valor por defecto, el compilador exige las que faltan, no hay ninguna forma de dejar el objeto a medio inicializar y la firma completa cabe en una pantalla. El DSL solo empieza a ganar cuando aparecen los bloques anidados con vocabulario propio, es decir, cuando el árbol que quiere describirse deja de ser un registro plano.

⚠️
El DSL reintroduce un problema que los parámetros nombrados habían resuelto

En un builder con propiedades mutables no hay nada que obligue al usuario a asignar las obligatorias, porque una propiedad sin asignar simplemente conserva su valor inicial. El fallo se descubre en ejecución, y el mensaje típico es un valor vacío que llega muy lejos antes de causar daño. La mitigación es declarar esas propiedades como lateinit o comprobarlas con requireNotNull en el método que construye el resultado, con un mensaje que nombre la propiedad ausente. Cualquiera de las dos es peor que un parámetro obligatorio comprobado por el compilador, y ese es un coste real del patrón que hay que anotar en la columna correcta.

El DSL como interfaz de usuario

Un DSL se consume escribiendo, y la herramienta con la que se escribe es el entorno de desarrollo. Eso convierte al autocompletado en la documentación real del diseño: lo que aparece en la lista al abrir un bloque es lo que el usuario creerá que puede hacer, y lo que no aparece es lo que no existirá para él aunque esté documentado en otra parte. Diseñar bien un DSL es, en gran medida, diseñar esa lista para cada nivel.

De ahí salen cuatro decisiones concretas. Cerrar el ámbito con una anotación de marca, porque sin ella la lista de cada nivel es la unión de todos los niveles superiores y deja de ser informativa. Mantener el vocabulario de cada nivel corto, del orden de cinco a diez entradas, ya que una lista de cuarenta opciones no orienta a nadie. Elegir nombres que formen frases naturales al leerse en su posición, que casi nunca son los mismos nombres que tendría una API ordinaria. Y ofrecer una forma canónica y única de expresar cada cosa, porque en un DSL la variedad de sinónimos no es riqueza sino ambigüedad para quien lee código ajeno.

@DslMarker annotation class RutaDsl

@RutaDsl
class RutaBuilder {
    fun get(camino: String, manejador: Contexto.() -> Unit) { /* ... */ }
    fun post(camino: String, manejador: Contexto.() -> Unit) { /* ... */ }
    fun grupo(prefijo: String, bloque: RutaBuilder.() -> Unit) { /* ... */ }
}

Hay una decisión más, previa a todas, que condiciona el resto: elegir entre propiedades asignables y funciones con argumentos. Una propiedad mutable se lee bien y se descubre bien, pero admite ser asignada dos veces sin protesta y no puede exigir nada. Una función obliga a dar el valor, permite validar en el acto y deja constancia en la firma de lo que es obligatorio. La regla práctica es reservar las propiedades para lo opcional con valor por defecto razonable y usar funciones para todo lo demás.

@RutaDsl
class ServidorBuilder {
    var puerto: Int = 8080                    // opcional, con defecto
    private lateinit var nombre: String       // obligatorio, sin defecto

    fun nombre(valor: String) {
        require(valor.isNotBlank()) { "el nombre del servidor no puede estar vacio" }
        nombre = valor
    }
}

Ese diseño ofrece tres verbos en el nivel de rutas y nada más. Un usuario que abra el bloque ve tres opciones, entiende el modelo en dos segundos y no puede alcanzar por accidente nada del nivel superior. La tentación de añadir un cuarto y un quinto atajo debe medirse siempre contra el coste de alargar esa lista para todos los usuarios futuros.

Los mensajes de error que provocas

Este es el apartado que decide la calidad percibida de un DSL y el que nadie evalúa antes de publicarlo. Un DSL apoya casi todo su funcionamiento en la inferencia de tipos, y la inferencia falla de una manera muy característica: cuando algo no encaja dentro de un bloque, el compilador a menudo no puede señalar la línea culpable y en su lugar informa de que no puede inferir el tipo del bloque entero, o de que el tipo esperado era el receptor y encontró otra cosa, con el subrayado colocado en la llave de apertura. El usuario ve un error a diez líneas de distancia del problema real.

val m = menu {
    opcion("Guardar", accion = "guardar")
    submenu("Editar") {
        opcion("Copiar")          // falta un argumento
    }
}
// El compilador puede señalar la llamada a submenu, o el bloque externo,
// segun cuanta informacion de tipos haya perdido por el camino.

La raíz del fenómeno es que la inferencia de tipos de un bloque es global al bloque: el compilador resuelve el cuerpo entero como una unidad y, si alguna pieza no encaja, la información que le queda para localizar el fallo es escasa. Cuanto más profundo es el anidamiento y más parámetros de tipo hay pendientes de deducir, más lejos del error real acaba el subrayado. Un DSL de un nivel casi nunca sufre esto; uno de cuatro niveles con genéricos lo sufre a diario.

Hay cuatro medidas que mejoran mucho esta situación y que conviene tomar desde el principio. Evitar las sobrecargas dentro del DSL, porque una llamada ambigua degrada la inferencia de todo el bloque que la contiene. Declarar siempre el tipo de retorno de las funciones del builder en lugar de dejarlo inferir, para que un error en el cuerpo no se propague hacia arriba. Mantener la genericidad al mínimo, ya que cada parámetro de tipo que deba deducirse desde dentro de una lambda es un punto donde la inferencia puede rendirse. Y validar el estado acumulado en el método que construye el resultado, con excepciones cuyo mensaje nombre el elemento concreto y su posición en el árbol, porque un error claro en ejecución vale más que un error de compilación indescifrable.

🧭

El autocompletado es el manual

Si el usuario necesita leer documentación para saber qué escribir dentro de un bloque, el ámbito de ese nivel está mal diseñado, no la documentación mal escrita.

🧨

Prueba tus errores

Escribe deliberadamente cinco usos incorrectos de tu DSL y lee los mensajes que produce el compilador. Ese ejercicio revela más sobre el diseño que cualquier ejemplo correcto.

⚖️

Compara con la alternativa aburrida

Escribe el mismo caso con parámetros nombrados y valores por defecto. Si la versión aburrida no es claramente peor, el DSL no está justificado.

Un DSL traslada complejidad del usuario al mantenedor, y esa transferencia hay que quererla

Conviene cerrar el nivel con la incomodidad que casi todas las presentaciones de esta técnica omiten. Un DSL no elimina complejidad: la mueve, y la mueve siempre en la misma dirección, desde quien escribe las llamadas hacia quien mantiene la biblioteca. El usuario obtiene un texto declarativo, corto y comprobado por el compilador; el mantenedor hereda un conjunto de tipos cuya superficie pública es ahora una gramática, con la obligación de que cada nivel siga teniendo sentido cuando el dominio evolucione, de que los nombres sigan formando frases legibles cuando se añada el caso que nadie previó, de que el ámbito siga cerrado cuando entre un builder nuevo escrito por otra persona, y de que los mensajes de error sigan siendo interpretables cuando la inferencia atraviese tres niveles de anidamiento. Esa transferencia es excelente cuando hay muchos usuarios y pocos mantenedores, que es la situación de Gradle, de Compose o de Ktor, y es un mal negocio evidente cuando hay dos usuarios y son las mismas personas que mantienen el código. Hay además un coste de segundo orden que rara vez se anticipa: un DSL es mucho más difícil de evolucionar que una función, porque su superficie no es una firma sino un conjunto de posiciones sintácticas legítimas, y retirar una palabra o cambiar el nivel en el que vive rompe código de una forma que ninguna herramienta de migración automática arregla bien. Por eso el criterio final no es estético sino económico y temporal: un DSL merece la pena cuando la estructura del dominio es genuinamente un árbol, cuando se va a escribir muchas más veces de las que se va a modificar, y cuando existe alguien dispuesto a sostener esa gramática durante el tiempo en que la gente la use. Si esas tres cosas se cumplen, el resultado es una de las herramientas más potentes que ofrece el lenguaje. Si no se cumplen, lo honesto y lo profesional es escribir una función con parámetros nombrados y seguir adelante.

⚔️ Justifica tu DSL o retíralo
  1. Elige una API tuya que estés tentado de convertir en DSL y evalúala contra las tres condiciones y las tres señales contrarias. Escribe la conclusión en una frase antes de tocar el código.
  2. Implementa el mismo caso dos veces, con DSL y con parámetros nombrados y valores por defecto, y pide a otra persona que lea ambas versiones sin contexto. Registra qué dudas le surgen en cada una.
  3. Diseña el ámbito de cada nivel escribiendo primero la lista de nombres que quieres que aparezca en el autocompletado, y solo después las clases que la producen.
  4. Provoca cinco errores distintos dentro de tu DSL, copia literalmente los mensajes del compilador y clasifícalos según si señalan la línea culpable o el bloque entero. Aplica al menos dos de las medidas de mitigación y repite la medición.
  5. Documenta qué palabra de tu DSL sería más costoso retirar dentro de dos años y explica por qué, apelando a las posiciones sintácticas donde puede aparecer y no al número de llamadas.