Hace poco escribía Jorge Sánchez un interesante artículo sobre code smells que ha ido encontrado al aplicar Clean Architecture. En él menciona en un par de ocasiones la obsesión por los tipos primitivos como un factor que nos puede impedir aplicar correctamente Clean Architecture. Esto es algo que he tocado en el blog de pasada en alguna ocasión, como cuando veíamos cómo crear modelos de dominio más ricos evitando usar tipos enumerados.
Pese a que esto del primitive obsession es algo muy manido, quiero aprovechar para darle una vuelta al tema y ver si realmente es tan util evitar tipos primitivos em>spoiler: sí, es muy útil) y qué contrapartidas hay que asumir para evitar primitive obsession.
Qué aporta evitar primitive obsession
La obsesión por los tipos primitivos consiste en utilizar datos de tipos básicos para modelar conceptos de nuestra aplicación. Vamos, que en lugar de tener un tipo Age
para representar la edad de una persona, utilizamos un tipo int
. O que andamos moviendo un par de objetos DateTime
por todas partes llamados Start
y End
, en lugar de tener un objeto DateRange
.
Cuando creamos tipos específicos para representar conceptos de la aplicación ganamos varias cosas.
Si estamos trabajando con un lenguaje estático y tenemos tipos distintos para representar conceptos distintos, podemos aprovecharnos del compilador y asegurarnos de que estamos pasando los datos adecuados de un método a otro.
Así, evitaríamos que un método que trabaja con edades de usuarios recibiera por error su Id
(de tipo int
) en lugar de su Age
(de tipo Age
). De hecho, podríamos recurrir a técnicas como tipos fantasmas para diferenciar entre distintos tipos estructuralmente idénticos pero con distinto valor semántico de forma fácil.
Incluso con un lenguaje dinámico donde perdemos esta ayuda del compilador, tener tipos más específicos para representar cada concepto simplifica el mantenimiento de invariantes. Podemos asegurar que al construir un valor de tipo Age
contiene una edad válida (es decir, mayor o igual que cero), que al construir un DateRange
siempre se cumple que la fecha de inicio no es posterior a la fecha de fin, o que no sumamos dos valores Money
que tienen un Currency
distinto sin hacer una conversión previa.
En los lenguajes orientados a objetos también nos pueden servir para asociar lógica a estos tipos, aumentando la cohesión del diseño. Podríamos incluir en nuestro tipo DateRange
un método IsContainedIn(DateRange other)
que nos indique si un rango de fechas está contenido dentro de otro.
Todos estos factores hacen que reducir el uso de tipos primitivos en favor de tipos más específicos sea algo bastante recomendable si queremos tener poder modelar nuestra aplicación de una forma más expresiva y sólida, ya que será mucho más fácil saber qué representa cada valor y qué invariantes cumple.
Sin embargo, no todo son ventajas (si no, no estaría escribiendo esto) y hay que tener en cuenta las implicaciones que tienen.
Qué problemas puede conllevar
Utilizar tipos primitivos en lugar de contruir tipos más específicos puede achacarse a una cuestión de pereza. Es verdad. Sobre todo en los lenguajes más habituales (C#, Java y amigos), definir nuevos tipos es una tarea pesada, y hacerlo bien, con su Equals
, GetHashCode
, inmutabilidad si es necesaria, etc., aún más. Si tienes la suerte de trabajar con otro tipo de lenguajes (Haskell, Scala y compañía), es mucho más cómodo. Pese a todo, no creo que esto sea un problema reseñable. Si es un concepto que vas a estar manejando con frecuencia en tu aplicación, es fácil amortizar el tiempo invertido en modelarlo bien.
El principal problema que pueden introducir los nuevos tipos creados es un incremento del acoplamiento entre distintas partes del sistema. Especialmente si el sistema está compuesto por distintos módulos relativamente independientes.
Si yo tenía un API para incrementar el saldo de una cuenta que recibía un decimal
, algo así como void IncreaseBalance(decimal amount)
podía utilizarlo desde cualquier módulo que fuera capaz de crear valores de tipo decimal
que, por definición de tipo primitivo, es cualquier módulo.
Al cambiar ese API para utilizar tipos más descriptivos y pasar a void IncreaseBalance(Money money)
, los consumidores de ese API ya no dependen sólo del componente que la expone, sino también de un tipo adicional, el tipo Money
.
¿Cómo de grave es esto? Pues depende, claro. Si los tipos que se están creando son ubícuos y estables, no parece muy grave. Habremos introducido un acoplamiento indirecto entre todos los módulos que usan esos tipos, pero probablemente sea llevadero.
Lo malo es cuando los tipos ya no son tan universales, cosa que es lo habitual. Quizá el tipo Money
de una aplicación no sea idéntico al de otra. O el concepto de Name
no sea idéntico en dos partes de una misma aplicación, porque una requiere que los nombres no sean vacíos, y otra que además de no ser vacíos sólo sean caracteres alfanuméricos. En este caso se hace necesario tener varios tipos diferentes y se complica la integración entre distintos componentes porque hace falta realizar conversiones de unos tipos a otros.
Esto ocurre también cuando queremos aprovechar las librerías estándar del lenguaje que estemos empleando. Estas librerías trabajan con tipos primitivos, pero no con nuestros tipos específicos. Si tengo un tipo Name
, no puedo usar directamente las funciones típicas de string
, como ToUpper
, ToLower
, Trim
, etc. Tampoco puedo sumar valores de Weight
a menos que redefina manualmente el operador +
o cree una función específica para ello.
Un ejemplo habitual de este problema lo podemos ver en Javascript. Cuando en lugar de usar un tipo primitivo como es un Array
se utiliza una colección específica como un NodeList
, no podemos usar los métodos típicos de array y necesitamos andar convirtiendo nuestro tipo específico a un array primitivo usando Array.prototype.slice.call(...)
o el más moderno Array.from(...)
para poder emplear los forEach
, map
o filter
de turno.
Dependiendo del lenguaje y las abstracciones que soporte, esto puede mitigarse más o menos haciendo que los tipos especificos que hemos creado implementen interfaces concretos, como IComparable
o IEnumerable
en C#, protocolos como ISeq
en clojure o type classes como Ord
en Haskell.
También hay que tener cuidado en la granularidad que vamos a manejar o podemos caer en el típico caso de «yo solo quería un plátano, y me han dado la selva entera con un gorila sosteniendo el plátano».
En su post, Jorge pone un ejemplo en el que para saber si un usuario puede grabar un vídeo o no, en lugar de devolver un valor de tipo bool
que nos lo indique, propone devolver una entidad AccountPlan
que es la que contiene esa información.
Esto seguramente sea perfectamente válido, pero hay que tener en cuenta que ahora estamos exponiendo mucha más información de la realmente necesaria hacia el exterior, acoplando los consumidores del API al tipo AccountPlan
y dificultándonos futuros cambios en el modelo si, por ejemplo, necesitamos que la decisión ya no dependa sólo del AccountPlan
o incluso si queremos cambiar nuestro modelo para que AccountPlan
deje de existir.
Resumen
Para modelar un problema es bueno aprovechar las herramientas que tenemos a nuestro alcance y el sistema de tipos es una de las más poderosas. Por ello, introducir abstracciones en lugar de usar tipos primitivos nos puede ayudar a construir modelos más expresivos y sólidos, en los que es más fácil entender la intención de cada valor que estamos manejando y asegurar que los invariantes se cumplen.
Sin embargo, la introducción de nuestros tipos puede aumentar el acoplamiento entre diferentes partes del sistema, dificultar la interactuación entre ellas o con las librerías estándar, y penalizar la ocultación de información, haciendo que seas más complicado evolucionar el sistema.
El contexto, como siempre, es clave para decidir cuándo nos conviene introducir un tipo específico en lugar de utilizar un tipo primitivo. Como regla general, yo diría que cuanto más simple e independiente del contexto es el tipo que vamos a introducir, más nos beneficiaremos de introducirlo, porque debería ser más estable en el tiempo y más reutilizable, lo que compensa el acoplamiento y el posible esfuerzo de añadirle el comportamiento que no podemos aprovechar de librerías estándar.
Interesante y recurrente tema, fuente inagotable de fragilidad del software.
«no puedo usar directamente las funciones típicas » => functores, lens, … ;P
«puede aumentar el acoplamiento entre diferentes partes del sistema», ¡añade otra indirección! («module obsession» xD)
Ahí el desarrollo del software debería tirar a herramientas de análisis que supieran identificar este tipo de excesos (acople ~ sobreabstracción), de momento, tiraremos de experiencia ;)
Una vez más, un post interesante que abre un buen debate, felicidades!
Cuando mencionas:
«El principal problema que pueden introducir los nuevos tipos creados es un incremento del acoplamiento entre distintas partes del sistema»
Si tomamos el ejemplo de la moneda, es interesante verlo desde el punto de vista de connascence:
– Recurrir a un tipo primitivo introduce connascence of meaning (o convention) versus uno de tipo. En el ejemplo, entre otras cosas, implicaría que todos los módulos de nuestro sistema asumirían que el tipo decimal es una moneda de un tipo determinado, algo que introduce otros tipos de dolor.
– Respecto a los tres ejes (strength, degree & locality), aparentemente sólo en el caso del locality saldría beneficiado el tipo primitivo, pero eso (como todo lo interesante) es debatible. En realidad cuando recurrimos a un tipo primitivo lo que pasa es que nos estamos acoplando a un módulo que consideramos nativo de nuestro software y digamos que nos estamos casando con algo que nos vamos a tener que casar si o si.
Ni que decir tiene lo que implica a nivel de refactor recurrir a un decimal vs un tipo específico (strength y degree)
Generalmente cuando más nos duele primitive obsession es precisamente en tipos que suelen ser trasversales a nuestro sistema (moneda, ids, medidas, etc) y, al igual que pagamos con gusto el precio de acoplarnos al tipo «decimal», yo suelo pagar con gusto acoplarme al tipo «moneda» que hemos definido a nivel de dominio. Como siempre, todo esto lleva delante un grandísimo «depende».
Ahora es cuando aparece Uncle Bob y dice: este debate sobra si hay pruebas unitarias.
Personalmente no me terminan de convencer los argumentos a favor de una mayor legibilidad, es más, puede resultar redundante e incluso ocultar información. Ejemplo:
– Tipo específico: Age age
– Tipo primitivo: int age
El nombre de la variable ya es suficientemente significativo, además, utilizar un tipo de dato «Age» oculta el verdadero tipo de la variable.
También es interesante valorar el aumento de la complejidad si se utiliza un ORM. Los mapeos puede resultar un auténtico tostón.