wandres.dev
SPM Y MÓDULOS · organizar el código

Diseñar la librería: la API es una promesa fechada

Qué hacer público y qué no, el presupuesto de superficie, el significado exacto de cada número en el versionado semántico, el catálogo de cambios aparentemente aditivos que rompen en Swift, y las herramientas para evolucionar sin traicionar el contrato: depreciación, tipos opacos y verificación automática de rupturas.

⏱ 21 min

Publicar una biblioteca cambia la naturaleza del código que escribes. Hasta ese momento cualquier decisión era reversible: renombrar, reorganizar, borrar. Después, cada símbolo público es una obligación con fecha de caducidad que tú no controlas, porque el que decide cuándo termina no eres tú sino el ritmo al que tus usuarios pueden migrar. Ese es el cambio conceptual entero: dejas de escribir código y pasas a emitir promesas, y el número de versión es el instrumento con el que declaras cuáles has roto. La habilidad que distingue a una biblioteca que envejece bien de una que consume a su autor no es la elegancia del diseño inicial, sino haber decidido pronto y conscientemente qué se negaba a prometer.

🎯 Al terminar esta lección sabrás
  • Aplicar un criterio explícito para decidir qué hacer public, qué dejar package y qué no exponer jamás.
  • Traducir cada categoría de cambio al número de versión que le corresponde y justificar la traducción.
  • Reconocer los cambios que parecen aditivos y rompen a los clientes de Swift.
  • Montar un ciclo de evolución con depreciación, migración y verificación automática de rupturas.

El presupuesto de superficie

Trata la superficie pública como un presupuesto que se gasta, no como un espacio que se llena. Cada símbolo público tiene un coste recurrente: hay que documentarlo, probarlo, mantenerlo compilando y migrarlo cuando cambie el mundo alrededor. Y tiene un coste oculto mayor, que es el diseño que impide: un tipo expuesto es un tipo que ya no puedes partir en dos.

El criterio operativo cabe en una pregunta. Ante cada declaración: si mañana quiero cambiarla, ¿a quién le debo una migración? Si la respuesta es «a nadie fuera de este repositorio», el nivel correcto es internal o package. Solo cuando la respuesta es «a mis usuarios, y estoy dispuesto» corresponde public.

De ahí salen varias preferencias concretas que ahorran versiones mayores. Expón funciones antes que tipos, porque una función se puede reimplementar y un tipo, una vez visible, arrastra su forma. Expón protocolos con pocos requisitos antes que clases, y estructuras antes que clases, porque una clase pública sin open ya es conservadora y con open regala el patrón de llamadas internas. Devuelve tipos opacos cuando el cliente solo necesita capacidades y no identidad:

public func lineas(de fichero: URL) -> some AsyncSequence<String, any Error> {
    // el tipo concreto sigue siendo mio y puedo cambiarlo en una version menor
}

Y no expongas los tipos de tus dependencias en tus firmas salvo que quieras convertirlas en parte de tu contrato: el día que aparezcan en un parámetro público, la versión mayor de esa dependencia se convierte automáticamente en versión mayor tuya. Los imports con nivel de acceso permiten que el compilador vigile esa frontera por ti.

💡
Lo que no escribes no lo mantienes

La forma más barata de reducir el coste de una biblioteca es no publicar una API hasta que alguien la haya necesitado dos veces. Una función interna que se hace pública en la versión menor siguiente no cuesta nada; una pública que hay que retirar cuesta una versión mayor y la paciencia de todos.

Qué promete cada número

El versionado semántico solo tiene sentido si se entiende para qué existe: automatizar la actualización. Cuando escribes from: "1.2.0", autorizas al resolutor a subirte a cualquier versión menor futura sin preguntarte. Todo el sistema descansa en que ese salto sea seguro, y esa seguridad no la garantiza ninguna herramienta, la garantiza quien etiqueta.

Conviene subrayar que el sujeto de la promesa no es el código sino el cliente: una versión es mayor o menor según lo que le ocurra a quien te usa, no según cuánto hayas reescrito por dentro. Reescribir un módulo entero sin tocar una firma es un parche; renombrar una etiqueta de argumento es una mayor.

La traducción precisa, en términos de Swift, es esta. Mayor: cualquier cambio tras el cual un cliente que compilaba pueda dejar de compilar, o compilando siga haciendo algo distinto. Menor: adiciones estrictamente compatibles, es decir, nada de lo que existía cambia de forma ni de significado. Parche: corrección de comportamiento sin ningún cambio de API.

Hay una trampa concreta en el tramo inicial. En SwiftPM, from: "0.4.0" significa literalmente el rango que va de esa versión hasta la 1.0.0: no hay tratamiento especial para el cero, a diferencia de otros gestores que interpretan la primera cifra no nula como la mayor. Mientras estés por debajo de 1.0, la única protección real de tus usuarios es que usen .upToNextMinor, cosa que casi nadie hace. La conclusión práctica es incómoda y verdadera: quedarse indefinidamente en 0.x no es prudencia, es trasladar el riesgo a quien no puede verlo. Llega a 1.0 en cuanto la forma sea la que piensas defender.

flowchart TD
A[He cambiado algo publico] --> B[Puede un cliente dejar de compilar]
B -->|Si| M[Version mayor]
B -->|No| C[Puede cambiar el comportamiento observable]
C -->|Si| M
C -->|No| D[He anadido superficie nueva]
D -->|Si| N[Version menor]
D -->|No| P[Version parche]
M --> R[Requiere guia de migracion y ciclo de depreciacion previo]
style M fill:#f38ba8,color:#11111b
style N fill:#a6e3a1,color:#11111b
style P fill:#89b4fa,color:#11111b

Lo aditivo que rompe

La categoría peligrosa no es la de los cambios que sabes que rompen, sino la de los que juras que no rompen. En Swift hay un catálogo bien identificado y merece la pena tenerlo memorizado.

Añadir un caso a un enum público. Sin evolución de biblioteca, el cliente debe agotar los casos en su switch. Un caso nuevo hace que ese switch deje de compilar. Es la ruptura más frecuente y la que más sorprende, y por eso un enum público solo debe usarse cuando el conjunto de casos es realmente cerrado por naturaleza; si prevés crecimiento, una estructura con propiedades estáticas te da la misma ergonomía sin la obligación de agotar.

Añadir un requisito a un protocolo público. Rompe a todos los que lo adoptan. Darle implementación por defecto lo hace compilable, no seguro: los adoptantes existentes empiezan a usar en silencio una implementación que nadie escribió pensando en ellos, que es una ruptura de comportamiento sin ningún síntoma.

Añadir una sobrecarga. Puede introducir ambigüedad en llamadas que antes resolvían sin dudar, o cambiar cuál se elige. El sistema de inferencia de Swift es lo bastante potente para que una sobrecarga aparentemente inocua desplace la resolución en algún punto de llamada que jamás verás.

Añadir un parámetro con valor por defecto. Los clientes que llaman siguen compilando, pero cualquiera que use la función como valor —pasarla a un argumento de tipo función, o guardarla en una variable— deja de compilar, porque el tipo de la función ha cambiado.

Añadir una conformidad. Suele ser aditivo, salvo cuando colisiona con una conformidad retroactiva que un cliente hubiera declarado por su cuenta. Y el reverso: una biblioteca nunca debe declarar conformidades retroactivas sobre tipos y protocolos ajenos, porque el día que el dueño legítimo añada la suya, cuál gana queda indefinido.

Cambiar una etiqueta de argumento. Es un cambio de nombre en toda regla, porque la etiqueta forma parte del nombre de la función en Swift. Lo mismo vale para reordenar parámetros aunque los tipos sean distintos.

Añadir una conformidad a Sendable o a un protocolo con requisitos implícitos. Parece pura documentación y modifica qué comprobaciones aplica el compilador en el código del cliente, que puede pasar a ver errores donde antes no los había.

La conclusión práctica de la lista es que la superficie más segura es la más pequeña y la más nominal: pocas funciones, pocos tipos, y ningún punto de extensión que no hayas decidido sostener. Cuando dudes entre exponer un enum o una estructura con constantes estáticas, el segundo diseño te compra la posibilidad de crecer sin versión mayor, y ese margen vale más que la exhaustividad del switch que le regalas al cliente.

public struct Formato: Sendable, Hashable {
    public let bruto: String
    public init(_ bruto: String) { self.bruto = bruto }

    public static let json = Formato("json")
    public static let binario = Formato("binario")
    // anadir un caso nuevo aqui es una version menor, no una mayor
}
🧾

Presupuesto, no espacio

Cada símbolo público cuesta para siempre. La pregunta no es si es útil, sino si estás dispuesto a mantenerlo hasta la próxima mayor.

🚧

Enum cerrado o nada

Un enum público promete que la lista de casos no crecerá. Si va a crecer, usa una estructura con constantes estáticas.

🔭

Devuelve capacidades

Un retorno opaco entrega lo que el cliente necesita sin entregarle el tipo concreto, y te deja cambiarlo en una versión menor.

Evolucionar sin romper

Una versión mayor no debería ser nunca una sorpresa. El ciclo sano tiene tres fases y todas caben en el lenguaje.

Primero anunciar: en una versión menor, marcar lo que va a desaparecer y decir por qué y por qué se sustituye. La forma con reemplazo es la única que permite arreglar el código automáticamente desde el editor.

@available(*, deprecated, renamed: "cargar(desde:)",
           message: "la variante sincrona se retira en la 3.0")
public func cargar(_ url: URL) -> Datos { ... }

Segundo convivir: mantener ambas rutas durante al menos un ciclo completo, para que quien tenga una dependencia intermedia sin actualizar pueda avanzar por partes. Retirar en la misma versión en que se deprecia no es un ciclo de depreciación, es un aviso decorativo.

Tercero retirar, en una mayor, acompañada de una guía de migración que enumere los cambios y su sustitución. Una versión mayor sin guía traslada a cada usuario el trabajo de descubrir por su cuenta lo que tú ya sabías.

Hay además una cuarta fase que casi nadie planifica y que decide si la mayor se adopta o se ignora: facilitar la convivencia. Mientras exista una parte del ecosistema anclada en tu versión anterior, cualquiera que dependa de las dos a la vez está bloqueado, y bajo la regla de versión única ese bloqueo no tiene escapatoria local. Por eso una versión mayor bien gestionada suele ir acompañada de un último parche sobre la línea antigua que retropropaga las correcciones críticas, y de una ventana anunciada de soporte con fecha. La alternativa —cortar en seco— no acelera la migración, la reparte en el tiempo de la peor manera posible.

Y por encima de todo, verificar. SwiftPM incluye una comprobación automática de rupturas que compara la superficie actual contra una etiqueta anterior:

swift package diagnose-api-breaking-changes 2.4.0

Ejecutarlo en integración continua contra la última etiqueta publicada convierte la disciplina de versionado en algo comprobable y no en algo recordado. Es, con diferencia, la inversión de mayor rendimiento de esta lección entera: elimina de golpe la clase de errores que consiste en etiquetar como menor algo que no lo era.

Compatibilidad es contravarianza, y el resto es aproximación

Existe una definición precisa de cambio compatible y no procede del versionado semántico sino de la teoría de tipos: una nueva versión es compatible con la anterior si es un subtipo de ella según la regla de contravarianza, esto es, si exige menos o igual de sus entradas y ofrece más o igual en sus salidas. Relajar un requisito, aceptar un tipo más general, garantizar una propiedad adicional: todo eso es seguro por construcción. Exigir más, devolver menos, estrechar lo aceptado: todo eso rompe, aunque el diff parezca minúsculo. Vista así, la distinción entre mayor y menor no es más que una aproximación de una cifra a esa relación de subtipado, y sus fallos característicos se explican solos: es aditivo añadir un caso a un enum, y sin embargo rompe, porque el cliente que agota casos no consume el tipo, lo analiza, y analizar es exigir conocimiento exhaustivo de la forma. Rich Hickey lo formuló sin recurrir al vocabulario de tipos y llegó al mismo sitio: el crecimiento es siempre seguro, la ruptura nunca lo es, y una versión mayor no es una versión nueva de lo mismo sino una cosa distinta que ha usurpado el nombre de la anterior; que la industria haya normalizado numerarla como continuación es precisamente lo que hace que las migraciones duelan. Ahora bien, incluso una biblioteca que respetara la contravarianza con rigor matemático seguiría rompiendo a sus usuarios, y ese es el límite duro que conviene aceptar pronto: la ley de Hyrum observa que, con suficientes consumidores, todo comportamiento observable de tu sistema pasa a ser dependido por alguien, con independencia de lo que prometiera el contrato. El orden en que iteras un diccionario, la latencia de una operación, el texto exacto de un mensaje de error, el tipo concreto que lanzas: nada de eso está en tu API declarada y todo eso aparecerá en un informe de fallo cuando lo cambies. De ahí se sigue la única estrategia que escala, y no es diseñar mejor la interfaz sino reducir lo observable: devolver tipos opacos en vez de concretos, aleatorizar deliberadamente lo que no prometes ordenado, no exponer enums de error abiertos, no filtrar los tipos de tus dependencias. Cada detalle que consigues ocultar es un grado de libertad que conservas durante años. Y por eso la afirmación más útil que puede hacerse sobre el diseño de bibliotecas es también la más contraintuitiva: el valor de una API no está en lo que permite hacer, que casi siempre puede lograrse de varias maneras, sino en lo que ha decidido no decir, porque es exactamente ese silencio lo que te dejará seguir cambiando el interior sin pedirle permiso a nadie.

📝
Lo esencial

La superficie pública es un presupuesto con coste recurrente, y el criterio para gastarlo es a quién le deberías una migración si cambiases esa declaración mañana. El número mayor se reserva para todo lo que pueda impedir que un cliente compile o alterar lo que observa; el menor, para adiciones estrictamente compatibles. Por debajo de 1.0 no hay ninguna protección especial en SwiftPM, así que quedarse en 0.x traslada el riesgo a quien no lo ve. Y la lista de cambios que parecen aditivos y rompen —un caso de enum, un requisito de protocolo, una sobrecarga, un parámetro con valor por defecto— se neutraliza mejor con una comprobación automática en integración continua que con buena memoria.

⚔️ Audita tu propio contrato
  1. Lista todos los símbolos públicos de una biblioteca tuya y marca cuáles pasarían a package sin romper a nadie externo.
  2. Ejecuta swift package diagnose-api-breaking-changes contra tu última etiqueta y clasifica cada hallazgo como mayor, menor o parche.
  3. Busca un enum público con probabilidad de crecer y conviértelo en una estructura con constantes estáticas.
  4. Localiza en tus firmas públicas los tipos que provienen de dependencias y decide en cada caso si son contrato o filtración.
  5. Deprecia una función con renamed:, publica la versión menor y comprueba que el editor aplica la corrección automática.