Qué es una Entidad de Domain Driver Design y cómo se implementa en c# y .net
Foto de Veronica Benavides en Unsplash
En este artículo voy a describir qué es una Entidad desde el punto de vista de Domain Driven Design, qué características tiene y cómo implementarla en c#.net.
1. Definición de Entidad
Una entidad es una clase que representa un concepto de la capa de negocio de la aplicación. Dicho concepto debe ser único y perdurable en el tiempo.
Así lo define Eric Evans en Domain Driven Design:
Muchos objetos no se definen por sus atributos sino por su continuidad en el tiempo y por su identidad.
Eric Evans. Domain Drivern Design.
A nivel práctico significa que una Entidad contiene un identificador único. Ese identificador es una propiedad inmutable que solemos representar en c# con un int, long, guid o string. Es posible tener dos Entidades con diferentes atributos (propiedades en c#), pero si su identificador es el mismo, se trata de la misma Entidad.
Cada aplicación tiene Entidades propias relacionadas con su negocio. Por ejemplo, en un CRM, una Entidad podría ser un Cliente, y en una aplicación de facturas, una Factura.
En c# no tenemos ningún elemento de serie que sea capaz de cumplir con este comportamiento.
2. Comparando objetos en c#
Imagina estas tres variables que representan el mismo concepto persona en c#:
int persona = 1;
int mismaPersona = 1;
int otraPersona = 2;
Si empezamos a realizar comparaciones entre ellas todo va a funcionar como esperamos:
if(persona == mismaPersona) // true
{
/* Código a ejecutar*/
}
if(persona == otroPersona) // false
{
/* Este código no se ejecuta*/
}
Esto es así porque int es un tipo de valor y cuando se compara con otra variable de tipo int se verifica si el valor que contienen es el mismo. A este concepto se le llama comparación por valor.
Veamos qué pasa si en lugar de enteros utilizamos clases.
Primero creo la clase Persona:
public class Persona
{
public int Id { get; set;}
}
Ahora aplico el mismo código que antes:
// Inicializo igual que antes
var persona = new Persona { Id: 1 };
var mismaPersona = new Persona { Id: 1 };
var otraPersona = new Persona { Id: 2 };
// Aplico las mismas comparaciones
if(persona == mismaPersona) // false ¿? -> antes era true
{
/* Este código no se ejecuta */
}
if(persona == otroPersona) // false
{
/* Este código no se ejecuta */
}
Ahora la comparativa entre persona y mismaPersona es diferente. No se compara el valor de la implementación sino la dirección de memoria donde se encuentra la variable, por eso el resultado es falso. Son objetos que se encuentran en diferentes posiciones de memoria.
A este concepto se llama comparación por referencia.
Dos clases son iguales si son el mismo objeto en memoria, no si tienen los mismos valores.
Dos enteros son iguales si tienen el mismo valor, aunque sean objetos diferentes en memoria.
Nota*: Los strings son tipos por referencia, pero se comparan por valor.
3. Clase base Entidad en c#
Existe un paquete nuget CSharpFunctionalExtensions de Vladimir Khorikov que contiene una clase base diseñada para representar una Entidad.
En su post Entity Base Class explica los detalles que permiten conseguir el comportamiento (te recomiendo su blog). Básicamente lo que hace esta clase es sobrescribir los métodos Equals y GetHashCode.
Usando esta librería podemos convertir la clase Persona en una entidad de la siguiente manera:
public class Persona : Entity<int> // heredamos de Entity
{
public Persona(int id) : base(id) // pasa el identificador a la clase base
{
}
}
Ahora al comparar dos clases con el mismo identificador el resultado es true.
var persona = new Persona(1);
var mismaPersona = new Persona(1);
var otraPersona = new Persona(2);
if(persona == mismaPersona) // ahora sí es true :)
{
}
if(persona == otroPersona) // false
{
}
A este concepto se le llama comparación por identidad.
Dos objetos son iguales si tienen el mismo identificador, independientemente del resto de propiedades.
Hasta aquí hemos conseguido cumplir con la característica principal de las entidades, pero no la única.
4. Las propiedades de una Entidad tienen el set privado
Todas las propiedades de la Entidad deben tener el set privado o directamente no tener un set. Para modificar dichas propiedades se usan los métodos.
Necesitamos que sea así para controlar las pre-condiciones y post-condiciones que se deben cumplir antes de realizar cualquier cambio en la clase. Y de regalo centralizamos las modificaciones de las propiedades en un único lugar, evitando duplicidades de código.
Por ejemplo, siguiendo con Persona, si le añadimos las propiedades Nombre y Apellidos, inicialmente lo haríamos así:
public class Persona : Entity<int>
{
public Persona(int id) : base(id)
{
}
public string Nombre { get; set; } // así lo haríamos todos inicialmente
public string Apellidos { get; set; }
}
El problema de esta implementación es que puede generar inconsistencias, estados no permitidos de nuestro negocio. Por ejemplo, podría realizar este destrozo:
var persona = new Persona(1);
persona.Nombre = "Albert"; // inicialización "correcta"
persona.Apellidos = "Capdevila";
// ... código ... //
persona.Nombre = string.Empty; // asignar un nombre vacío
persona.Apellidos = "1"; // esté apellido no tiene sentido
En cambio si dejamos los set privados, podremos controlar mejor si se intenta establecer una propiedad con un valor incorrecto:
public class Persona : Entity<int>
{
public Persona(int id) : base(id)
{
}
public string Nombre { get; private set; } // set privado
public string Apellidos { get; private set; } // set privado
public void CambiarNombre(string nombre, string apellidos)
{
// Validaciones previas
if(string.IsNullOrEmpty(nombre)
throw new Exception("Indica el nombre de la persona");
if(string.IsNullOrEmpty(apellidos)
throw new Exception("Indica los apellidos de la persona");
Nombre = nombre;
Apellidos = apellidos;
}
}
En la fase de validaciones previas podríamos añadir todas las que se nos ocurran. Por ejemplo validaciones de longitud, o para evitar que pongan símbolos o números.
Lanzar excepciones cuando el valor no es válido está bien pero es un poco radical. Podríamos devolver un "resultado" que nos indique si se ha podido realizar la operación. Para ello podemos usar la clase Result que se encuentra en la misma librería CSharpFunctionalExtensions.
Result es un struct que contiene una resultado válido o un mensaje de error.
Modifico el método CambiarNombre para que devuelva Result en lugar de void.
public class Persona : Entity<int>
{
public Persona(int id) : base(id)
{
}
public string Nombre { get; private set; } // set privado
public string Apellidos { get; private set; } // set privado
public Result CambiarNombre(string nombre, string apellidos) // devuelve Result para evitar las Excepciones
{
if(string.IsNullOrEmpty(nombre)
return Result.Failure("Indica el nombre de la persona");
if(string.IsNullOrEmpty(apellidos)
return Result.Failure("Indica los apellidos de la persona");
Nombre = nombre;
Apellidos = apellidos;
return Result.Success();
}
}
Veamos cómo se usaría:
var persona = new Persona(1);
persona.Nombre = "Albert"; // ¡¡no compila!!
persona.Apellidos = "Capdevila"; // ¡¡no compila!!
// Hay que usar el método en su lugar //
persona.CambiarNombre("Albert","Capdevila");
persona.CambiarNombre(string.Empty,"Capdevila"); // no se conseguiría el cambio
La primera reacción al explicar que los sets de una entidad deben ser privados siempre es de sorpresa. Inicialmente el código es más largo y parece menos flexible. Pero en realidad, esas líneas de código de más, si no las metemos dentro de la entidad, acabarán existiendo fuera de ella y además las iremos duplicando por diferentes puntos. Con el paso del tiempo acabarán tan esparcidas por el código que nos será complicado entender las reglas de negocio.
5. El estado de una entidad simpre debe ser válido
Las propiedades de la Entidad siempre deben estar en un estado válido. Por ejemplo, si definimos una propiedad Descripción de tipo string con una longitud máxima de 64 caracteres, no debe ser posible asignar un valor con una longitud mayor.
Imaginemos que en el ejemplo de Persona, definimos que Nombre y Apellidos son obligatorias siempre. Pues bien, resulta que tan solo con instanciar un objeto Persona ya no cumplimos con los requisitos.
var persona = new Persona(1);
// en este punto Nombre y Apellidos son null
Por tanto, nos falta alguna cosa para evitar que pueda existir una Persona en un estado no válido.
Voy a modificar el constructor de Persona para evitar estados inválidos:
public class Persona : Entity<int>
{
public Persona(int id, string nombre, string apellidos) : base(id)
{
if(string.IsNullOrEmpty(nombre)
throw new Exception("Indica el nombre de la persona");
if(string.IsNullOrEmpty(apellidos)
return Result.Failure("Indica los apellidos de la persona");
Nombre = nombre;
Apellidos = apellidos;
}
// Propiedades y métodos
}
Con esto ya conseguimos el propósito deseado, pero otra vez estamos lanzando excepciones que podríamos evitar.
Casi en el 90% de entidades que diseño acabo usando un método factoría simple de la siguiente manera:
public class Persona : Entity<int>
{
// -> método factoría simple que protege el estado inicial de la entidad
public static Result<Persona> Crear(int id, string nombre, string apellidos)
{
var validacion = Result.Combine( // -> Result.Combine concatena los métodos de validación
ValidarNombre(nombre),
ValidarApellidos(apellidos) // -> Podemos concatenar las validaciones que queramos
);
if(validacion.IsFailure) // -> Si alguna validación falla no instanciamos la clase
return validacion.ConvertTo<Persona>(); -> Devolvemos el error de la validación
return new Persona(id, string nombre, string apellidos); // -> todo ha ido bien :)
}
// Constructor privado. Sólo accesible desde el método anterior
private Persona(int id, string nombre, string apellidos) : base(id)
{
Nombre = nombre;
Apellidos = apellidos;
}
public string Nombre { get; private set; } // set privado
public string Apellidos { get; private set; } // set privado
public Result CambiarNombre(string nombre, string apellidos) // método para modificar las propiedades
{
var validacion = Result.Combine( // -> Reutilizamos las validaciones utilizadas en el método Crear
ValidarNombre(nombre),
ValidarApellidos(apellidos));
if(validacion.IsFailure)
return validacion; // -> Si no cumplimos los requisitos devolvemos el mensaje de error
Nombre = nombre;
Apellidos = apellidos;
return Result.Success();
}
// Métodos de validación -> deben ser estáticos y públicos
public static Result ValidarNombre(string nombre)
{
if(string.IsNullOrEmpty(nombre)
return Result.Failure("Indica el nombre de la persona");
retunt Result.Success();
}
public static Result ValidarApellidos(string apellidos)
{
if(string.IsNullOrEmpty(apellidos)
return Result.Failure("Indica los apellidos de la persona");
retunt Result.Success();
}
// ... otros métodos de valiación
}
Ni siquiera en la creación del objeto se pueden establecer propiedades que no cumplan con las reglas del negocio.
6. Encapsula lógica de negocio predecible
Los métodos de las entidades encapsulan lógica de negocio.
Recordemos que las entidades representan una situación que se está dando en el mundo real. Debemos colocar dentro de sus métodos la lógica que procese y aplique las reglas de negocio.
Por otro lado, debemos evitar hacer llamadas externas o no predecibles dentro de los métodos. Los métodos deben ser idempotentes, es decir, que sean predecibles.
Está "prohibido":
- Obtener datos de una base de datos. Si los necesitas, pásalos por parámetro en el método.
- Acceder al sistema de archivos. Equivalente a una llamada a una base de datos.
- Utilizar DateTime.Now o DateTime.Today, puesto que sus resultados son impredecibles. Pasa por parámetro la fecha que necesites.
- Utilizar la clase Random para generar números aleatorios. Pasa los números necesarios por parámetro.
En general, cualquier llamada que no controlemos al 100% no está permitida. ¿Qué nos quedará dentro? Sólo lógica de negocio, aislada y sin dependencias.
¿Qué pasa si quiero validar que no se cree una entidad con alguno de sus valores duplicado? Por ejemplo, no queremos crear personas con el mismo nombre y apellidos.
Este tipo de lógica está en la frontera entre las capas de negocio y de aplicación. Aquí no puedo extenderme más, pero te dejo este post de Vladimir Khorikov titulado DDD trilemma por si quieres ahondar más en el tema.
Respondiendo la pregunta a nivel práctico:
- Dentro del ámbito de una sola entidad no puedes tener acceso al resto de entidades de la aplicación. Por tanto, la validación de duplicidades a nivel global la coloco en la capa de aplicación aunque pertenezca a la capa de negocio. Es una concesión que hago en favor del rendimiento y es excepcional.
7. Conclusiones
Estas son las características que debe cumplir una entidad:
- Una entidad debe compararse por referencia y por identidad.
- Sus propiedades no tienen set o el set es privado.
- El estado de la Entidad (sus propiedades) se modifican a través de sus métodos.
- El estado siempre debe ser válido. No se permiten propiedades con valores incorrectos durante su ciclo de vida.
- Los métodos de las entidades encapsulan lógica de negocio y son predecibles.