En Habré y no solo se ha escrito una cantidad decente de artículos sobre diseño dirigido por dominio, tanto en general sobre arquitectura como con ejemplos en .Net. Pero al mismo tiempo, una parte tan importante de esta arquitectura como Value Objects a menudo se menciona poco.
En este artículo, intentaré descubrir los matices de la implementación de Value Objects en .Net Core usando Entity Framework Core.
Debajo de un gato hay mucho código.
Poco de teoría
El núcleo de la arquitectura del diseño impulsado por dominio es el
dominio , el área temática a la que se aplica el software que se está desarrollando. Aquí está toda la lógica de negocios de la aplicación, que generalmente interactúa con varios datos. Los datos pueden ser de dos tipos:
- Objeto de la entidad
- Objeto de valor (en adelante - VO)
El objeto de entidad define una entidad en la lógica de negocios y siempre tiene un identificador por el cual la entidad se puede encontrar o comparar con otra entidad. Si dos Entidades tienen un identificador idéntico, esta es la misma Entidad. Casi siempre cambian.
Value Object es un tipo inmutable, cuyo valor se establece durante la creación y no cambia a lo largo de la vida del objeto. No tiene un identificador. Si dos VO son estructuralmente idénticos, son equivalentes.
La entidad puede contener otra entidad y VO. Las VO pueden incluir otras VO, pero no la Entidad.
Por lo tanto, la lógica de dominio debería funcionar exclusivamente con Entity y VO, esto garantiza su coherencia. Tipos de datos básicos como string, int, etc. a menudo no pueden actuar como VO, porque simplemente pueden violar el estado del dominio, lo cual es casi un desastre en el marco de DDD.
Un ejemplo En los diversos manuales, la clase Persona, que se ha cansado de todos, a menudo se muestra así:
public class Person { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } }
Simple y claro: identificador, nombre y edad, ¿dónde puede cometer un error?
Pero aquí puede haber varios errores: por ejemplo, desde el punto de vista de la lógica empresarial, un nombre es obligatorio, no puede tener una longitud cero o más de 100 caracteres y no debe contener caracteres especiales, signos de puntuación, etc. Y la edad no puede ser inferior a 10 o superior a 120 años.
Desde el punto de vista del lenguaje de programación, 5 es un entero completamente normal, similarmente una cadena vacía. Pero el dominio ya está en un estado incorrecto.
Pasemos a practicar
En este punto, sabemos que VO debe ser inmutable y contener un valor que sea válido para la lógica empresarial.
La inmunidad se logra inicializando la propiedad de solo lectura al crear el objeto.
La validación del valor se produce en el constructor (cláusula Guard). Es deseable hacer pública la verificación en sí misma, de modo que otras capas puedan validar los datos recibidos del cliente (el mismo navegador).
Creemos un VO para Nombre y Edad. Además, complicamos un poco la tarea: agregue un PersonalName combinando FirstName y LastName, y aplíquelo a Person.
Nombre public class Name { private static readonly Regex ValidationRegex = new Regex( @"^[\p{L}\p{M}\p{N}]{1,100}\z", RegexOptions.Singleline | RegexOptions.Compiled); public Name(String value) { if (!IsValid(value)) { throw new ArgumentException("Name is not valid"); } Value = value; } public String Value { get; } public static Boolean IsValid(String value) { return !String.IsNullOrWhiteSpace(value) && ValidationRegex.IsMatch(value); } public override Boolean Equals(Object obj) { return obj is Name other && StringComparer.Ordinal.Equals(Value, other.Value); } public override Int32 GetHashCode() { return StringComparer.Ordinal.GetHashCode(Value); } }
Nombre personal public class PersonalName { protected PersonalName() { } public PersonalName(Name firstName, Name lastName) { if (firstName == null) { throw new ArgumentNullException(nameof(firstName)); } if (lastName == null) { throw new ArgumentNullException(nameof(lastName)); } FirstName = firstName; LastName = lastName; } public Name FirstName { get; } public Name LastName { get; } public String FullName => $"{FirstName} {LastName}"; public override Boolean Equals(Object obj) { return obj is PersonalName personalName && EqualityComparer<Name>.Default.Equals(FirstName, personalName.FirstName) && EqualityComparer<Name>.Default.Equals(LastName, personalName.LastName); } public override Int32 GetHashCode() { return HashCode.Combine(FirstName, LastName); } public override String ToString() { return FullName; } }
Edad public class Age { public Age(Int32 value) { if (!IsValid(value)) { throw new ArgumentException("Age is not valid"); } Value = value; } public Int32 Value { get; } public static Boolean IsValid(Int32 value) { return 10 <= value && value <= 120; } public override Boolean Equals(Object obj) { return obj is Age other && Value == other.Value; } public override Int32 GetHashCode() { return Value.GetHashCode(); } }
Y finalmente Persona:
public class Person { public Person(PersonalName personalName, Age age) { if (personalName == null) { throw new ArgumentNullException(nameof(personalName)); } if (age == null) { throw new ArgumentNullException(nameof(age)); } Id = Guid.NewGuid(); PersonalName= personalName; Age = age; } public Guid Id { get; private set; } public PersonalName PersonalName{ get; set; } public Age Age { get; set; } }
Por lo tanto, no podemos crear Persona sin un nombre completo o edad. Además, no podemos crear un nombre "incorrecto" o una edad "incorrecta". Un buen programador seguramente verificará los datos recibidos en el controlador utilizando los métodos Name.IsValid ("John") y Age.IsValid (35) y, en caso de datos incorrectos, informará al cliente sobre esto.
Si establecemos una regla en todas partes del modelo para usar solo Entity y VO, nos protegeremos de una gran cantidad de errores: los datos incorrectos simplemente no entrarán en el modelo.
Persistencia
Ahora necesitamos guardar nuestros datos en el almacén de datos y obtenerlos a pedido. Utilizaremos Entity Framework Core como ORM, y el almacén de datos es MS SQL Server.
DDD define claramente: la persistencia es una subespecie de la capa de infraestructura porque oculta una implementación específica de acceso a datos.
El dominio no necesita saber nada sobre Persistencia, esto determina solo las interfaces de los repositorios.
Y Persistence contiene implementaciones específicas, configuraciones de mapeo, así como un objeto UnitOfWork.
Hay dos opiniones sobre si vale la pena crear repositorios y unidades de trabajo.
Por un lado, no, no es necesario, porque en Entity Framework Core todo esto ya está implementado. Si tenemos una arquitectura multinivel de la forma DAL -> Business Logic -> Presentation, que se basa en el almacenamiento de datos, entonces ¿por qué no usar las capacidades de EF Core directamente?
Pero el dominio en DDD no depende del almacenamiento de datos y el ORM utilizado: estas son todas las sutilezas de implementación que están encapsuladas en Persistence y que no son de interés para nadie más. Si proporcionamos DbContext a otras capas, divulgamos inmediatamente los detalles de implementación, nos unimos estrechamente al ORM seleccionado y obtenemos DAL, como la base de toda la lógica empresarial, pero esto no debería ser así. En términos generales, el dominio no debería notar un cambio en ORM e incluso la pérdida de Persistencia como una capa.
Entonces, la interfaz del repositorio de personas, en el dominio:
public interface IPersons { Task Add(Person person); Task<IReadOnlyList<Person>> GetList(); }
y su implementación en Persistence:
public class EfPersons : IPersons { private readonly PersonsDemoContext _context; public EfPersons(UnitOfWork unitOfWork) { if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _context = unitOfWork.Context; } public async Task Add(Person person) { if (person == null) { throw new ArgumentNullException(nameof(person)); } await _context.Persons.AddAsync(person); } public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons.ToListAsync(); } }
No parece nada complicado, pero hay un problema. Fuera de la caja, Entity Framework Core solo funciona con tipos básicos (string, int, DateTime, etc.) y no sabe nada sobre PersonalName y Age. Enseñemos a EF Core a comprender nuestros objetos de valor.
Configuracion
La API Fluent es más adecuada para configurar Entity en DDD. Los atributos no son adecuados, ya que el dominio no necesita saber nada sobre los matices de la asignación.
Cree una clase en Persistence con la configuración básica PersonConfiguration:
internal class PersonConfiguration : IEntityTypeConfiguration<Person> { public void Configure(EntityTypeBuilder<Person> builder) { builder.ToTable("Persons"); builder.HasKey(p => p.Id); builder.Property(p => p.Id).ValueGeneratedNever(); } }
y conéctelo al DbContext:
protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new PersonConfiguration()); }
Mapeo
La sección para la cual se escribió este material.
Por el momento, hay dos formas más o menos convenientes de asignar clases no estándar a los tipos base: conversiones de valor y tipos propios.
Conversiones de valor
Esta característica apareció en Entity Framework Core 2.1 y le permite determinar la conversión entre los dos tipos de datos.
Escribamos el convertidor para Age (en esta sección todo el código está en PersonConfiguration):
var ageConverter = new ValueConverter<Age, Int32>( v => v.Value, v => new Age(v)); builder .Property(p => p.Age) .HasConversion(ageConverter) .HasColumnName("Age") .HasColumnType("int") .IsRequired();
Sintaxis simple y concisa, pero no sin defectos:
- No se puede convertir nulo;
- No es posible convertir una sola propiedad en varias columnas en una tabla y viceversa;
- EF Core no puede convertir una expresión LINQ con esta propiedad en una consulta SQL.
Me detendré en el último punto con más detalle. Agregue un método al repositorio que devuelva una lista de personas mayores de una edad determinada:
public async Task<IReadOnlyList<Person>> GetOlderThan(Age age) { if (age == null) { throw new ArgumentNullException(nameof(age)); } return await _context.Persons .Where(p => p.Age.Value > age.Value) .ToListAsync(); }
Hay una condición para la edad, pero EF Core no podrá convertirla en una consulta SQL y, llegando a Where (), cargará toda la tabla en la memoria de la aplicación y, solo entonces, usando LINQ, cumplirá la condición p.Age.Value> age.Value .
En general, Value Conversions es una opción de mapeo simple y rápida, pero debe recordar esta característica de EF Core, de lo contrario, en algún momento, al consultar tablas grandes, la memoria puede agotarse.
Tipos de propiedad
Los tipos propios aparecieron en Entity Framework Core 2.0 y reemplazaron los tipos complejos del Entity Framework normal.
Hagamos la edad como tipo de propiedad:
builder.OwnsOne(p => p.Age, a => { a.Property(u => u.Value).HasColumnName("Age"); a.Property(u => u.Value).HasColumnType("int"); a.Property(u => u.Value).IsRequired(); });
No esta mal. Y los tipos propios no tienen algunas de las desventajas de las conversiones de valor, a saber, los puntos 2 y 3.
2.
Es posible convertir una propiedad en varias columnas en la tabla y viceversa
Lo que necesita para PersonalName, aunque la sintaxis ya está un poco sobrecargada:
builder.OwnsOne(b => b.PersonalName, pn => { pn.OwnsOne(p => p.FirstName, fn => { fn.Property(x => x.Value).HasColumnName("FirstName"); fn.Property(x => x.Value).HasColumnType("nvarchar(100)"); fn.Property(x => x.Value).IsRequired(); }); pn.OwnsOne(p => p.LastName, ln => { ln.Property(x => x.Value).HasColumnName("LastName"); ln.Property(x => x.Value).HasColumnType("nvarchar(100)"); ln.Property(x => x.Value).IsRequired(); }); });
3. EF Core
puede convertir una expresión LINQ con esta propiedad en una consulta SQL.
Agregue la clasificación por apellido y nombre al cargar la lista:
public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons .OrderBy(p => p.PersonalName.LastName.Value) .ThenBy(p => p.PersonalName.FirstName.Value) .ToListAsync(); }
Dicha expresión se convertirá correctamente en una consulta SQL y la clasificación se realiza en el lado del servidor SQL, y no en la aplicación.
Por supuesto, también hay desventajas.
- Los problemas con nulo no han desaparecido;
- Los campos de tipos propios no pueden ser de solo lectura y deben tener un setter privado o protegido.
- Los tipos propios se implementan como entidad regular, lo que significa:
- Tienen un identificador (como una propiedad de sombra, es decir, no aparece en la clase de dominio);
- EF Core rastrea todos los cambios en los tipos de propiedad, exactamente lo mismo que para la entidad normal.
Por un lado, esto no es en absoluto lo que deberían ser los objetos de valor. No deben tener ningún identificador. No se debe realizar un seguimiento de los VO para detectar cambios, ya que inicialmente son inmutables, se deben realizar un seguimiento de las propiedades de la Entidad principal, pero no las propiedades de VO.
Por otro lado, estos son detalles de implementación que pueden omitirse, pero nuevamente, no lo olvide. El seguimiento de los cambios afecta el rendimiento. Si esto no se nota con las selecciones de Entidad única (por ejemplo, por Id) o listas pequeñas, entonces con una muestra de listas grandes de Entidad "pesada" (muchas propiedades VO), la reducción del rendimiento será muy notable precisamente por el seguimiento.
Presentación
Descubrimos cómo implementar objetos de valor en un dominio y repositorio. Es hora de usarlo todo. Creemos dos páginas simples, con la lista Persona y el formulario para agregar Persona.
El código del controlador sin métodos de acción se ve así:
public class HomeController : Controller { private readonly IPersons _persons; private readonly UnitOfWork _unitOfWork; public HomeController(IPersons persons, UnitOfWork unitOfWork) { if (persons == null) { throw new ArgumentNullException(nameof(persons)); } if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _persons = persons; _unitOfWork = unitOfWork; }
Agregar acción para obtener la lista de personas:
[HttpGet] public async Task<IActionResult> Index() { var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View(result); }
Vista @model PersonsListModel @{ ViewData["Title"] = "Persons List"; } <div class="text-center"> <h2 class="display-4">Persons</h2> </div> <table class="table"> <thead> <tr> <td><b>Last name</b></td> <td><b>First name</b></td> <td><b>Age</b></td> </tr> </thead> @foreach (var p in Model.Persons) { <tr> <td>@p.LastName</td> <td>@p.FirstName</td> <td>@p.Age</td> </tr> } </table>
Nada complicado: cargamos la lista, creamos un Objeto de transferencia de datos (PersonModel) para cada
Persona y enviado a la Vista correspondiente.
Mucho más interesante es la adición de Persona:
[HttpPost] public async Task<IActionResult> AddPerson(PersonModel model) { if (model == null) { return BadRequest(); } if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); } if (!Name.IsValid(model.LastName)) { ModelState.AddModelError(nameof(model.LastName), "LastName is invalid"); } if (!Age.IsValid(model.Age)) { ModelState.AddModelError(nameof(model.Age), "Age is invalid"); } if (!ModelState.IsValid) { return View(); } var firstName = new Name(model.FirstName); var lastName = new Name(model.LastName); var person = new Person( new PersonalName(firstName, lastName), new Age(model.Age)); await _persons.Add(person); await _unitOfWork.Commit(); var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View("Index", result); }
Vista @model PersonDemo.Models.PersonModel @{ ViewData["Title"] = "Add Person"; } <h2 class="display-4">Add Person</h2> <div class="row"> <div class="col-md-4"> <form asp-action="AddPerson"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <div class="form-group"> <label asp-for="FirstName" class="control-label"></label> <input asp-for="FirstName" class="form-control" /> <span asp-validation-for="FirstName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="LastName" class="control-label"></label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Age" class="control-label"></label> <input asp-for="Age" class="form-control" /> <span asp-validation-for="Age" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Create" class="btn btn-primary" /> </div> </form> </div> </div> @section Scripts { @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} }
Hay una validación obligatoria de los datos entrantes:
if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); }
Si esto no se hace, cuando se crea un VO con un valor incorrecto, se lanzará una excepción ArgumentException (recuerde acerca de la cláusula de protección en los constructores de VO). Con la verificación, es mucho más fácil enviar un mensaje al usuario de que uno de los valores es incorrecto.
Aquí debe hacer una pequeña digresión: en Asp Net Core hay una forma regular de validación de datos, utilizando atributos. Pero en DDD, este método de validación no es correcto por varias razones:
- Las capacidades de los atributos pueden no ser suficientes para la lógica de validación;
- Cualquier lógica de negocios, incluidas las reglas para validar parámetros, se establece exclusivamente por el dominio. Él tiene el monopolio de esto y todas las otras capas deben tener en cuenta esto. Se pueden usar los atributos, pero no debe confiar en ellos. Si el atributo omite datos incorrectos, nuevamente obtendremos una excepción al crear un VO.
Volver a AddPerson (). Después de la validación de datos, se crean PersonalName, Age y luego Person. A continuación, agregue el objeto al repositorio y guarde los cambios (Confirmar). Es muy importante que Commit no se invoque en el repositorio de EfPersons. La tarea del repositorio es realizar alguna acción con los datos, no más. El compromiso se realiza solo desde el exterior, cuando exactamente - decide el programador. De lo contrario, es posible una situación cuando se produce un error en medio de una determinada iteración comercial: algunos de los datos se guardan y otros no. Recibimos el dominio en el estado "roto". Si Commit se realiza al final, entonces, si ocurre el error, la transacción simplemente se revertirá.
Conclusión
Di ejemplos de la implementación de Value Objects en general y los matices de mapeo en Entity Framework Core. Espero que el material sea útil para comprender cómo aplicar los elementos del diseño impulsado por dominio en la práctica.
Código fuente completo del proyecto personsDemo -
GitHubEl material no revela el problema de interactuar con objetos de valor opcionales (anulables), si PersonalName o Age no fueran propiedades requeridas de Person. Quería describir esto en este artículo, pero ya salió algo sobrecargado. Si hay interés en este tema, escriba en los comentarios, la continuación será.
Para los fanáticos de las "bellas arquitecturas" en general y del diseño impulsado por el dominio en particular, recomiendo encarecidamente el recurso
Enterprise Craftsmanship .
Hay muchos artículos útiles sobre la construcción correcta de arquitectura y ejemplos de implementación en .Net. Algunas ideas fueron prestadas allí, implementadas con éxito en proyectos de "combate" y parcialmente reflejadas en este artículo.
También se utilizó la documentación oficial para
Tipos de propiedad y
Conversiones de valor .