Diseñar la API pública: la fachada y la superficie mínima
El módulo raíz como única puerta de entrada del plugin, la distinción entre lo accesible y lo publicado, los convenios de configuración y de error que se fijan una sola vez, y por qué cada símbolo exportado es una deuda perpetua que conviene no contraer.
Hasta ahora escribías código para ti: si algo no encajaba, lo cambiabas y seguías. Publicar invierte esa relación de manera irreversible. En el instante en que otra persona escribe require con el nombre de tu plugin, cada nombre que devuelves deja de ser un detalle de implementación y se convierte en una promesa que alguien invocará desde su configuración a las once de la noche, en una versión de Neovim que tú no usas, para un caso que no habías imaginado. Esta lección trata de decidir por adelantado cuáles son esas promesas, y de aceptar la consecuencia incómoda: todo lo que no prometes es libertad que conservas, y todo lo que prometes es libertad que regalas para siempre.
- Construir el módulo raíz como fachada y mantener la implementación en módulos internos.
- Distinguir entre lo que es accesible desde Lua y lo que está publicado como contrato.
- Fijar los convenios de configuración, de errores y de retorno antes de la primera versión.
- Razonar el coste permanente de cada símbolo exportado y defender la superficie mínima.
La fachada
Un plugin de Neovim no tiene un punto de entrada impuesto por el lenguaje: tiene el que tú declares. El convenio establecido —y conviene seguirlo, porque los convenios son gratis y las excepciones se pagan en soporte— es que lua/mi-plugin/init.lua sea lo único que un usuario necesite cargar, y que ese archivo no contenga lógica sino delegación.
-- lua/mi-plugin/init.lua : la unica puerta de entrada
local M = {}
---@param opts table|nil
---@return nil
function M.setup(opts)
require("mi-plugin.config").aplicar(opts or {})
end
---@return boolean activo
function M.toggle()
return require("mi-plugin.core").toggle()
end
return M
El detalle que parece cosmético y no lo es: cada require vive dentro del cuerpo de la función, no en la cabecera del archivo. Así la fachada cuesta unos microsegundos al cargarse, y el usuario que la carga en el arranque para registrar un mapeo no paga el precio de tu módulo de interfaz gráfica hasta que lo usa. La estructura que se deriva de esto es siempre la misma, y es legible sin explicación:
lua/mi-plugin/
init.lua fachada publica, y nada mas
config.lua valores por defecto, validacion y fusion
core.lua la logica de verdad
ui.lua ventanas, extmarks, resaltados
plugin/mi-plugin.lua solo lo que debe existir sin require explicito
doc/mi-plugin.txt el contrato escrito en prosa
Accesible no es publicado
Lua no tiene visibilidad. Cualquier módulo bajo lua/ es alcanzable con require, cualquier campo de una tabla devuelta es invocable, y no existe ninguna palabra clave que impida a nadie llamar a mi-plugin.core.reconstruir_indice. La consecuencia es que la frontera entre lo público y lo privado no la impone el lenguaje: la declaras tú y la sostiene la documentación.
Publicado
Aparece en doc/, tiene anotaciones de tipo y se rompe solo en una versión mayor. Todo lo que cuelga del módulo raíz.
Interno
Alcanzable, pero sin promesa alguna. Se renombra, se parte o se borra en cualquier versión de parche sin previo aviso.
Experimental
Publicado y documentado como inestable, con una fecha o una versión declarada para decidir si asciende o desaparece.
Esta distinción no es burocracia: es la única defensa que tendrás cuando alguien abra un issue quejándose de que le has roto algo. Si lo que usaba estaba documentado, tiene razón y el fallo es tuyo. Si estaba llamando a un módulo interno, la respuesta amable es explicarle por qué eso nunca fue una promesa y, si su necesidad es legítima, convertirla en una petición de API pública. Sin la frontera escrita, todas las discusiones se vuelven cuestión de opinión, y el proyecto acaba congelado por miedo a mover cualquier cosa.
Convenios que se fijan una sola vez
Hay cuatro decisiones que resulta barato tomar antes de la primera versión y carísimo cambiar después, porque afectan a todas las llamadas y no a una.
La primera es la configuración. El convenio dominante es una única función setup que recibe una tabla, la fusiona con los valores por defecto y valida el resultado. Fusionar en profundidad importa: el usuario que solo quiere cambiar un color no debería tener que reescribir el resto del árbol.
-- lua/mi-plugin/config.lua
local M = {}
M.defaults = {
enabled = true,
ventana = { border = "rounded", ancho = 60 },
ignorar = { "gitcommit", "help" },
}
M.opts = vim.deepcopy(M.defaults)
function M.aplicar(opts)
local nueva = vim.tbl_deep_extend("force", M.defaults, opts)
vim.validate("enabled", nueva.enabled, "boolean")
vim.validate("ventana.ancho", nueva.ventana.ancho, "number")
M.opts = nueva
return M.opts
end
return M
La segunda es qué hace require por sí solo. La respuesta correcta es nada observable: sin autocomandos, sin mapeos, sin comandos, sin grupos de resaltado. Cargar un módulo tiene que ser inocuo, porque el usuario puede cargarlo para leer una constante y no espera que su editor cambie de comportamiento. Los efectos empiezan en setup, o en plugin/ si de verdad deben existir sin que nadie los pida.
La tercera es cómo fallas. En Lua hay dos protocolos, y mezclarlos es una crueldad: error para lo que es culpa del programador —un tipo equivocado, una opción inexistente— y devolver nil más un mensaje para lo que es un resultado legítimo del mundo, como un archivo que no está. Elige uno para cada categoría, escríbelo en la documentación, y no vuelvas a pensarlo.
-- Culpa del programador: revienta pronto y con el nombre del campo
function M.abrir(id)
vim.validate("id", id, "number")
...
end
-- Resultado legitimo del mundo: dos valores, nunca un error
---@return string|nil contenido
---@return string|nil error
function M.leer(ruta) ... end
La cuarta es qué devuelves. Una función que crea algo debe devolver una manija para destruirlo; una función que consulta debe devolver datos, no imprimirlos. Un plugin cuyas funciones solo tienen efectos secundarios y devuelven nil es un plugin imposible de componer, y la composición es exactamente lo que la gente hará con el tuyo en cuanto lo instale. El caso extremo y revelador es la función que abre una ventana: si no devuelve el identificador de esa ventana, acabas de obligar a todo el mundo a adivinarlo recorriendo la lista de ventanas abiertas, que es justo el tipo de acoplamiento frágil que después se rompe y aparece como un issue tuyo.
Cuando dudes entre añadir la opción número treinta o exponer un punto de extensión, elige lo segundo. Una función de usuario en la configuración —para filtrar, para formatear, para decidir si actuar en este buffer— cubre infinitos casos con un solo símbolo público, mientras que cada opción nueva cubre exactamente uno y multiplica las combinaciones que tienes que probar. Los plugins que envejecen bien tienen pocas opciones y varios ganchos.
El coste de un símbolo
flowchart TB u[Configuracion del usuario] --> f[Modulo raiz como fachada] f --> s[setup y comandos publicos] f --> t[Anotaciones de tipo y doc] s --> c[config interno] s --> k[core interno] k --> i[ui interno] c -.-> x[Nadie depende de esto] k -.-> x i -.-> x style f fill:#cba6f7,color:#11111b style x fill:#a6e3a1,color:#11111b
Las flechas discontinuas del diagrama son la parte importante y la que se olvida al dibujarlo: representan lo que nadie puede alcanzar legítimamente, y por tanto lo que puedes reescribir mañana sin publicar una versión mayor. Un plugin bien diseñado tiene muchas más flechas discontinuas que continuas, y esa proporción es la medida más honesta de tu libertad futura.
Cada nombre exportado tiene un coste que no se paga al escribirlo sino durante los años siguientes: hay que documentarlo, hay que probarlo, hay que mantener su comportamiento estable frente a los cambios internos y hay que seguir soportándolo cuando ya no encaja con el diseño. Una API de cuarenta funciones no es cuatro veces más trabajo que una de diez: es considerablemente más, porque las combinaciones entre ellas también son contrato. La regla operativa que mejor funciona es preguntarse, símbolo por símbolo, si estarías dispuesto a mantenerlo tres años. Si la respuesta es no, no lo exportes; deja que quien lo necesite lo pida, con un caso de uso concreto, y entonces exporta la mínima función que lo resuelva.
Conviene enunciar sin adornos la asimetría que gobierna todo este nivel, porque es la que separa a quien escribe código de quien mantiene software. Dentro de tu plugin, cualquier decisión es reversible: puedes cambiar la estructura de datos, partir un módulo en tres, sustituir el algoritmo o reescribirlo entero, y mientras el comportamiento observable no cambie nadie se entera ni tiene derecho a enterarse. En la frontera, ninguna decisión lo es. El nombre de una función exportada, el orden de sus argumentos, la forma exacta de la tabla de configuración y hasta el nombre de un grupo de resaltado están replicados en cientos de repositorios ajenos sobre los que no tienes ningún poder, y esa réplica es lo que convierte una decisión de diez segundos en una restricción permanente. De ahí se sigue la única heurística de diseño de bibliotecas que ha resistido el paso del tiempo: es infinitamente más barato añadir que quitar. Una API que empieza pequeña y crece bajo demanda converge hacia lo que la gente necesita de verdad, porque cada adición está justificada por un uso real y llega con su caso de prueba puesto. Una API que empieza completa —porque parecía elegante ofrecer todas las variantes, o porque exportar era más rápido que decidir— nunca converge: acumula funciones que nadie llama pero que no puedes borrar, opciones que interactúan de formas que no probaste, y una documentación que describe un espacio de posibilidades mayor que el que sostienes. El corolario práctico es que la fachada no es un patrón de organización sino un instrumento de política: al obligar a que todo pase por un archivo, ese archivo se convierte en la lista completa de tus promesas, cabe de un vistazo en una pantalla, y hace visible el momento en que la superficie empieza a crecer más rápido que tu capacidad de sostenerla. Los plugins que siguen vivos y usables diez años después no son los que hicieron más, sino los que prometieron menos y lo cumplieron; y esa modestia, que parece falta de ambición, es en realidad la forma más avanzada de ambición que existe en el desarrollo de software: la de construir algo que sobreviva a tu propio interés por él.
El módulo raíz es la fachada y no contiene lógica, con los require dentro de las funciones. Lo público es lo documentado, no lo alcanzable. Configuración por tabla con fusión profunda y validación, cargar sin efectos observables, un protocolo de error por categoría y valores de retorno útiles. Y ante la duda, no exportes: añadir es barato, quitar es imposible.
- Toma un plugin tuyo y escribe en un papel la lista completa de lo que un usuario puede llamar hoy. Marca cuáles estarías dispuesto a mantener tres años.
- Reescribe
init.luacomo fachada pura, con losrequiredentro de las funciones, y mide el tiempo de carga antes y después. - Mueve toda la lógica a módulos internos y comprueba que ningún ejemplo de tu README necesita nombrarlos.
- Implementa
setupcon fusión profunda y validación, y verifica que cambiar una sola clave anidada no borra las hermanas. - Documenta explícitamente qué es público, qué es interno y qué es experimental. Después borra de la fachada todo lo que no supere la prueba de los tres años.