Diseñar la jerarquía de errores: dominio, técnica y frontera
Por qué un error de negocio y un fallo de infraestructura no pertenecen al mismo tipo, cómo traducir en cada frontera sin perder la causa, qué debe contener un error para que la interfaz pueda decidir, y qué forma dar al tipo para que sobreviva a la evolución del código.
Casi todos los sistemas mal diseñados en este terreno comparten un mismo síntoma: un único enum gigantesco con cuarenta casos, donde conviven el cupón caducado y el error de descodificación de JSON, y donde cada capa añade el suyo porque no había otro sitio. El resultado es predecible. La interfaz recibe fallos que no sabe explicar, el registro de incidencias pierde la causa técnica, los casos de uso capturan errores que jamás pueden ocurrir en su contexto y nadie puede añadir un caso sin romper a media docena de consumidores. La jerarquía de errores no es un detalle de higiene: es un problema de diseño de tipos con las mismas reglas que cualquier otro, y con una regla propia que lo domina todo —los errores se traducen en las fronteras, no viajan crudos.
- Separar los errores del dominio de los fallos técnicos y justificar por qué no comparten tipo.
- Traducir errores al cruzar cada capa preservando la causa original en una cadena inspeccionable.
- Modelar en el error los datos que permiten decidir, y dejar el texto para la capa de presentación.
- Elegir la forma del tipo —enum, struct o protocolo— según la evolución que vaya a sufrir.
Dos familias que no se mezclan
Un error de dominio es un desenlace previsto de una regla de negocio: saldo insuficiente, cupón caducado, plaza ya reservada. Nadie ha fallado; el sistema funciona exactamente como debía y está comunicando un resultado. Tiene sentido para la persona que usa la aplicación, suele admitir una acción correctiva y forma parte de la especificación tanto como el camino feliz.
Un fallo técnico es lo contrario: la infraestructura no cumplió. Se agotó el tiempo de espera, el servidor devolvió un cuerpo que no encaja con el modelo, el disco está lleno. No significa nada para quien usa el sistema, casi nunca admite una acción concreta más allá de reintentar, y su valor está en el diagnóstico, no en la comunicación.
Mezclarlos en un tipo produce tres daños simultáneos que conviene nombrar por separado: la interfaz acaba obligada a inventar un texto para casos que no sabe explicar; el diagnóstico se degrada porque el detalle técnico se aplana en un caso genérico; y la exhaustividad se vuelve inútil, porque cada consumidor debe considerar decenas de casos imposibles en su contexto.
// Dominio: lo entiende el negocio, tiene acción
enum ErrorDeReserva: Error {
case sinPlazas(alternativas: [Fecha])
case fueraDePlazo(limite: Fecha)
case yaReservado(identificador: String)
}
// Técnico: lo entiende quien opera el sistema
enum ErrorDeTransporte: Error {
case tiempoAgotado(segundos: Int)
case respuestaInvalida(codigo: Int)
case cuerpoIlegible(causa: any Error)
}
Pregunta si el fallo aparecería igual con una infraestructura perfecta. Si con red infinita, disco infinito y servidor impecable el fallo seguiría ocurriendo, es de dominio. Si desaparecería, es técnico. La respuesta es casi siempre inmediata y casi siempre correcta.
Traducir en la frontera
La regla central de esta lección es que cada capa define su propio tipo de error y traduce lo que recibe al cruzar su frontera, preservando el original como causa. Un error de descodificación no debe llegar nunca a la vista; un error de dominio no debe nacer nunca en el cliente de red.
enum ErrorDeCatalogo: Error {
case noDisponible(referencia: String) // dominio
case fallaTecnica(causa: any Error) // técnica, con la causa intacta
}
struct RepositorioDeCatalogo {
func articulo(_ ref: String) async throws(ErrorDeCatalogo) -> Articulo {
do {
let dto = try await transporte.pedir(ref)
guard dto.disponible else { throw .noDisponible(referencia: ref) }
return Articulo(dto)
} catch let e as ErrorDeCatalogo {
throw e // ya es del dominio: pasa
} catch {
throw .fallaTecnica(causa: error) // todo lo demás se envuelve
}
}
}
Ese causa es lo que separa una traducción de una amputación. Al envolver conservas la cadena entera, de modo que quien registra la incidencia puede recorrerla hasta el fallo original mientras quien presenta el error solo mira el nivel superior. Perder la causa es el error más caro de todos, porque el daño no se ve hasta la madrugada en que alguien intenta diagnosticar una incidencia de producción con un mensaje que dice, íntegramente, error desconocido.
flowchart LR T[Transporte lanza fallo tecnico] --> R[Repositorio traduce y envuelve la causa] R --> C[Caso de uso decide reintento o dominio] C --> V[Vista presenta mensaje y accion] R -.causa conservada.-> L[Registro de diagnostico] style T fill:#f38ba8,color:#11111b style C fill:#f9e2af,color:#11111b style V fill:#a6e3a1,color:#11111b
Dos antipatrones aparecen justo aquí. El primero es capturar y relanzar sin añadir nada: un do con un catch que solo hace throw error es ruido puro, y peor si por el camino borra el tipo. El segundo es capturar y tragar: una cláusula vacía que devuelve un valor por defecto convierte un fallo diagnosticable en un comportamiento raro que alguien investigará durante semanas.
Datos para decidir, texto para presentar
Un error bien diseñado responde a preguntas, no a la pregunta qué le digo al usuario. Esa se contesta en otra capa.
Lo que el tipo debe llevar dentro son los datos que permiten tomar una decisión: si el fallo es transitorio y merece reintento, cuánto conviene esperar, qué alternativas existen, qué identificador de correlación acompaña la incidencia. Todo eso son valores asociados, no frases.
extension ErrorDeCatalogo {
var esTransitorio: Bool {
if case .fallaTecnica(let causa) = self { return causa is ErrorDeTransporte }
return false
}
}
El texto, en cambio, pertenece a la presentación, y por tres razones que se sostienen solas: depende del idioma, depende del contexto —el mismo fallo se dice distinto en un formulario y en un panel de administración— y cambia mucho más a menudo que la lógica. Meter cadenas literales dentro del enum de dominio ata las tres cosas y hace imposible traducir sin recompilar la capa de negocio.
Cuando el sistema exige que el error sepa describirse a sí mismo, la conformidad correspondiente permite distinguir qué pasó, por qué y qué hacer, tres campos separados en lugar de una frase única. Que estén separados no es burocracia: permite que la interfaz muestre el primero en un banner, el tercero en un botón y reserve el segundo para un panel de detalle, sin volver a redactar nada.
// La traducción vive en la presentación, no en el dominio
func mensaje(para error: ErrorDeReserva) -> Mensaje {
switch error {
case .sinPlazas(let alternativas):
Mensaje(texto: "No quedan plazas ese día", accion: .proponer(alternativas))
case .fueraDePlazo(let limite):
Mensaje(texto: "El plazo terminó el \(limite.formateada)", accion: .ninguna)
case .yaReservado(let id):
Mensaje(texto: "Ya tienes esta reserva", accion: .abrir(id))
}
}
Ese switch es la prueba de fuego del diseño. Si al escribirlo descubres casos para los que no sabes redactar ninguna frase, esos casos no eran de dominio y están en el tipo equivocado. Si descubres que necesitas datos que el error no lleva, faltan valores asociados. Y si necesitas veinte ramas, el tipo abarca dos dominios distintos. Y la regla de redacción que resiste el contacto con usuarios reales cabe en tres líneas: describe lo ocurrido sin jerga, no culpes a quien lee, y ofrece siempre una siguiente acción posible aunque sea reintentar. Un código de incidencia sin acción asociada no ayuda a nadie; un código junto a una acción es exactamente lo que necesita quien atiende el soporte.
La forma del tipo y su evolución
Elegir entre enum, struct y protocolo no es estética: determina qué cambios podrás hacer sin romper a nadie.
Enum
Exhaustividad verificada y coincidencia de patrones cómoda. Precio: dentro de un módulo con evolución de biblioteca, añadir un caso obliga a los consumidores externos a manejar lo desconocido.
Struct con constantes estáticas
Un tipo con un código y unos datos, más constantes con nombre. Se amplía sin romper nada, a cambio de perder la exhaustividad.
Protocolo
Útil para capacidades transversales como reintentabilidad o correlación, que atraviesan varios tipos de error sin unificarlos.
Conformidad de igualdad
Hace verificables las pruebas: el error esperado se escribe como valor y se compara, en lugar de inspeccionarse a mano.
Para código de aplicación, el enum casi siempre gana: los consumidores se recompilan a la vez y la exhaustividad es una red de seguridad real cuando el dominio crece. Para una biblioteca publicada, la balanza se inclina hacia el struct ampliable, porque cada caso nuevo dejaría de ser una mejora para convertirse en una versión mayor. Y el protocolo no compite con ninguno de los dos: sirve para expresar propiedades comunes —esto se puede reintentar, esto tiene identificador de correlación— sobre familias de errores que siguen siendo distintas.
Queda una decisión de tamaño. Un tipo de error por capa suele ser lo correcto; uno por función es ceremonia insostenible; uno por aplicación es el enum gigante del que partíamos. Si un tipo de error supera la docena de casos, casi siempre está describiendo dos dominios que aún no se han separado.
Hay una asimetría curiosa en cómo tratamos las firmas: dedicamos horas a nombrar los parámetros y a afinar el tipo de retorno, y despachamos el error con un enum improvisado en cinco minutos. Sin embargo, el tipo de retorno describe qué hace tu abstracción cuando el mundo coopera, mientras que el tipo de error describe qué has decidido que es responsabilidad de quien te llama, y esa segunda decisión es la que define de verdad dónde está la frontera de tu módulo. Cada caso que expones es una afirmación fuerte: esto puede pasarte, no puedo resolverlo yo, y creo que tú sí puedes hacer algo. Cada caso que no expones —porque lo resolviste dentro, reintentaste, aplicaste un valor por defecto o te detuviste— es una afirmación igual de fuerte en sentido contrario. Vista así, la jerarquía de errores es literalmente el mapa de las responsabilidades de tu sistema, y por eso los síntomas de una mala jerarquía son siempre síntomas de arquitectura: un enum con cuarenta casos significa que un módulo se ha convertido en el vertedero donde todas las capas depositan su ignorancia; un error técnico que llega a la vista significa que alguna capa intermedia no tenía criterio propio y se limitó a reenviar; una causa perdida significa que alguien decidió que su interpretación del fallo era mejor que la evidencia. Esto tiene una consecuencia práctica que compensa toda la teoría: cuando no sepas cómo modelar un error, no te preguntes qué salió mal, pregúntate quién debe decidir qué hacer. Si la respuesta es quien te llama, el error es tuyo y debe llevar los datos de esa decisión. Si la respuesta es tú, no había error que propagar. Y si la respuesta es nadie, porque no hay decisión posible, entonces no era un error recuperable sino un fallo de lógica, y ahí el camino era detenerse, no lanzar.
- Toma el tipo de error más grande de un proyecto tuyo y clasifica cada caso como dominio o técnico; comprueba si sobreviven como un solo tipo.
- Introduce una traducción en la frontera de tu capa de datos conservando la causa y verifica que el diagnóstico puede recorrerla entera.
- Escribe una propiedad calculada que responda si un fallo merece reintento y úsala desde la capa superior en lugar de mirar casos.
- Extrae todos los textos de usuario de un enum de dominio y muévelos a la presentación; mide cuánto encoge el tipo.
- Convierte ese mismo enum en un struct ampliable y argumenta si la pérdida de exhaustividad compensa en tu contexto.