wandres.dev
DISEÑO DE API · compatibilidad binaria

Explicit API mode: nada público por accidente

Kotlin toma dos decisiones cómodas para el código de aplicación y peligrosas para una biblioteca: todo es público mientras no se diga lo contrario, y el tipo de retorno se deduce solo. El modo de API explícita revoca ambas y obliga a escribir la visibilidad y el tipo de cada declaración que vaya a salir del artefacto. Esta lección explica qué exige exactamente, en qué dos intensidades se activa, dónde no se aplica y, sobre todo, qué obliga a pensar antes de que el símbolo llegue a la superficie pública.

⏱ 22 min

Una biblioteca no se distingue de una aplicación por su tamaño ni por su calidad, sino por una asimetría irreversible: sus decisiones las paga otro, y las paga durante años. En una aplicación, un símbolo público de más no cuesta nada porque el único consumidor es el propio repositorio y basta un renombrado para arreglarlo. En una biblioteca publicada, ese símbolo se convierte en una promesa que alguien copió en su código y que ya no puedes retirar sin romperle la compilación o, peor, el arranque. El modo de API explícita existe para que esa promesa no se haga nunca por descuido. No añade capacidad expresiva, no cambia el bytecode y no impide ninguna arquitectura; solo retira dos valores por defecto y exige una decisión consciente en su lugar. Toda biblioteca que aspire a durar lo activa el primer día, y quien lo activa tarde descubre que la superficie que creía tener y la que realmente publicó no coinciden.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la visibilidad pública por defecto y la inferencia del tipo de retorno son cómodas en una aplicación y hostiles en una biblioteca.
  • Activar el modo en sus dos intensidades y saber qué declaraciones quedan fuera de su exigencia.
  • Detectar tipos de retorno inferidos que publican más de lo pretendido y reducirlos a la abstracción mínima suficiente.
  • Convertir cada declaración nueva en una decisión deliberada sobre si pertenece o no a la superficie pública.

Dos valores por defecto pensados para otro problema

El primero es la visibilidad. En Kotlin, una declaración sin modificador es public, decisión razonable para el noventa por ciento del código que se escribe en el mundo, que vive dentro de un mismo módulo y nunca se publica. El segundo es la inferencia: una función con cuerpo de expresión o una propiedad con inicializador no necesitan declarar su tipo, porque el compilador lo deduce y suele acertar. Ambas comodidades comparten un efecto secundario cuando el módulo se empaqueta y se sube a un repositorio de artefactos: el resultado de la deducción, y no tu intención, es lo que queda publicado.

// Todo lo siguiente forma parte de la API publica del artefacto
fun normalizar(entrada: String) = entrada.trim().lowercase()

val etiquetas = mutableListOf("alfa", "beta")

class Motor {
    val estadoInterno = mutableMapOf<String, Int>()
    fun arrancar() = Estado.ACTIVO
}

Ninguna de esas cuatro declaraciones se escribió con intención de publicarse; simplemente nadie escribió lo contrario. Y dos de ellas publican algo peor que un nombre: publican un tipo mutable. Quien consuma la biblioteca podrá añadir elementos a etiquetas y a estadoInterno, porque el tipo inferido es MutableList y MutableMap, no List ni Map. El día que quieras corregirlo, la corrección será un cambio incompatible en toda regla.

La inferencia produce además una segunda familia de sorpresas, más sutil, alrededor del tipo numérico o de la implementación concreta que devuelve una expresión. Si el cuerpo de fun total() = lineas.sumOf { it.unidades } deduce Int y mañana un cambio interno lo lleva a Long, la firma publicada cambia sin que ninguna línea de la declaración se haya tocado. El autor no ve un cambio de API porque en el código fuente no hay ningún tipo escrito que haya cambiado.

Merece la pena insistir en la asimetría temporal que hay detrás de todo esto, porque es lo que convierte un descuido menor en un problema permanente. El coste de escribir internal es de nueve caracteres y se paga una vez, en el instante en que el autor tiene todo el contexto en la cabeza. El coste de haberlo omitido se paga durante toda la vida del artefacto, en forma de un símbolo que hay que seguir manteniendo, documentar cuando alguien pregunte y arrastrar por el procedimiento completo de obsolescencia el día que estorbe. No hay ninguna otra decisión de diseño en Kotlin con una relación tan desproporcionada entre lo que cuesta acertar y lo que cuesta fallar.

Qué exige el modo y dónde no se aplica

La activación vive en el bloque kotlin del script de construcción y admite dos intensidades. La estricta convierte cada omisión en error de compilación; la tolerante la convierte en aviso, que es la vía practicable para adoptar el modo en una base de código ya publicada sin detener la entrega.

// build.gradle.kts
kotlin {
    explicitApi()           // estricto: cada omision es un error
    // explicitApiWarning() // tolerante: cada omision es un aviso
}

Por debajo, ambas formas se traducen en la opción de compilador -Xexplicit-api con valor strict o warning, que es lo que hay que usar cuando la construcción no pasa por el complemento de Gradle. A partir de ahí el compilador reclama dos cosas: un modificador de visibilidad en toda declaración cuya visibilidad efectiva sería pública, y un tipo escrito en toda función y propiedad que forme parte de la API.

La exigencia es más quirúrgica de lo que parece, porque el modo no molesta donde no puede haber contrato. Quedan fuera las declaraciones locales dentro de un cuerpo de función, los miembros de objetos anónimos, los parámetros y, muy en particular, los miembros marcados con override, cuya firma ya viene fijada por el supertipo y no admite discusión. Tampoco se aplica al conjunto de fuentes de pruebas, porque un módulo de test no publica nada. El efecto neto es que el ruido se concentra exactamente donde importa.

public interface Repositorio {
    public fun buscar(clave: String): Usuario?
}

public class RepositorioEnMemoria : Repositorio {
    // El override no necesita visibilidad ni tipo: los hereda del supertipo
    override fun buscar(clave: String): Usuario? = datos[normalizar(clave)]

    // Los miembros que no salen del modulo se marcan una vez y descansan
    private val datos: MutableMap<String, Usuario> = mutableMapOf()

    private fun normalizar(clave: String): String {
        val recortada = clave.trim()          // local: el modo no la toca
        return recortada.lowercase()
    }
}

Esa distribución de exigencias explica por qué la adopción resulta mucho menos costosa de lo que teme quien la pospone. En un módulo típico, la inmensa mayoría de las declaraciones son miembros privados, locales o redefiniciones, y el modo pasa sobre todas ellas sin decir nada. Lo que se ilumina es un conjunto sorprendentemente pequeño y sorprendentemente revelador: justo las declaraciones sobre las que nunca se tomó una decisión.

flowchart TD
A[Declaracion nueva de nivel superior o miembro] --> B{La ve quien consume el artefacto}
B -->|No| C[Marca internal o private]
B -->|Si| D[Marca public y escribe el tipo de retorno]
D --> E{El tipo escrito es el minimo suficiente}
E -->|No| F[Reduce a la abstraccion mas general que sirva]
E -->|Si| G[Entra en la superficie publica y se congela]
💡
Adopta en tolerante, congela en estricto

En un módulo ya publicado, empieza por explicitApiWarning, resuelve la oleada de avisos por paquetes y solo entonces pasa a explicitApi. Al revés, la construcción se detiene el primer día y la tentación de desactivar el modo gana la partida.

El ejercicio mental que impone

Lo valioso del modo no es lo que impide, sino la pregunta que fuerza en el momento exacto en que aún es barata. Cada vez que escribes una declaración nueva tienes que responder si pertenece o no al contrato, y esa respuesta se toma con el contexto fresco, no dos años después leyendo un fichero de volcado. La disciplina se resume en un reflejo: escribe internal primero y asciende a public solo cuando exista un consumidor externo real que lo justifique. Un símbolo interno puede hacerse público en cualquier momento sin romper a nadie; el viaje contrario no existe.

La segunda pregunta la impone el tipo escrito, y es la más formativa de las dos. Al obligarte a teclear el tipo de retorno, el modo te enfrenta a la diferencia entre lo que la implementación devuelve y lo que el contrato debería prometer. La regla es publicar la abstracción más general que satisfaga al llamante, porque todo lo que publiques de más queda congelado a favor de terceros.

// Antes: la inferencia publica la implementacion concreta y su mutabilidad
fun etiquetas() = mutableListOf("alfa", "beta")
val indice = HashMap<String, Usuario>()

// Despues: el contrato promete lo minimo y la implementacion queda libre
public fun etiquetas(): List<String> = mutableListOf("alfa", "beta")
public val indice: Map<String, Usuario> = HashMap()

internal fun normalizarClave(clave: String): String = clave.trim().lowercase()

Hay un tercer efecto que solo se aprecia con el tiempo y que conviene anticipar. Al quedar todas las visibilidades escritas, el fichero fuente se vuelve legible como documento de diseño: se puede recorrer un paquete entero y ver de un vistazo qué se ofrece y qué se reserva, sin abrir el navegador de símbolos ni deducir nada. Esa legibilidad es la que hace viable la revisión humana del contrato, que es el asunto de la tercera lección de este nivel.

El modo interactúa además con una decisión que a menudo se toma por comodidad y que conviene revisar al activarlo: la de qué se declara al nivel superior de un paquete. Todo lo que vive en el nivel superior es visible desde cualquier importación del paquete, mientras que un miembro de un objeto o de una clase queda naturalmente acotado por su contenedor. Cuando el modo obliga a escribir la visibilidad de una función auxiliar de nivel superior, la pregunta que suele aparecer no es si debe ser pública, sino si debe estar ahí.

// Antes: tres auxiliares sueltas en el paquete, publicas por omision
fun codificar(valor: String) = /* ... */ valor
fun decodificar(valor: String) = /* ... */ valor
fun validar(valor: String) = valor.isNotBlank()

// Despues: una superficie de un solo simbolo y tres detalles encapsulados
public object Codec {
    public fun codificar(valor: String): String = valor
    public fun decodificar(valor: String): String = valor
    internal fun validar(valor: String): Boolean = valor.isNotBlank()
}

Conviene también anticipar el efecto sobre la documentación generada, que suele ser el argumento que convence a quien duda. Un generador de documentación recorre exactamente la misma superficie que el modo delimita, de modo que activar el modo y reducir lo público tiene el efecto inmediato de producir una documentación que se puede leer entera. Una referencia de doscientos símbolos, de los cuales cuarenta son intencionados, no la lee nadie; y lo que no se lee no se usa, con lo que la biblioteca acaba juzgada por su ruido y no por su diseño.

📐

Dos revocaciones

El modo retira la visibilidad pública implícita y la inferencia del tipo de retorno en la API. Nada más, y con eso basta.

🧭

Interno primero

Ascender de internal a public nunca rompe a nadie. Descender rompe a todo el mundo. Elige la dirección barata por defecto.

🔮

El tipo es el contrato

Publica la abstracción mínima suficiente. Lo que la inferencia deduzca es un detalle de implementación que no deberías regalar.

La visibilidad no es un detalle de higiene: es la única frontera que el compilador puede defender por ti

Conviene mirar el modo de API explícita como lo que realmente es, que no es una regla de estilo sino la aplicación a la escala del módulo del mismo principio que gobierna todo el diseño de Kotlin: mover al compilador aquello que hoy depende de la disciplina de una persona. En un módulo sin el modo activado, la frontera entre lo que prometes y lo que meramente implementas existe únicamente en la cabeza de quien escribió el código, y esa cabeza se marcha del equipo, cambia de proyecto o simplemente olvida. Todo lo que queda entonces es un artefacto en el que cualquier símbolo es tan público como el más deliberado de ellos, y en el que ningún criterio automático puede distinguir el contrato de la implementación. Activar el modo convierte esa frontera en un objeto sintáctico, y al hacerlo permite que todo lo demás exista: el volcado de la superficie tiene sentido porque hay una superficie definida, la validación en integración continua tiene sentido porque hay algo estable que comparar, la política de obsolescencia tiene sentido porque hay un conjunto finito de promesas que retirar y las anotaciones de aceptación explícita tienen sentido porque hay un régimen normal del que excluirse. Sin esa primera decisión, las cuatro lecciones restantes de este nivel se aplicarían sobre una superficie que nadie eligió. Hay además una consecuencia menos obvia y más profunda sobre la calidad del diseño. Cuando cada símbolo público exige una justificación explícita, las APIs se encogen, y una API pequeña es casi siempre una API mejor, no porque la brevedad sea una virtud estética, sino porque cada elemento retirado del contrato es un grado de libertad que conservas para las versiones futuras. El coste de mantener una biblioteca durante una década no lo determina el tamaño de su implementación, sino el número de promesas que hizo cuando aún no sabía lo suficiente como para hacerlas.

⚔️ Descubre tu superficie real
  1. Activa explicitApiWarning en un módulo tuyo, cuenta los avisos y clasifícalos en tres grupos: debía ser público, debía ser interno y nunca lo pensé.
  2. Localiza las declaraciones cuyo tipo inferido sea mutable o una implementación concreta y reescríbelas con la abstracción mínima suficiente.
  3. Busca una función pública cuyo tipo de retorno numérico dependa de una expresión interna y razona qué ocurriría si esa expresión cambiara de Int a Long.
  4. Toma el paquete más grande del módulo y anota qué proporción de sus símbolos públicos tiene al menos un consumidor externo real.
  5. Pasa a explicitApi estricto y anota cuánto tiempo te costó la migración completa; guarda el dato para justificar activarlo el primer día en el próximo proyecto.