wandres.dev
DISEÑO DE APIS · las guidelines

Diseñar para el consumidor: defectos y tipos que blindan

Dos técnicas que trasladan trabajo del que usa la API al que la escribe: los valores por defecto que sustituyen a familias enteras de sobrecargas, y los tipos construidos para que los estados inválidos ni siquiera se puedan expresar, con el análisis del coste que ambas imponen.

⏱ 19 min

Una API es un reparto de trabajo. Todo lo que su autor no resuelva quedará resuelto —o no— por cada uno de sus consumidores, multiplicado por el número de llamadas que existan en el mundo. Bajo esa luz, dos decisiones que suelen presentarse como cuestiones de comodidad se revelan como decisiones económicas de primer orden: si ofreces valores por defecto o una familia de sobrecargas, y si tu tipo permite representar estados que tu implementación considera imposibles. La primera decide cuánta documentación tendrá que leer el consumidor; la segunda decide cuántas comprobaciones tendrá que escribir, y cuántas olvidará.

🎯 Al terminar esta lección sabrás
  • Sustituir familias de sobrecargas por un único método con parámetros por defecto y justificar la mejora.
  • Diseñar tipos donde los estados inválidos sean inexpresables en lugar de meramente prohibidos.
  • Envolver valores primitivos para impedir confusiones que el compilador no detecta.
  • Valorar el coste real de blindar una API y reconocer cuándo la rigidez sale cara.

Un defecto vale por una familia entera

Muchas bibliotecas heredadas de otras tradiciones exponen la misma operación cuatro o cinco veces, cada una con un subconjunto de parámetros. En Swift eso casi nunca está justificado, porque los valores por defecto cubren el mismo espacio con una sola declaración.

// La familia heredada: cuatro simbolos, cuatro paginas de documentacion
func buscar(_ t: String)
func buscar(_ t: String, sensible: Bool)
func buscar(_ t: String, sensible: Bool, limite: Int)
func buscar(_ t: String, sensible: Bool, limite: Int, orden: Orden)

// La forma idiomatica: un simbolo, una pagina
func buscar(_ texto: String,
            sensibleAMayusculas: Bool = false,
            limite: Int = .max,
            orden: Orden = .relevancia) -> [Resultado]

La ventaja obvia es de mantenimiento: añadir un parámetro nuevo con defecto no rompe a nadie, mientras que añadirlo a una familia obliga a duplicar la mitad de las combinaciones. Pero la ventaja decisiva es cognitiva y ocurre en el punto de uso. Con sobrecargas, el consumidor debe elegir primero qué símbolo llamar, decisión que exige comparar firmas; con defectos, llama siempre al mismo y solo decide qué desviaciones del comportamiento normal necesita. El autocompletado deja de ser un menú de opciones equivalentes y pasa a ser una lista de ajustes.

Los defectos comunican además una información que ninguna sobrecarga transmite: cuál es el comportamiento canónico. Cuando lees que el orden por defecto es la relevancia y el límite es ilimitado, has aprendido de un vistazo qué esperaba el autor que hicieras la mayoría de las veces. Esa señal desaparece por completo en una familia donde todas las variantes tienen el mismo rango.

💡
La regla del argumento raro

Coloca al final los parámetros con defecto y ordénalos de más a menos usados. Si un parámetro con defecto acaba apareciendo en casi todas las llamadas, el defecto está mal elegido; si no aparece nunca, probablemente sobra el parámetro.

Estados inválidos que no se pueden escribir

La segunda técnica es más profunda y cambia la naturaleza de lo que estás publicando. En vez de documentar qué combinaciones de valores son legales y comprobarlas en tiempo de ejecución, se elige una representación en la que las ilegales no existen como valores posibles.

// Prohibido pero expresable: hay que documentarlo y validarlo
struct Peticion {
    var exito: Bool
    var datos: Data?      // valido solo si exito es cierto
    var error: Error?     // valido solo si exito es falso
}

// Inexpresable: el compilador cierra la puerta
enum Peticion {
    case exito(Data)
    case fallo(Error)
}

El primer tipo admite cuatro combinaciones, de las cuales dos son estados corruptos que la implementación jamás debe producir y que, sin embargo, cualquier consumidor puede construir. El segundo admite exactamente dos, y las dos son válidas. La diferencia no es de estilo: es la diferencia entre una invariante que se mantiene por vigilancia y una que se mantiene por construcción.

flowchart LR
A[Invariante del dominio] --> B{Se puede codificar en el tipo}
B -- Si --> C[Enum o inicializador que valida]
B -- No --> D{Se detecta pronto}
D -- Si --> E[Precondicion documentada]
D -- No --> F[Riesgo latente y pruebas]
C --> G[Error en compilacion]
E --> H[Error al ejecutar]
style C fill:#a6e3a1,color:#11111b
style G fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b

El repertorio de Swift para esto es amplio y conviene tenerlo presente como catálogo. Las enumeraciones con valores asociados eliminan las combinaciones imposibles de campos. Los inicializadores que fallan o lanzan concentran la validación en un único punto y garantizan que todo valor existente ya pasó por él. Los genéricos con restricciones impiden aplicar una operación a un tipo que no la soporta. Y los tipos opacos permiten devolver algo utilizable sin exponer la representación que el consumidor podría manipular mal.

struct Porcentaje {
    let valor: Double
    init?(_ crudo: Double) {
        guard (0...100).contains(crudo) else { return nil }
        valor = crudo
    }
}

Nótese lo que ese inicializador consigue: a partir de ahí, ninguna función que reciba un Porcentaje necesita comprobar el rango, y ninguna revisión de código necesita verificar que lo hizo. La validación se ha pagado una vez, en la frontera, en lugar de en cada uso.

Envolver primitivos cuesta poco y ahorra mucho

El caso particular más rentable de la técnica anterior es el de los identificadores y las magnitudes representados como cadenas o números desnudos. Dos parámetros del mismo tipo primitivo son intercambiables para el compilador, y por tanto un error de orden pasa silenciosamente.

// El compilador acepta encantado los argumentos invertidos
func transferir(de origen: String, a destino: String, importe: Double)

// Ahora el intercambio no compila
struct CuentaID: Hashable { let valor: String }
struct Euros { let centimos: Int }

func transferir(de origen: CuentaID, a destino: CuentaID, importe: Euros)

El coste de esos envoltorios es prácticamente nulo en ejecución: un struct con un único campo almacenado se representa igual que el campo que contiene, de modo que no hay indirección ni asignación de memoria adicional. El coste real es de escritura, y se reduce mucho conformando a ExpressibleByStringLiteral o a los protocolos numéricos cuando la ergonomía lo pida.

Las unidades merecen mención aparte porque son la fuente clásica de fallos caros. Un Double llamado duracion no dice si son segundos o milisegundos, y la biblioteca estándar resolvió ese problema para sí misma introduciendo Duration en vez de seguir aceptando números sueltos. La lección se traslada directamente: si tu dominio tiene magnitudes, la unidad pertenece al tipo y no al nombre del parámetro ni al comentario.

El precio de blindar

Sería deshonesto presentar estas técnicas sin su factura. Blindar una API tiene tres costes concretos y hay contextos donde no compensan.

El primero es la rigidez ante la evolución. Una enumeración pública con casos exhaustivos es un contrato fuerte, y añadirle un caso rompe a todo consumidor que la conmute sin caso por defecto. Por eso las bibliotecas que atraviesan módulos disponen de la anotación de no congelación, y por eso conviene pensar dos veces antes de exponer un enum público en un dominio que aún se mueve.

El segundo es la fricción de conversión. Cada envoltorio obliga a construir y desenvolver en las fronteras, y cuando una API está mal cosida con las adyacentes el consumidor pasa la mitad del código traduciendo. La señal de alarma es ver conversiones repetidas en el lugar de la llamada.

El tercero es la complejidad de lectura. Un tipo que codifica seis invariantes mediante genéricos anidados puede ser irreprochable y a la vez ilegible, y un mensaje de error del compilador sobre tipos genéricos profundos cuesta más tiempo que la comprobación en ejecución que ha sustituido. El criterio práctico es proporcional: cuanto más grave sea la consecuencia del mal uso y más lejos esté de detectarse, más justificado está el blindaje.

🎚️

Defectos, no familias

Un símbolo con parámetros por defecto documenta el comportamiento canónico y evoluciona sin romper nada.

🛡️

Inexpresable, no prohibido

Una invariante codificada en el tipo se sostiene por construcción; una documentada se sostiene por vigilancia.

🏷️

Unidades en el tipo

Envolver primitivos cuesta cero en ejecución e impide el error de orden que el compilador nunca vería.

Quién paga el coste de una decisión de diseño

Detrás de estas dos técnicas late un único principio que trasciende la programación y que podría formularse así: en toda interfaz existe una asimetría radical entre quien la define y quienes la usan, y la calidad del diseño se mide por la dirección en que esa asimetría se explota. El autor toma la decisión una vez y en condiciones ideales, con el modelo completo en la cabeza, tiempo para pensar y todo el contexto disponible; los consumidores la sufren miles de veces, cada uno con conocimiento parcial, bajo presión y en mitad de otra tarea que es la que de verdad les ocupa. Cualquier trabajo que el autor no haga se replica en cada punto de uso, y no se replica intacto sino degradado, porque quien lo rehace carece del contexto que el autor tenía. Esto convierte la validación diferida en algo peor que una molestia: es una apuesta estadística sobre la disciplina ajena en la que el diseñador siempre pierde, porque basta un consumidor descuidado entre mil para que la invariante que creía garantizada deje de serlo, y ese consumidor descuidado será, con probabilidad uno, el propio autor dentro de dos años. La misma lógica explica por qué la sobrecarga múltiple es un fallo de diseño y no una alternativa estilística: transfiere al lector una decisión —elegir símbolo— que el autor ya había tomado implícitamente al escribir la implementación, obligándole a reconstruir por comparación de firmas algo que un parámetro con defecto le habría dicho de una vez. Y explica también por qué la formulación negativa importa tanto: no se trata de prohibir el mal uso, sino de hacerlo inexpresable, porque una prohibición requiere un vigilante y la inexpresabilidad no requiere ninguno. La lección transferible alcanza a cualquier disciplina donde alguien construya algo que otros usarán, desde un formulario hasta una norma legal: la pregunta correcta nunca es si tu diseño permite el uso correcto, sino cuánto esfuerzo, atención y conocimiento exige del usuario para no equivocarse, y si estás dispuesto a apostar a que todos ellos lo tendrán, siempre, incluso el día en que estén cansados.

📝
Lo esencial

Los parámetros por defecto sustituyen familias de sobrecargas con menos símbolos, mejor evolución y una señal clara de cuál es el comportamiento normal. Codificar invariantes en el tipo las convierte en garantías por construcción, y envolver primitivos elimina errores de orden y de unidad a coste nulo en ejecución. El blindaje se paga en rigidez, conversiones y legibilidad, así que se aplica en proporción a la gravedad del mal uso que evita.

⚔️ Blinda una API que ya existe
  1. Encuentra en tu código una familia de sobrecargas y colápsala en un método con parámetros por defecto ordenados por frecuencia de uso.
  2. Localiza un tipo con campos opcionales correlacionados y reescríbelo como enumeración con valores asociados.
  3. Cuenta cuántas comprobaciones desaparecen de los consumidores tras ese cambio.
  4. Sustituye dos parámetros del mismo tipo primitivo por envoltorios y comprueba que el intercambio deja de compilar.
  5. Argumenta por escrito un caso de tu propio código donde blindar no compensa y explica qué coste concreto lo desaconseja.