wandres.dev
CONTEXT PARAMETERS · dependencias implícitas

Diseñar con contexto: cuándo es la herramienta correcta y cuándo es magia

El juicio que cierra el nivel. Tres preguntas que deciden si algo merece ser un parámetro de contexto, la comparación honesta con el parámetro ordinario y con la inyección de dependencias por constructor, los cinco síntomas de abuso y un conjunto de reglas de higiene para que un mecanismo implícito siga siendo revisable dentro de dos años.

⏱ 20 min

Las cuatro lecciones anteriores describen un mecanismo con reglas nítidas y sin sorpresas: se declara con nombre, se resuelve por tipo del ámbito interno al externo, habilita llamadas y no las despacha, y se compila como un parámetro más. Todo lo que queda es la parte difícil, que no es técnica. Un parámetro de contexto es tan cómodo de declarar que se declarará de más, y su coste no aparece el día que se escribe sino dos años después, cuando alguien abra una función, lea una llamada perfectamente limpia y tenga que reconstruir mentalmente cuatro niveles de ámbito para saber qué transacción se está usando. La pregunta que cierra el nivel no es cómo se usan los contextos, sino qué merece ser contexto, y la respuesta correcta es que muy pocas cosas.

🎯 Al terminar esta lección sabrás
  • Aplicar tres preguntas de decisión que separan el contexto legítimo del parámetro disfrazado.
  • Comparar el parámetro de contexto con el parámetro ordinario y con la inyección por constructor sin caricaturizar ninguno.
  • Reconocer los cinco síntomas de abuso que convierten un ámbito en acoplamiento invisible.
  • Adoptar reglas de higiene que mantengan revisable una base de código con contextos.

Tres preguntas antes de declarar un contexto

Conviene empezar por el sesgo por defecto, porque establecerlo bien ahorra la mitad de las discusiones. El sesgo por defecto es no: un valor es un parámetro ordinario salvo que demuestre lo contrario, igual que una clase es final salvo que se declare open y un tipo no admite nulo salvo que se marque. Kotlin ha tomado esa misma decisión en todos los ejes donde la comodidad y la seguridad se oponen, y no hay ninguna razón para invertirla aquí.

La primera pregunta es la de la irrelevancia semántica: ¿cambiaría el significado de la operación si este valor fuera otro? Un formateador de moneda distinto produce una cadena distinta pero no convierte la operación en otra operación; una moneda de destino distinta sí. Cuando el valor participa en lo que la función significa, es un parámetro y quien llama debe escribirlo, aunque escribirlo cueste. Cuando el valor solo determina cómo se hace algo que se haría igual sin él, es candidato a contexto.

La segunda es la de la regionalidad: ¿existe una región del programa dentro de la cual este servicio es el mismo para todas las llamadas? Una transacción cumple, porque hay un bloque con principio y fin y todo lo de dentro pertenece a la misma unidad de trabajo. Un identificador de usuario que cambia en cada iteración de un bucle no cumple, y forzarlo a contexto obliga a abrir un ámbito por iteración, que es una forma cara y confusa de escribir un argumento.

La tercera es la de la transversalidad: ¿lo necesitan muchas funciones que no tienen nada que ver entre sí? Si el servicio lo usan tres funciones del mismo archivo, la respuesta correcta casi siempre es una clase con ese servicio como campo. El contexto se justifica cuando el conjunto de funciones que lo necesitan atraviesa módulos y no admite un hogar común, que es la situación de la observabilidad, de los ámbitos de recurso y de la localización.

Las tres preguntas hay que pasarlas en ese orden y las tres tienen que dar la respuesta favorable, porque cada una filtra un error distinto y ninguna cubre a las otras. Un identificador de petición pasa la primera y la tercera pero suspende la segunda si cambia dentro del bucle; un repositorio pasa la primera y la segunda y suspende la tercera porque tiene un hogar evidente. Solo lo que sobrevive a las tres es contexto, y la lista de cosas que sobreviven es notablemente corta: en un proyecto sano cabe en los dedos de una mano.

// Suspende la primera: la moneda cambia el resultado, es un argumento
fun convertir(importe: Dinero, destino: Moneda): Dinero

// Suspende la segunda: cambia en cada iteracion, no delimita ninguna region
fun procesarUsuario(id: UsuarioId, datos: Datos): Resultado

// Pasa las tres: transversal, regional y semanticamente irrelevante
context(tx: Transaccion)
fun guardar(datos: Datos): Unit
💡
El test de la frase

Escribe la restricción en castellano llano y mira si suena a verdad del dominio: esta función solo tiene sentido dentro de una transacción abierta suena bien; esta función solo tiene sentido si existe un usuario suena a que el usuario es un argumento; esta función solo tiene sentido si hay una cadena disponible no suena a nada. Si la frase resultante necesita mencionar un tipo general como String, Int o Map, no hay contexto: hay un parámetro que alguien quiere dejar de escribir.

Frente al parámetro y frente a la inyección de dependencias

Un efecto secundario agradable de este mecanismo, y que conviene tener en cuenta al valorar la decisión, es que mejora la comprobabilidad en lugar de empeorarla. Una prueba que quiera sustituir el servicio abre su propio ámbito con un doble y lo cierra al terminar, sin tocar ningún estado compartido y sin coordinarse con ninguna otra prueba, de modo que la ejecución en paralelo deja de ser un riesgo. Es exactamente lo contrario de lo que ocurre con un singleton mutable, y por sí solo justifica migrar los servicios ambientales que hoy vivan ahí.

La comparación con el parámetro ordinario ya está hecha por las tres preguntas, pero conviene explicitar la asimetría de costes, porque no es simétrica en absoluto. Convertir un parámetro en contexto ahorra caracteres en cada llamada y cuesta visibilidad en todas ellas; convertir un contexto en parámetro cuesta caracteres y no pierde nada más que comodidad. Por tanto el sesgo por defecto tiene que ser el parámetro, y el contexto debe ganarse su lugar demostrando que el ruido que elimina es real y masivo. Diez llamadas no lo justifican; una dependencia que atraviesa treinta firmas intermedias que no la usan, sí.

La comparación con la inyección de dependencias por constructor es más sutil, y el error habitual es plantearla como una competición. No lo es, porque resuelven problemas que solo se parecen por fuera. La inyección por constructor construye un grafo de objetos y fija dependencias para todo el tiempo de vida de una instancia; el contexto delimita una región de ejecución y no tiene nada que ver con ningún tiempo de vida. De hecho el propio lenguaje impide confundirlos, porque los contextos no se pueden declarar sobre clases ni sobre constructores.

// Dependencia de identidad: vive con el objeto, va por constructor
class ServicioPedidos(private val repo: RepositorioPedidos) {

    // Dependencia de region: vive con la llamada, va por contexto
    context(tx: Transaccion, log: Logger)
    fun confirmar(id: String): Confirmacion {
        log.info("confirmando $id")
        return tx.dentro { repo.marcarConfirmado(id) }
    }
}

Hay una prueba mecánica que zanja casi todos los casos dudosos y que conviene tener a mano: pregúntate si dos llamadas consecutivas al mismo método de la misma instancia podrían necesitar legítimamente valores distintos. Si la respuesta es no, la dependencia pertenece al objeto y el constructor es su sitio; el compilador ni siquiera te dejaría hacer otra cosa, porque los contextos no se declaran sobre clases. Si la respuesta es sí, has descubierto que lo que creías una propiedad del objeto era en realidad una propiedad del momento en que se le llama, y ese descubrimiento suele deshacer nudos de diseño que llevaban años ahí.

La regla que se deriva es fácil de recordar y bastante fiable: si la dependencia no cambia mientras el objeto exista, es de identidad y va por constructor; si cambia con la región de ejecución y puede ser distinta en dos llamadas al mismo objeto, es de ámbito y va por contexto. Un repositorio pertenece al primer grupo; una transacción, un ámbito de corrutina y una localización pertenecen al segundo. El contexto complementa a la inyección justo donde esta no llega, que en Kotlin idiomático es un territorio grande: las funciones de nivel superior, las funciones de extensión y todo el código sin estado que no quiere ser una clase.

⚠️
El contexto no sustituye a un contenedor de inyección

Existe la tentación de declarar contextos para todas las dependencias y jubilar el contenedor, y es un mal negocio por dos razones concretas. La primera es que un contenedor resuelve un grafo, con dependencias entre dependencias y con ciclos de vida distintos por rama, y el contexto no resuelve ningún grafo: solo transporta valores ya construidos. La segunda es que la exigencia de contexto es viral hacia arriba, así que declarar diez servicios como contexto obliga a la raíz del programa a proveerlos todos a la vez, que es exactamente el acoplamiento total que un contenedor existe para evitar. Usa el contenedor para construir y el contexto para delimitar.

flowchart TD
A[Una funcion necesita un valor] --> B{Cambia el significado de la operacion}
B -- Si --> P[Parametro ordinario]
B -- No --> C{Es el mismo en toda una region}
C -- No --> P
C -- Si --> D{Vive con un objeto o con la llamada}
D -- Con el objeto --> E[Constructor]
D -- Con la llamada --> F[Parametro de contexto]

El riesgo de la magia implícita

Antes de enumerar los síntomas conviene reconocer con honestidad qué se pierde al usar el mecanismo bien, porque incluso el uso correcto tiene un coste y fingir lo contrario no ayuda a nadie. Se pierde la lectura local: para saber qué transacción usa una línea hay que salir de ella y buscar hacia arriba. Se pierde parte del contenido informativo del diff, porque un cambio en la función que abre el ámbito altera el comportamiento de código que no aparece en el cambio. Y se pierde inmediatez en el diagnóstico de errores de configuración, ya que el compilador señala el sitio de llamada mientras la causa suele estar varios niveles por encima. Ninguna de las tres es grave, y las tres se agravan de forma no lineal con la cantidad de contextos, que es exactamente la razón por la que la disciplina importa aquí más que en cualquier otra característica del lenguaje.

Los cinco síntomas de abuso son reconocibles y aparecen casi siempre juntos. El primero es el tipo general como contexto: en cuanto veas un contexto de tipo String o Map, hay un parámetro escondido y habrá ambigüedades. El segundo es la pila profunda de contextos, tres o más declarados a la vez en firmas repartidas por el proyecto, que convierte cada llamada en una consulta a cuatro ámbitos distintos. El tercero es el contexto que se usa una sola vez en toda la cadena, señal de que el ámbito no describe nada y solo evita escribir un argumento. El cuarto es la dependencia oculta en la API pública: publicar una función con contexto obliga a todo cliente a construir ese ámbito, y si el tipo del contexto no es evidente, la biblioteca resulta imposible de empezar a usar. El quinto es el contexto mutable, que reintroduce por la puerta de atrás todos los problemas del estado global y anula la única ventaja seria del mecanismo, que era ser estático y verificable.

De los cinco, el segundo y el cuarto son los que más silenciosamente destruyen una base de código, porque ninguno de los dos produce error alguno: el proyecto compila, las pruebas pasan y el problema solo se manifiesta como una lentitud creciente en la incorporación de gente nueva y en el tiempo que cuesta responder a la pregunta más simple posible sobre una línea cualquiera. Cuando notes que para explicar una función tienes que empezar diciendo depende de dónde la llames, ya has cruzado la frontera.

🧪

Un tipo dedicado por capacidad

Nunca tipos generales. Una interfaz propia por servicio, aunque sea de una línea, hace la resolución trivial y documenta la exigencia.

📉

Como mucho dos a la vez

Más de dos contextos en una firma es una señal de que faltaba un tipo que los agrupara o de que la función hace demasiadas cosas.

🚪

Fronteras explícitas

En la API pública de una biblioteca, provee tú el ámbito con una función de entrada en lugar de exigir que el cliente lo monte a mano.

🧊

Contextos inmutables

Un contexto es una capacidad, no un almacén. Si algo dentro cambia durante la región, vuelves a tener estado global con otro nombre.

La contramedida más eficaz para el cuarto síntoma es un patrón de una sola línea que toda biblioteca que exija contexto debería ofrecer: una función de entrada que construya el ámbito, lo provea y lo cierre, de modo que el cliente nunca tenga que averiguar por su cuenta cómo se monta.

// La biblioteca provee la puerta; el cliente no monta nada a mano
fun <R> enTransaccion(fuente: FuenteDatos, bloque: Transaccion.() -> R): R {
    val tx = fuente.abrir()
    try {
        val resultado = tx.bloque()   // el receptor sirve de argumento de contexto
        tx.confirmar()
        return resultado
    } catch (e: Throwable) {
        tx.deshacer()
        throw e
    }
}

// Y dentro del bloque todo lo que exija Transaccion resuelve solo
val total = enTransaccion(fuente) { guardar(datos); calcularTotal() }

Esa función hace tres cosas a la vez y las tres importan: documenta con su nombre cuál es la región, garantiza que el recurso se cierra pase lo que pase, y convierte una exigencia difusa de la API en una llamada que el cliente encuentra sin leer nada. Cuando una biblioteca exige contexto y no ofrece su puerta de entrada, el trabajo de montar el ámbito se ha externalizado a todos sus usuarios a la vez.

📝
Lo que el contexto sí mejora sin discusión

Frente a la variable de hilo, el parámetro de contexto gana en todos los frentes y por eso su adopción en el ecosistema de corrutinas fue inmediata: se comprueba al compilar, sobrevive a los cambios de hilo del despachador porque viaja por la pila, no requiere limpieza y no acopla las pruebas entre sí. Si en tu proyecto queda algún servicio ambiental sostenido por una variable de hilo, esa es la migración con mejor relación entre esfuerzo y riesgo eliminado de todo este nivel.

Toda abstracción implícita es un préstamo de legibilidad futura contra comodidad presente

Conviene cerrar el nivel con la generalización que lo justifica, porque los parámetros de contexto son solo un caso particular de una familia que vas a encontrar en todas partes y sobre la que hace falta una posición. La familia la componen todos los mecanismos que hacen que algo ocurra sin que aparezca escrito en el punto donde ocurre: la herencia y sus métodos heredados de tres niveles más arriba, las conversiones implícitas, la inyección de dependencias por anotación, los interceptores, los aspectos, los efectos de un plugin de compilador y, ahora, el contexto. Todos comparten la misma economía y por eso se los puede juzgar con el mismo criterio: retiran información del punto de uso a cambio de retirar también ruido, y ambas cosas son la misma operación vista desde dos lados, de modo que la pregunta nunca es si conviene ocultar, sino si lo ocultado es recuperable cuando haga falta y a qué precio. Y ahí es donde los casos se separan de verdad. Lo ocultado por una variable de hilo no es recuperable en absoluto: no hay ningún lugar del texto que te diga quién la puso ni si estará ahí; por eso es el peor miembro de la familia. Lo ocultado por los receptores de contexto, el diseño que Kotlin descartó, era recuperable pero solo con un editor que resolviera cada nombre, lo cual excluía las revisiones de código, los diffs y a cualquiera que leyera desde fuera. Lo ocultado por un parámetro de contexto es recuperable leyendo hacia arriba: la firma dice qué se exige, el compilador garantiza que estaba, el uso lleva el nombre delante y el ámbito que lo proveyó está en el mismo archivo o en uno que el editor alcanza en un salto. Ese es un préstamo razonable y hay que reconocerlo como tal, pero sigue siendo un préstamo, y la cuota se paga en cada lectura futura, no en la escritura. De ahí sale el criterio con el que conviene terminar, y es de una sola frase: haz implícito solo aquello que quien lee daría por supuesto de todos modos. Nadie que lea una función de repositorio se sorprende de que haya una transacción abierta; nadie que lea una función de presentación se sorprende de que haya una localización vigente. Eso es contexto legítimo, y volverlo explícito solo añadiría ceremonia a una verdad compartida. En cambio, cuando lo que quieres ocultar es algo que un lector honesto necesitaría preguntar, has dejado de ahorrar ruido y has empezado a esconder una decisión. La diferencia entre las dos cosas no la marca la sintaxis, que es idéntica, ni el compilador, que aceptará ambas sin quejarse: la marca tu juicio sobre qué sabe quien va a leer esto después de ti, y ese juicio es, al final, todo lo que hay en el oficio de diseñar una API.

⚔️ Somete tus contextos a juicio
  1. Inventaria todos los parámetros de contexto de un módulo tuyo y clasifícalos por tipo. Marca los que usen tipos generales del lenguaje o de la biblioteca estándar.
  2. Pasa las tres preguntas a cada uno y reconvierte en parámetro ordinario todos los que fallen la primera. Cuenta cuántas llamadas se ensucian de verdad.
  3. Localiza una dependencia que hoy va por constructor y decide si en realidad cambia con la región de ejecución. Si cambia, muévela a contexto y observa qué se simplifica.
  4. Busca la firma con más contextos declarados a la vez y propón un tipo que agrupe dos de ellos, o divide la función. Justifica cuál de las dos salidas es mejor y por qué.
  5. Elige una función pública de tu biblioteca que exija contexto y escribe la función de entrada que lo provea, de modo que un cliente pueda empezar a usarla sin montar el ámbito a mano.