Entidades de estilo DDD con Entity Framework Core

Este artículo trata sobre cómo aplicar los principios del diseño controlado por dominio (DDD) a las clases que Entity Framework Core (EF Core) asigna a la base de datos, y por qué esto podría ser útil.

TLDR


El enfoque DDD tiene muchas ventajas, pero lo principal es que DDD transfiere el código de operaciones de creación / modificación dentro de la clase de entidad. Esto reduce significativamente las posibilidades de que un desarrollador malinterprete / interprete las reglas para crear, inicializar y usar instancias de clase.

  1. El libro de Eric Evans y sus discursos no tienen mucha información sobre este tema:
  2. Proporcione al cliente un modelo simple para obtener objetos persistentes (clases) y administrar su ciclo de vida.
  3. Las clases de entidad deben indicar explícitamente si se pueden cambiar, cómo y por qué reglas.
  4. En DDD existe el concepto de agregado. El agregado es un árbol de entidades relacionadas. De acuerdo con las reglas DDD, el trabajo con agregados debe realizarse a través de la "raíz de agregación" (la esencia raíz del árbol).

Eric menciona repositorios en sus discursos. No recomiendo implementar un repositorio con EF Core, porque EF ya implementa los patrones de repositorio y unidad de trabajo per se. Te contaré más sobre esto en un artículo separado, " ¿Vale la pena usar un repositorio con EF Core ?"

Entidades de estilo DDD


Comenzaré mostrando el código de entidad en estilo DDD y luego comparándolo con la forma en que generalmente se crean las entidades con EF Core (nota del traductor. El autor llama a la palabra "generalmente" un modelo anémico). Para este ejemplo, usaré la base de datos de Internet librería (una versión muy simplificada de Amazon ”. La estructura de la base de datos se muestra en la imagen a continuación.

imagen

Las primeras cuatro tablas representan todo sobre los libros: los libros mismos, sus autores, reseñas. Las dos tablas siguientes se utilizan en el código de lógica de negocios. Este tema se describe en detalle en un artículo separado.
Todo el código de este artículo se ha subido al repositorio GenericBizRunner en GitHub . Además del código de la biblioteca GenericBizRunner, hay otro ejemplo de una aplicación ASP.NET Core que usa GenericBizRunner para trabajar con la lógica empresarial. Más sobre esto está escrito en el artículo " Biblioteca para trabajar con lógica de negocios y Entity Framework Core ".
Y aquí está el código de entidad correspondiente a la estructura de la base de datos.

public class Book { public const int PromotionalTextLength = 200; public int BookId { get; private set; } //… all other properties have a private set //These are the DDD aggregate propties: Reviews and AuthorLinks public IEnumerable<Review> Reviews => _reviews?.ToList(); public IEnumerable<BookAuthor> AuthorsLink => _authorsLink?.ToList(); //private, parameterless constructor used by EF Core private Book() { } //public constructor available to developer to create a new book public Book(string title, string description, DateTime publishedOn, string publisher, decimal price, string imageUrl, ICollection<Author> authors) { //code left out } //now the methods to update the book's properties public void UpdatePublishedOn(DateTime newDate)… public IGenericErrorHandler AddPromotion(decimal newPrice, string promotionalText)… public void RemovePromotion()… //now the methods to update the book's aggregates public void AddReview(int numStars, string comment, string voterName, DbContext context)… public void RemoveReview(Review review)… } 

Qué buscar:

  1. Línea 5: establece el acceso a todas las propiedades de la entidad declaradas privadas. Esto significa que los datos se pueden modificar utilizando el constructor o los métodos públicos descritos más adelante en este artículo.
  2. Líneas 9 y 10. Las colecciones relacionadas (los mismos agregados de DDD) proporcionan acceso público a IEnumerable <T>, no ICollection <T>. Esto significa que no puede agregar o eliminar elementos de la colección directamente. Deberá utilizar métodos especializados de la clase Libro.
  3. Línea 13. EF Core requiere un constructor sin parámetros, pero puede tener acceso privado. Esto significa que otro código de aplicación no podrá evitar la inicialización y crear instancias de clases utilizando un constructor sin parámetros (comentario de un traductor. A menos que, por supuesto, cree entidades utilizando solo reflexión)
  4. Líneas 16-20: la única forma de crear una instancia de la clase Book es usar el constructor público. Este constructor contiene toda la información necesaria para inicializar el objeto. Por lo tanto, se garantiza que el objeto esté en un estado válido.
  5. Líneas 23-25: Estas líneas contienen métodos para cambiar el estado de un libro.
  6. Líneas 28-29: estos métodos le permiten cambiar entidades relacionadas (agregados)

Los métodos en las líneas 23-39, seguiré llamando a los "métodos que proporcionan acceso". Estos métodos son la única forma de cambiar las propiedades y las relaciones dentro de una entidad. La conclusión es que la clase Libro está "cerrada". Se crea a través de un constructor especial y solo puede modificarse parcialmente a través de métodos especiales con nombres adecuados. Este enfoque crea un fuerte contraste con el enfoque estándar para crear / modificar entidades en EF Core, en el que todas las entidades contienen un constructor predeterminado vacío y todas las propiedades se declaran públicas. La siguiente pregunta es, ¿por qué es mejor el primer enfoque?

Comparación de creación de entidad


Comparemos el código para obtener datos de varios libros de json y crear instancias de las clases de libros sobre la base.

a. Enfoque estándar


 var price = (decimal) (bookInfoJson.saleInfoListPriceAmount ?? DefaultBookPrice) var book = new Book { Title = bookInfoJson.title, Description = bookInfoJson.description, PublishedOn = DecodePubishDate(bookInfoJson.publishedDate), Publisher = bookInfoJson.publisher, OrgPrice = price, ActualPrice = price, ImageUrl = bookInfoJson.imageLinksThumbnail }; byte i = 0; book.AuthorsLink = new List<BookAuthor>(); foreach (var author in bookInfoJson.authors) { book.AuthorsLink.Add(new BookAuthor { Book = book, Author = authorDict[author], Order = i++ }); } 

b. Estilo DDD


 var authors = bookInfoJson.authors.Select(x => authorDict[x]).ToList(); var book = new Book(bookInfoJson.title, bookInfoJson.description, DecodePubishDate(bookInfoJson.publishedDate), bookInfoJson.publisher, ((decimal?)bookInfoJson.saleInfoListPriceAmount) ?? DefaultBookPrice, bookInfoJson.imageLinksThumbnail, authors); 

Código de constructor de clase de libro

 public Book(string title, string description, DateTime publishedOn, string publisher, decimal price, string imageUrl, ICollection<Author> authors) { if (string.IsNullOrWhiteSpace(title)) throw new ArgumentNullException(nameof(title)); Title = title; Description = description; PublishedOn = publishedOn; Publisher = publisher; ActualPrice = price; OrgPrice = price; ImageUrl = imageUrl; _reviews = new HashSet<Review>(); if (authors == null || !authors.Any()) throw new ArgumentException( "You must have at least one Author for a book", nameof(authors)); byte order = 0; _authorsLink = new HashSet<BookAuthor>( authors.Select(a => new BookAuthor(this, a, order++))); } 

Qué buscar:

  1. Líneas 1-2: el constructor lo obliga a pasar todos los datos necesarios para una inicialización adecuada.
  2. Líneas 5, 6 y 17-9: el código contiene varias comprobaciones de reglas comerciales. En este caso particular, una violación de las reglas se considera un error en el código, por lo tanto, en caso de violación, se lanzará una excepción. Si el usuario pudiera corregir estos errores, quizás usaría una fábrica estática que devuelve Estado <T> (traductor de comentarios. Usaría la Opción <T> o Resultado <T>, como un nombre más común). El estado es un tipo que devuelve una lista de errores.
  3. Líneas 21-23: el enlace BookAuthor se crea en el constructor. El constructor BookAuthor se puede declarar con el nivel de acceso interno. De esta forma podemos evitar la creación de relaciones fuera del DAL.

Como habrás notado, la cantidad de código para crear una entidad es aproximadamente la misma en ambos casos. Entonces, ¿por qué es mejor el estilo DDD? El estilo DDD es mejor en eso:

  1. Controles de acceso. Se excluye el cambio accidental de propiedad. Cualquier cambio se produce a través del constructor o método público con el nombre correspondiente. Obviamente lo que está pasando.
  2. Corresponde a DRY (no te repitas). Es posible que deba crear instancias de libros en varios lugares. El código de asignación está en el constructor y no tiene que repetirlo en varios lugares.
  3. Oculta la complejidad. La clase Book tiene dos propiedades: ActualPrice y OrgPrice. Ambos valores deben ser iguales al crear un nuevo libro. En un enfoque estándar, cada desarrollador debe ser consciente de esto. En el enfoque DDD, es suficiente que el desarrollador de la clase Book lo sepa. El resto aprenderá sobre esta regla porque está explícitamente escrita en el constructor.
  4. Oculta la creación agregada. En un enfoque estándar, el desarrollador debe crear manualmente una instancia de BookAuthor. En el estilo DDD, esta complejidad se encapsula para el código de llamada.
  5. Permite que las propiedades tengan acceso de escritura privado
  6. Una de las razones para usar DDD es bloquear la entidad, es decir No le dé la posibilidad de cambiar las propiedades directamente. Comparemos la operación de cambio con y sin DDD.

Comparación de cambio de propiedad


Eric Evans, una de las principales ventajas de las entidades de estilo DDD, dice lo siguiente: "Comunican las decisiones de diseño sobre el acceso a objetos".
Nota traductor La frase original es difícil de traducir al ruso. En este caso, las decisiones de diseño son decisiones tomadas sobre cómo debería funcionar el software. Esto significa que las decisiones fueron discutidas y confirmadas. El código con constructores que inicializan correctamente entidades y métodos con nombres correctos que reflejan el significado de las operaciones le dice explícitamente al desarrollador que las asignaciones de ciertos valores se hicieron con intención, y no por error, y no son un capricho de otro desarrollador o detalles de implementación.
Entiendo esta frase de la siguiente manera.

  1. Haga obvio cómo modificar los datos dentro de una entidad y qué datos deberían cambiar juntos.
  2. Haga obvio cuándo no debe modificar ciertos datos en la entidad.
Comparemos los dos enfoques. El primer ejemplo es simple, y el segundo es más complicado.

1. Cambio de fecha de publicación


Supongamos que queremos trabajar primero con un borrador de un libro y solo luego publicarlo. Al momento de escribir el borrador, se establece una fecha de publicación estimada, que es muy probable que cambie durante el proceso de edición. Para almacenar la fecha de publicación, utilizaremos la propiedad Publicada.

a. Entidad con propiedades públicas


 var book = context.Find<Book>(dto.BookId); book.PublishedOn = dto.PublishedOn; context.SaveChanges(); 

b. Entidad de estilo DDD


En el estilo DDD, el establecedor de la propiedad se declara privado, por lo que utilizaremos un método de acceso especializado.

 var book = context.Find<Book>(dto.BookId); book.UpdatePublishedOn( dto.PublishedOn); context.SaveChanges(); 

Estos dos casos son casi iguales. La versión DDD es incluso un poco más larga. Pero todavía hay una diferencia. En el estilo DDD, sabe con certeza que la fecha de publicación se puede cambiar porque hay un método con un nombre obvio. También sabe que no puede cambiar el editor porque la propiedad del editor no tiene un método apropiado para cambiar. Esta información será útil para cualquier programador que trabaje con una clase de libros.

2. Administrar el descuento para el libro.


Otro requisito es que debemos poder gestionar los descuentos. El descuento consiste en un nuevo precio y un comentario, por ejemplo, "¡50% antes del final de esta semana!"

La implementación de esta regla es simple, pero no demasiado obvia.

  1. La propiedad OrgPrice es el precio sin descuento.
  2. Precio real: el precio actual al que se vende el libro. Si el descuento es válido, el precio actual diferirá de OrgPrice por el tamaño del descuento. Si no, entonces el valor de las propiedades será igual.
  3. La propiedad PromotionText debe contener el texto de descuento si se aplica el descuento o nulo si el descuento no se aplica actualmente.

Las reglas son bastante obvias para la persona que las implementó. Sin embargo, para otro desarrollador, por ejemplo, desarrollar una interfaz de usuario para agregar un descuento. Agregar los métodos AddPromotion y RemovePromotion a la clase de entidad oculta los detalles de implementación. Ahora otro desarrollador tiene métodos públicos con los nombres correspondientes. La semántica del uso de métodos es obvia.

Eche un vistazo a la implementación de los métodos AddPromotion y RemovePromotion.

 public IGenericErrorHandler AddPromotion(decimal newPrice, string promotionalText) { var status = new GenericErrorHandler(); if (string.IsNullOrWhiteSpace(promotionalText)) { status.AddError( "You must provide some text to go with the promotion.", nameof(PromotionalText)); return status; } ActualPrice = newPrice; PromotionalText = promotionalText; return status; } 

Qué buscar:

  1. Líneas 4-10: se requiere agregar un comentario de texto promocional. El método verifica que el texto no esté vacío. Porque El usuario puede corregir este error. El método devuelve una lista de errores para su corrección.
  2. Líneas 12, 13: el método establece los valores de las propiedades de acuerdo con la implementación que el desarrollador ha elegido. El usuario del método AddPromotion no tiene que conocerlos. Para agregar un descuento, solo escriba:

 var book = context.Find<Book>(dto.BookId); var status = book.AddPromotion(newPrice, promotionText); if (!status.HasErrors) context.SaveChanges(); return status; 

El método RemovePromotion es mucho más simple: no implica el manejo de errores. Por lo tanto, el valor de retorno es nulo.

 public void RemovePromotion() { ActualPrice = OrgPrice; PromotionalText = null; } 

Estos dos ejemplos son muy diferentes entre sí. En el primer ejemplo, cambiar la propiedad PublishOn es tan simple que la implementación estándar está bien. En el segundo ejemplo, los detalles de implementación no son obvios para alguien que no ha trabajado con la clase Libro. En el segundo caso, el estilo DDD con métodos de acceso especializados oculta los detalles de implementación y facilita la vida de otros desarrolladores. Además, en el segundo ejemplo, el código contiene lógica empresarial. Si bien la cantidad de lógica es pequeña, podemos almacenarla directamente en los métodos de acceso y devolver una lista de errores si el método no se usa correctamente.

3. Trabajar con el agregado - Colección de propiedades Comentarios


DDD ofrece trabajar con la unidad solo a través de la raíz. En nuestro caso, la propiedad Comentarios crea problemas. Incluso si setter se declara privado, el desarrollador puede agregar o eliminar objetos utilizando los métodos add y remove, o incluso llamar al método clear para borrar toda la colección. Aquí, la nueva función EF Core, campos de respaldo, nos ayudará.

El campo de respaldo permite al desarrollador encapsular la colección real y proporcionar acceso público al enlace de interfaz IEnumerable <T>. La interfaz IEnumerable <T> no proporciona métodos para agregar, quitar o borrar. En el siguiente código se muestra un ejemplo del uso de campos de respaldo.

 public class Book { private HashSet<Review> _reviews; public IEnumerable<Review> Reviews => _reviews?.ToList(); //… rest of code not shown } 

Para que esto funcione, debe decirle a EF Core que al leer desde la base de datos, debe escribir en un campo privado, no en una propiedad pública. El código de configuración se muestra a continuación.

 protected override void OnModelCreating (ModelBuilder modelBuilder) { modelBuilder.Entity<Book>() .FindNavigation(nameof(Book.Reviews)) .SetPropertyAccessMode(PropertyAccessMode.Field); //… other non-review configurations left out } 

Para trabajar con revisiones, agregué dos métodos: AddReview y RemoveReview a la clase de libro. El método AddReview es más interesante. Aquí está su código:

 public void AddReview(int numStars, string comment, string voterName, DbContext context = null) { if (_reviews != null) { _reviews.Add(new Review(numStars, comment, voterName)); } else if (context == null) { throw new ArgumentNullException(nameof(context), "You must provide a context if the Reviews collection isn't valid."); } else if (context.Entry(this).IsKeySet) { context.Add(new Review(numStars, comment, voterName, BookId)); } else { throw new InvalidOperationException("Could not add a new review."); } } 

Qué buscar:

  1. Líneas 4-7: intencionalmente no inicializo el campo _reviews en un constructor privado sin parámetros que EF Core usa al cargar entidades desde la base de datos. Esto permite que mi código determine si la colección se cargó utilizando el método .Include (p => p.Reviews). En el constructor público, inicializo el campo, por lo que NRE no sucederá cuando trabaje con la entidad creada.
  2. Líneas 8-12: si la colección de Revisiones no se cargó, el código debe usar DbContext para inicializar.
  3. Líneas 13-16: si el libro se creó con éxito y contiene una ID, entonces uso otra técnica para agregar una revisión: simplemente instalo una clave externa en una instancia de la clase Revisar y la escribo en la base de datos. Esto se describe con más detalle en la sección 3.4.5 de mi libro.
  4. Línea 19: Si estamos aquí, entonces hay algún tipo de problema con la lógica del código. Entonces lanzo una excepción.

He diseñado todos mis métodos de acceso para casos invertidos donde solo se carga la entidad raíz. La forma de actualizar la unidad queda a criterio de los métodos. Es posible que deba cargar entidades adicionales.

Conclusión


Para crear entidades en estilo DDD con EF Core, debe cumplir con las siguientes reglas:

  1. Cree constructores públicos para crear instancias de clase inicializadas correctamente. Si se pueden producir errores durante el proceso de creación que el usuario puede corregir, cree el objeto no utilizando el constructor público, sino utilizando el método de fábrica que devuelve Estado <T>, donde T es el tipo de entidad que se está creando
  2. Todas las propiedades son establecedores de propiedades. Es decir Todas las propiedades son de solo lectura fuera de la clase.
  3. Para las propiedades de navegación de la colección, declare los campos de respaldo y el tipo de propiedad pública declare IEnumerable <T>. Esto evitará que otros desarrolladores cambien las colecciones de forma incontrolable.
  4. En lugar de establecedores públicos, cree métodos públicos para todas las operaciones de cambio de objeto permitidas. Estos métodos deben devolver nulo si la operación no puede fallar con un error que el usuario puede corregir, o Estado <T> si pueden.
  5. El alcance de la responsabilidad de la entidad es importante. Creo que es mejor limitar las entidades para cambiar la clase en sí y otras clases dentro del agregado, pero no fuera. Las reglas de validación deben limitarse a verificar la corrección de la creación y el cambio de estado de las entidades. Es decir No verifico las reglas comerciales como los saldos de acciones. Hay un código especial de lógica de negocios para esto.
  6. Los métodos de cambio de estado deben suponer que solo se carga la raíz de agregación. Si un método necesita cargar otros datos, debe cuidarlo solo.
  7. Los métodos de cambio de estado deben suponer que solo se carga la raíz de agregación. Si un método necesita cargar otros datos, debe cuidarlo solo. Este enfoque simplifica el uso de entidades por parte de otros desarrolladores.

Pros y contras de las entidades DDD cuando se trabaja con EF Core


Me gusta el enfoque crítico de cualquier patrón o arquitectura. Esto es lo que pienso sobre el uso de entidades DDD.

Pros


  1. Usar métodos especializados para cambiar el estado es un enfoque más limpio. Esta es definitivamente una buena solución, simplemente porque los métodos nombrados correctamente revelan las intenciones del código mucho mejor y hacen obvio lo que se puede y no se puede cambiar. Además, los métodos pueden devolver una lista de errores si el usuario puede corregirlos.
  2. Cambiar agregados solo a través de la raíz también funciona bien
  3. Los detalles de la relación uno a muchos entre las clases Libro y Revisión ahora están ocultos para el usuario. La encapsulación es un principio básico de la POO.
  4. El uso de constructores especializados le permite asegurarse de que las entidades se crean y se garantiza que se inicializarán correctamente.
  5. Mover el código de inicialización al constructor reduce significativamente la probabilidad de que el desarrollador no interprete correctamente cómo se debe inicializar la clase.

Contras


  1. Mi enfoque contiene dependencias en la implementación de EF Core.
  2. Algunas personas incluso lo llaman antipatrón. El problema es que ahora las entidades del modelo sujeto dependen del código de acceso a la base de datos. En términos de DDD, esto es malo. Me di cuenta de que si no hubiera hecho esto, habría tenido que confiar en la persona que llama para saber qué debería cargarse. Este enfoque rompe el principio de separación de preocupaciones.
  3. DDD te obliga a escribir más código.

¿Realmente vale la pena en casos simples, como actualizar la fecha de publicación de un libro?
Como puede ver, me gusta el enfoque DDD. Sin embargo, me tomó un tiempo estructurarlo correctamente, pero en este momento el enfoque ya se ha resuelto y lo estoy aplicando en los proyectos en los que estoy trabajando. Ya logré probar este estilo en proyectos pequeños y estoy satisfecho, pero aún no se han descubierto todos los pros y los contras cuando lo aplico en proyectos grandes.

Mi decisión de permitir el uso de código específico de EFCore en los argumentos de los métodos de entidad del modelo de entidad no fue simple. Traté de evitar esto, pero al final llegué a la conclusión de que el código de llamada tenía que cargar muchas propiedades de navegación. Y si esto no se hace, entonces el cambio simplemente no se aplicará sin ningún error (especialmente en una relación uno a uno). Esto no era aceptable para mí, así que permití el uso de EF Core en algunos métodos (pero no en los constructores).

Otro lado malo es que DDD te obliga a escribir significativamente más código para las operaciones CRUD. Todavía no estoy seguro de si seguir comiendo un cactus y escribir métodos separados para todas las propiedades, o en algunos casos vale la pena alejarse de un puritanismo tan radical. Sé que solo hay un carro y un pequeño camión de CRUD aburrido, que es más fácil de escribir directamente. Solo el trabajo en proyectos reales mostrará cuál es mejor.

Otros aspectos DDD no cubiertos en este artículo


El artículo ha resultado ser demasiado largo, así que voy a terminar aquí. Pero, esto significa que todavía hay mucho material no revelado. Ya escribí sobre algo, sobre algo que escribiré en el futuro cercano. Esto es lo que queda por la borda:

  1. Lógica empresarial y DDD. He estado usando conceptos DDD en código de lógica de negocios durante varios años y, usando las nuevas características de EF Core, espero poder transferir parte de la lógica al código de entidad. Lea el artículo "Una vez más sobre la arquitectura de la capa de lógica de negocios con Entity Framework (Core y v6)"
  2. DDD y el patrón de repositorio. Eric Evans recomienda utilizar un repositorio para abstraer el acceso a los datos. , «» EF Core – . Por qué - .
  3. DBContext' / (bounded contexts). DbContext'. , BookContext Book OrderContext, . , « » , . , .

Todo el código para este artículo está disponible en el repositorio GenericBizRunner en GitHub . Este repositorio contiene una aplicación ASP.NET Core de ejemplo con métodos de acceso especializados para modificar la clase Book. Puede clonar el repositorio y ejecutar la aplicación localmente. Utiliza Sqlite en memoria como base de datos, por lo que debe ejecutarse en cualquier infraestructura.

¡Feliz desarrollo!

Source: https://habr.com/ru/post/es432410/


All Articles