Primitive Obsession es un code smell que se produce cuando diseñamos nuestras clases con valores primitivos como string, integers etc.. en lugar de crear tipos específicos que se ajustan mejor a lo que estamos queriendo hacer.
Valores y objetos
Antes de seguir con el code smell creo que es interesante repasar la diferencia entre estos dos tipos.
Los valores son tipos inmutables sin identidad. Si comparamos dos instancias de un valor en una condición y tienen el mismo estado se asume que son el mismo valor, por lo tanto la condición devuelve true. Un ejemplo podría ser un tipo money donde dos instancias con el valor 5€ son el mismo money pese a ser instancias diferentes. Este tipo de objetos se llaman también objetos de valor (Value Object).
Por el contrario objetos son tipos mutables con identidad. Si comparamos dos instancias de un objeto en una condición y tienen el mismo estado no tiene porque ser el mismo objeto, por lo tanto la condición devuelve false a no ser que sea exactamente la misma instancia del objeto. Por ejemplo si tenemos un objeto persona y hay dos instancias con mismo nombre y apellidos, no quiere decir que sean la misma persona, en este caso la identidad de las instancias es diferente y estaría reflejada por su DNI.
Valores y objetos se crean mediante el mismo tipo de construcción class, en lenguajes como java, c#, de ahí puede venir la confusión o dificultad para distinguirlos.
En c# podría ser posible crear tipos de valor o value object mediante struct pero tiene algunas limitaciones, en su lugar podemos crear tipos de referencia mediante class con semántica de tipos de valor. Por ejemplo en .Net el tipo string es un tipo de referencia pero con semántica de tipo de valor porque es inmutable y se compara objetos comparando sus valores no sus instancias.
La obsesión
Hay muchos conceptos como pueden ser nombre de usuario, dni, dirección, dinero, código postal, teléfono etc.. que es habitual ver que se diseñan de tipo string. Esto es obsesión primitiva porque cada uno de esos casos tienen unas invariantes, hay valores que no pueden contener esos campos y en lugar de tener esas validaciones repartidas por el código, es mejor crear tipos de valor inmutables que encapsulan ese comportamiento. De esa forma también evitamos incumplir el principio DRY (Don't Repeat Yourself).
Creando un Value Object
Vamos a ver un ejemplo de tipo de valor escrito en c# que va a representar un nombre de usuario.
Invariantes
Lo primero es tener un tipo UserName que protege sus invariantes. Veamos primero los test.
[Fact]
public void CreateUserName_WithNullValue_ShouldThrowArgumentNullException()
{
UserName username;
Exception ex = Assert.Throws<ArgumentNullException>(() => username = new UserName(null));
}
[Fact]
public void CreateUserName_WithInvalidValue_ShouldThrowArgumentException()
{
UserName username;
String expectedMessage = "Invalid value";
Exception ex = Assert.Throws<ArgumentException>(() => username = new UserName(" xurxodev "));
Assert.Contains(expectedMessage,ex.Message);
}
Ahora la clase.
public class UserName
{
private readonly string value;
public UserName(string value)
{
if (value == null)
throw new ArgumentNullException("value");
if (!IsValid(value))
throw new ArgumentException("Invalid value.", "value");
this.value = value;
}
private bool IsValid(string value)
{
if (string.IsNullOrEmpty(value))
return false;
return value.Trim() == value;
}
}
Igualdad
Para poder comparar dos instancias de UserName mediante el método equals.
[Fact]
public void EqualMethod_ForTwoUserNamesWithEqualValue_ShouldReturnTrue ShouldEqualsReturnTrueIfValueIsEqual()
{
UserName username1 = new UserName("xurxodev");
UserName username2 = new UserName("xurxodev");
Assert.Equal(username1, username2);
}
Necesitamos sobrescribir los método Equals y HashCode.
public override bool Equals(object obj)
{
var other = obj as UserName;
if (other == null)
return base.Equals(obj);
return object.Equals(this.value, other.value);
}
public override int GetHashCode()
{
return this.value.GetHashCode();
}
Para poder comparar mediante los operadores de igualdad.
[Fact]
public void EqualOperator_ForTwoUserNamesWithEqualValue_ShouldReturnTrue()
{
UserName username1 = new UserName("xurxodev");
UserName username2 = new UserName("xurxodev");
Assert.True(username1 == username2);
}
Necesitamos sobrescribir el operador igual y distinto.
public static bool operator ==(UserName x, UserName y)
{
if (System.Object.ReferenceEquals(x, y))
{
return true;
}
if (((object)x == null) || ((object)y == null))
{
return false;
}
return x.Equals(y);
}
public static bool operator !=(UserName x, UserName y)
{
return !(x == y);
}
El método equals que hemos sobreescrito realiza unboxing, es conveniente implementar la interfaz IEquatable<> para mejorar el rendimiento en métodos como Contains, IndexOf, LastIndexOf, y Remove en Dictionary o List. De esta forma vamos a tener un método equals que recibe el tipo a comparar de tipo UserName y modificamos el otro que recibe el parámetro de tipo object.
public override bool Equals(object obj)
{
return Equals(obj as UserName);
}
public bool Equals(UserName other)
{
if (Object.ReferenceEquals(other, null)) return false;
if (Object.ReferenceEquals(this, other)) return true;
return object.Equals(this.value, other.value);
}
Inmutabilidad
Como se aprecia en UserName no es posible modificar el valor de UserName una vez asignado en el constructor, porque es readonly. Para modificar el UserName tendríamos que crear uno nuevo y sustituirlo.
Conversión Implícita
Puede ser interesante conocer el valor string que contiene el value object.
[Fact]
public void ConvertFromUserNameToString_ShouldWorkSucessfully()
{
string username1 = new UserName("xurxodev");
Assert.Equal("xurxodev", username1);
}
Para conseguirlo añadimos la conversión implícita desde UserName a string.
public static implicit operator string (UserName userName)
{
return userName.value;
}
También es aconsejable sobrescribir el método ToString con el mismo objetivo.
public override string ToString()
{
return this.value.ToString();
}
Refactorizando
Ya tenemos nuestro value object listo, pero existe mucho código que va a ser común en value objects simples, una opción puede ser crear una clase base que contenga todo ese código.
Clase base.
public abstract class SimpleValueObject<T>:IEquatable<T>
where T : SimpleValueObject<T>
{
private readonly string value;
public SimpleValueObject(string value)
{
this.value = value;
}
public override bool Equals(object obj)
{
return Equals(obj as T);
}
public override int GetHashCode()
{
return this.value.GetHashCode();
}
public bool Equals(T other)
{
if (Object.ReferenceEquals(other, null)) return false;
if (Object.ReferenceEquals(this, other)) return true;
return object.Equals(this.value, other.value);
}
public static bool operator ==(SimpleValueObject<T> x, SimpleValueObject<T> y)
{
if (System.Object.ReferenceEquals(x, y))
{
return true;
}
if (((object)x == null) || ((object)y == null))
{
return false;
}
return x.Equals(y);
}
public static bool operator !=(SimpleValueObject<T> x, SimpleValueObject<T> y)
{
return !(x == y);
}
public static implicit operator string (SimpleValueObject<T> userName)
{
return userName.value;
}
public override string ToString()
{
return this.value.ToString();
}
}
Tipo concreto.
public class UserName:SimpleValueObject<UserName>
{
public UserName(string value):base(value)
{
if (value == null)
throw new ArgumentNullException("value");
if (!IsValid(value))
throw new ArgumentException("Invalid value.", "value");
}
private bool IsValid(string value)
{
if (string.IsNullOrEmpty(value))
return false;
return value.Trim() == value;
}
}
Ahora si volvemos a pasar todos los test deberían seguir pasando.
También podemos hacer mucho más genérica la clase base sin que contenga el campo value y mediante reflexión identificar los campos que tiene.
Conclusiones
Primitive Obsession es un code smell que si lo sabemos identificar y nos creamos un tipo de valor con la semántica de igualdad, inmutabilidad etc.. , vamos a mejorar considerablemente la calidad de nuestro código. Evitaremos tener código duplicado y también incumplir otros principios como DRY.