Diseñar tipos de dominio: identificadores, unidades y cantidades
Con el mecanismo entendido queda la parte que decide el resultado: qué envolver, con qué invariantes, dónde colocar la conversión hacia el mundo exterior y en qué casos la disciplina no compensa. Esta lección propone un catálogo de patrones para identificadores, magnitudes con unidad y cantidades validadas, examina el efecto medible sobre la legibilidad y sobre los errores, y traza con franqueza la frontera donde envolver estorba más de lo que protege.
Todo lo estudiado hasta aquí es mecanismo, y el mecanismo es la parte fácil. La parte difícil es el juicio: decidir qué conceptos de tu dominio merecen un tipo propio, cuáles se defienden solos con un nombre de parámetro, dónde deben vivir las validaciones para que no se repitan, cómo se cruza la frontera hacia la base de datos y hacia el protocolo sin llenar el código de conversiones, y cuándo la respuesta correcta es no envolver nada. Esa última pregunta es la que casi nunca se plantea en los textos entusiastas y es la que más distingue a un diseño maduro, porque una técnica que se aplica sin excepciones deja de ser una decisión de ingeniería y se convierte en un ritual. Esta lección cierra el nivel poniendo el mecanismo al servicio del criterio.
- Construir tipos de dominio para las tres familias habituales con los patrones adecuados a cada una.
- Colocar las validaciones y las conversiones en los bordes, sin dispersarlas por el interior del sistema.
- Evaluar el efecto de la disciplina sobre la legibilidad y sobre las clases de error que elimina.
- Delimitar con criterio los casos en los que envolver no compensa.
Un catálogo de tres patrones
El primer patrón es el del identificador puro, que es también el más frecuente y el más barato. No lleva invariante, no valida nada y su única función es impedir la mezcla con otro identificador. La forma canónica incluye una conversión a texto explícita, porque el comportamiento heredado por omisión rara vez es el que se quiere en los registros.
@JvmInline
value class IdCuenta(val valor: Long) {
override fun toString(): String = "cuenta#" + valor
}
@JvmInline
value class IdPedido(val valor: Long)
@JvmInline
value class Referencia(val valor: Uuid) // sobre el tipo UUID de la biblioteca
El tercer caso merece un comentario. Un identificador universal no cabe en una clase de valor por sí mismo, pero sí cabe una clase de valor que lo envuelva, porque el campo único es entonces una referencia. Se obtiene así la distinción de tipos entre la referencia de un pedido y la de un envío sin añadir ningún objeto sobre los que ya había, que es un buen negocio.
El segundo patrón es el de la magnitud con unidad, donde el envoltorio hace un trabajo más ambicioso: además de impedir la mezcla, define qué operaciones tienen sentido. Sumar dos importes es legítimo; multiplicarlos no significa nada. Multiplicar un importe por un factor sin unidad sí lo significa. El tipo puede expresar todo eso.
@JvmInline
value class Centimos(val valor: Long) {
operator fun plus(otro: Centimos): Centimos = Centimos(valor + otro.valor)
operator fun times(factor: Int): Centimos = Centimos(valor * factor)
fun aEuros(): String = "%d,%02d".format(valor / 100, valor % 100)
companion object {
fun deEuros(euros: Int): Centimos = Centimos(euros * 100L)
}
}
El tercer patrón es el de la cantidad validada, que añade una invariante comprobada en la construcción. Aquí conviene la combinación de constructor privado y fábrica, porque un constructor que lanza obliga a quien llama a capturar o a saber de antemano que el valor es válido, mientras que una fábrica que devuelve un tipo nulable convierte la posibilidad del fallo en algo que el sistema de tipos exige tratar.
@JvmInline
value class Porcentaje private constructor(val base100: Int) {
fun aplicarA(c: Centimos): Centimos = Centimos(c.valor * base100 / 100)
companion object {
fun de(v: Int): Porcentaje? = if (v in 0..100) Porcentaje(v) else null
fun exacto(v: Int): Porcentaje = de(v) ?: error("porcentaje fuera de rango")
}
}
Identificador
Sin invariante y sin operaciones. Solo separa conceptos. Coste nulo y beneficio inmediato en toda firma con más de un identificador.
Magnitud
Define qué aritmética tiene sentido. Suma dentro de la unidad, producto solo con escalares, conversión explícita al presentar.
Cantidad validada
Constructor privado y fábrica. La existencia del valor es prueba de la invariante en todo el sistema que lo recibe.
Los bordes: dónde vive la conversión
El error de implantación más común no consiste en elegir mal los tipos, sino en dejar que las conversiones se repartan por todo el sistema. La regla que evita esa dispersión es que el tipo de dominio empieza en el borde de entrada y termina en el borde de salida, y que entre ambos no debe aparecer nunca el subyacente crudo.
// borde de entrada: una unica conversion y una unica validacion
fun mapearFila(fila: Fila): Cuenta = Cuenta(
id = IdCuenta(fila.getLong("id")),
saldo = Centimos(fila.getLong("saldo_centimos")),
)
// borde de salida: se desenvuelve al serializar
fun aRespuesta(c: Cuenta): Map<String, Any> = mapOf(
"id" to c.id.valor,
"saldo" to c.saldo.aEuros(),
)
Entre esos dos puntos, ninguna función debería aceptar el tipo subyacente. Cuando aparece una que lo hace suele estar señalando una fuga real: alguien necesitaba un dato en crudo y no encontró la operación adecuada en el tipo de dominio, de modo que lo abrió. La respuesta correcta no es prohibir la apertura sino añadir al tipo la operación que faltaba.
Esa regla admite una lectura práctica muy cómoda para las revisiones de código. Cada aparición del campo subyacente fuera de la propia declaración del tipo y de los dos bordes es un candidato a revisar, y localizarlas es trivial porque el nombre del campo es único. Un sistema sano tiene esas apariciones concentradas en unos pocos ficheros; cuando empiezan a repartirse por la lógica de negocio, el tipo se está usando como una etiqueta decorativa y no como una abstracción.
flowchart LR A[Base de datos o protocolo] --> B[Borde de entrada] B --> C[Tipos de dominio] C --> D[Logica de negocio] D --> C C --> E[Borde de salida] E --> F[Respuesta o persistencia]
Con la serialización conviene ser explícito. La mayoría de las bibliotecas del ecosistema saben tratar una clase de valor como su campo subyacente cuando se les indica, y ese es casi siempre el comportamiento deseado, porque el formato externo es un contrato con terceros que no debería cambiar por una decisión interna de modelado. La alternativa, dejar que la biblioteca serialice un objeto con un campo, produce documentos con un nivel de anidamiento espurio que después hay que sostener durante años.
Lo que se gana y dónde no compensa
El efecto sobre la legibilidad es el más inmediato y también el más fácil de subestimar antes de haberlo experimentado. Una firma con cinco parámetros del mismo tipo primitivo obliga a consultar la documentación o la implementación para escribir la llamada; la misma firma con cinco tipos de dominio se lee sin ayuda y el editor la completa correctamente. El efecto sobre los errores es de otra naturaleza: no reduce la probabilidad de equivocarse, la anula para toda una clase de equivocaciones, porque el programa erróneo deja de compilar.
Ese cambio en la lectura tiene una manifestación concreta que se puede comprobar sin instrumentar nada: la cantidad de contexto que hace falta para revisar una llamada. Con primitivos, revisar exige abrir la definición de la función, contar los parámetros y verificar el orden; con tipos de dominio, la revisión ocurre en la propia línea, porque un argumento del tipo equivocado ya no está ahí. La diferencia se nota sobre todo en las revisiones de código ajeno y en los cambios hechos con prisa, que es exactamente cuando se cometen estos errores.
Hay un tercer efecto menos evidente y probablemente más valioso a largo plazo, que es sobre la capacidad de refactorizar. Cuando un concepto tiene tipo propio, la herramienta puede encontrar todos sus usos con precisión; cuando el concepto es un entero largo entre otros muchos, la búsqueda depende de nombres de variable y produce falsos positivos y falsos negativos a partes iguales. Cambiar la representación de un identificador de entero a texto es media hora de trabajo mecánico en el primer caso y una semana de arqueología en el segundo.
Dicho todo lo cual, hay situaciones en las que envolver no compensa y conviene nombrarlas sin rodeos. No compensa cuando el valor no viaja: una variable local en una función de diez líneas no puede confundirse con nada. No compensa cuando el concepto solo aparece en un sitio y nunca se pasa como argumento. No compensa en el interior de un algoritmo numérico donde el mismo valor cambia de significado cada pocas líneas, porque ahí el tipo de dominio se convierte en una sucesión de conversiones que oscurecen la aritmética. No compensa cuando el tipo tendría que atravesar continuamente posiciones genéricas en un camino caliente, situación en la que la alternativa correcta es el patrón del array envuelto estudiado en la lección anterior. Y desde luego no compensa envolver un tipo que ya expresa lo que hace: un texto que no significa nada más que un texto no gana nada dentro de una clase.
Cuando una función empieza a recibir tres tipos de dominio y a devolver un cuarto que solo existe para transportar el resultado a la línea siguiente, la abstracción ha dejado de modelar y ha empezado a ceremoniar. La señal de alarma fiable es la proporción de conversiones respecto a operaciones: si en un fichero hay más aperturas del envoltorio que usos del tipo, ese tipo no estaba modelando nada.
Si vas a introducir esto en un sistema existente, el orden que produce más beneficio con menos fricción es identificadores primero, magnitudes monetarias después y cantidades validadas al final. Los identificadores no llevan invariante, se pueden introducir tipo a tipo y su valor se aprecia desde la primera firma que se convierte.
Conviene terminar el nivel elevando la mirada desde el mecanismo hasta lo que el mecanismo permite decir, porque son cosas distintas y solo la segunda cambia la forma de trabajar. Cuando se declara que un porcentaje es un entero entre cero y cien y se cierra el único camino de construcción con una fábrica que comprueba el rango, se ha hecho algo más que evitar un error: se ha creado un lugar del programa donde una afirmación sobre el dominio es verdadera por construcción, y toda función que reciba ese tipo hereda la verdad sin volver a comprobarla y sin confiar en nadie. Eso es cualitativamente diferente de validar en la entrada y esperar que nadie estropee el valor por el camino, porque lo segundo depende de que cada persona que toque el código en los próximos años conozca una regla que no está escrita en ninguna parte verificable. La consecuencia práctica es que las validaciones dejan de repetirse. En un sistema sin tipos de dominio, cada función defensiva comprueba otra vez lo que ya se comprobó tres capas más arriba, porque su autor no tenía forma de saber qué garantías traía el argumento; el resultado es un código lleno de comprobaciones redundantes que nadie se atreve a quitar y que aun así no cubren todos los caminos. En un sistema con tipos de dominio, la comprobación ocurre una vez, en la frontera, y a partir de ahí el tipo transporta la garantía. Se elimina así una forma de deuda especialmente insidiosa, que es la del conocimiento que existe pero no se puede consultar. Queda una advertencia final que da sentido a la última sección. Nada de esto convierte la disciplina en un imperativo universal, y el mismo razonamiento que justifica envolver un identificador que atraviesa treinta funciones desaconseja envolver una variable local que vive seis líneas. La pregunta que conviene hacerse ante cada candidato no es si el concepto merece un nombre, porque todos lo merecen, sino cuánta distancia va a recorrer ese valor y cuántas manos lo van a tocar. Los tipos protegen a través de la distancia y del tiempo, y donde no hay ni distancia ni tiempo, lo único que aportan es ceremonia.
- Elige el identificador más usado de tu sistema, dale un tipo propio y recorre los errores de compilación anotando cuántos revelan un uso que no habías previsto.
- Convierte una magnitud monetaria en tipo de dominio y define en él únicamente las operaciones que tienen sentido en la unidad.
- Toma una validación que hoy se repite en varias funciones, muévela a la fábrica de un tipo validado y cuenta cuántas comprobaciones desaparecen.
- Localiza los bordes de entrada y salida de un módulo y comprueba que ninguna función intermedia acepta el tipo subyacente.
- Elige un caso donde decidas no envolver, escribe en una línea por qué, y contrasta ese criterio con la lista de la última sección.