Entités de style DDD avec Entity Framework Core

Cet article explique comment appliquer les principes DDD (Domain-Driven Design) aux classes que le Entity Framework Core (EF Core) mappe à la base de données, et pourquoi cela peut être utile.

TLDR


L'approche DDD présente de nombreux avantages, mais l'essentiel est que DDD transfère le code des opérations de création / modification à l'intérieur de la classe d'entité. Cela réduit considérablement les chances d'un développeur de mal comprendre / interpréter les règles de création, d'initialisation et d'utilisation des instances de classe.

  1. Le livre d'Eric Evans et ses discours n'ont pas beaucoup d'informations à ce sujet:
  2. Fournir au client un modèle simple pour obtenir des objets persistants (classes) et gérer son cycle de vie.
  3. Vos classes d'entités doivent indiquer explicitement si elles peuvent être modifiées, comment et par quelles règles.
  4. Dans DDD, il y a le concept d'agrégat. L'agrégat est un arbre d'entités liées. Selon les règles DDD, le travail avec les agrégats doit être effectué via la «racine d'agrégation» (l'essence racine de l'arbre).

Eric mentionne les référentiels dans ses discours. Je ne recommande pas d'implémenter un référentiel avec EF Core, car EF implémente déjà le référentiel et l'unité de travail en soi. Je vais vous en dire plus à ce sujet dans un article séparé, " Cela vaut-il la peine d'utiliser un référentiel avec EF Core ?"

Entités de style DDD


Je vais commencer par montrer le code d'entité dans le style DDD, puis le comparer avec la façon dont les entités avec EF Core sont généralement créées (note du traducteur. L'auteur appelle le mot «généralement» un modèle anémique.) Pour cet exemple, j'utiliserai la base de données Internet librairie (une version très simplifiée d'Amazon. »La structure de la base de données est illustrée dans l'image ci-dessous.

image

Les quatre premiers tableaux représentent tout sur les livres: les livres eux-mêmes, leurs auteurs, les critiques. Les deux tableaux ci-dessous sont utilisés dans le code logique métier. Cette rubrique est décrite en détail dans un article séparé.
Tout le code de cet article a été téléchargé dans le référentiel GenericBizRunner sur GitHub . En plus du code de bibliothèque GenericBizRunner, il existe un autre exemple d'application ASP.NET Core qui utilise GenericBizRunner pour travailler avec la logique métier. Pour en savoir plus, consultez l'article « Bibliothèque pour travailler avec la logique métier et Entity Framework Core ».
Et voici le code d'entité correspondant à la structure de la base de données.

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)… } 

Que rechercher:

  1. Ligne 5: définissez l'accès à toutes les propriétés d'entité déclarées privées. Cela signifie que les données peuvent être modifiées à l'aide du constructeur ou à l'aide des méthodes publiques décrites plus loin dans cet article.
  2. Lignes 9 et 10. Les collections associées (les mêmes agrégats de DDD) fournissent un accès public à IEnumerable <T>, pas à ICollection <T>. Cela signifie que vous ne pouvez pas ajouter ou supprimer directement des éléments de la collection. Vous devrez utiliser des méthodes spécialisées de la classe Livre.
  3. Ligne 13. EF Core nécessite un constructeur sans paramètre, mais il peut avoir un accès privé. Cela signifie que d'autres codes d'application ne pourront pas contourner l'initialisation et créer des instances de classes à l'aide d'un constructeur non paramétrique (commentaire d'un traducteur. À moins bien sûr que vous ne créiez des entités en utilisant uniquement la réflexion)
  4. Lignes 16-20: La seule façon de créer une instance de la classe Book est d'utiliser le constructeur public. Ce constructeur contient toutes les informations nécessaires pour initialiser l'objet. Ainsi, l'objet est garanti d'être dans un état valide.
  5. Lignes 23-25: ces lignes contiennent des méthodes pour changer l'état d'un livre.
  6. Lignes 28-29: ces méthodes vous permettent de modifier les entités liées (agrégats)

Pour les méthodes des lignes 23 à 39, je continuerai d'appeler les «méthodes qui fournissent l'accès». Ces méthodes sont le seul moyen de modifier les propriétés et les relations au sein d'une entité. L'essentiel est que la classe Book est "fermée". Il est créé par un constructeur spécial et ne peut être modifié que partiellement par des méthodes spéciales avec des noms appropriés. Cette approche crée un contraste frappant avec l'approche standard de création / modification d'entités dans EF Core, dans laquelle toutes les entités contiennent un constructeur par défaut vide et toutes les propriétés sont déclarées publiques. La question suivante est: pourquoi la première approche est-elle meilleure?

Comparaison de la création d'entités


Comparons le code pour obtenir des données sur plusieurs livres de json et créer des instances des classes Book sur leur base.

a. Approche standard


 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. Style 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); 

Code constructeur de classe de livre

 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++))); } 

Que rechercher:

  1. Lignes 1-2: le constructeur vous oblige à passer toutes les données nécessaires à une bonne initialisation.
  2. Lignes 5, 6 et 17-9: le code contient plusieurs vérifications des règles métier. Dans ce cas particulier, une violation des règles est considérée comme une erreur dans le code, par conséquent, en cas de violation, une exception sera levée. Si l'utilisateur pouvait corriger ces erreurs, j'utiliserais peut-être une fabrique statique qui renvoie Status <T> (traducteur de commentaires. J'utiliserais Option <T> ou Result <T>, comme nom plus courant). Le statut est un type qui renvoie une liste d'erreurs.
  3. Lignes 21-23: la liaison BookAuthor est créée dans le constructeur. Le constructeur BookAuthor peut être déclaré avec le niveau d'accès interne. De cette façon, nous pouvons empêcher la création de relations en dehors du DAL.

Comme vous l'avez peut-être remarqué, la quantité de code pour créer une entité est approximativement la même dans les deux cas. Alors, pourquoi le style DDD est-il meilleur? Le style DDD est meilleur en ce sens:

  1. Contrôle l'accès. Le changement accidentel de propriété est exclu. Tout changement se produit via le constructeur ou la méthode publique avec le nom correspondant. Évidemment ce qui se passe.
  2. Correspond à SEC (ne vous répétez pas). Vous devrez peut-être créer des instances Book à plusieurs endroits. Le code d'affectation est dans le constructeur et vous n'avez pas à le répéter à plusieurs endroits.
  3. Cache la complexité. La classe Book a deux propriétés: ActualPrice et OrgPrice. Ces deux valeurs doivent être égales lors de la création d'un nouveau livre. Dans une approche standard, chaque développeur doit en être conscient. Dans l'approche DDD, il suffit que le développeur de la classe Book en soit informé. Les autres apprendront cette règle car elle est explicitement écrite dans le constructeur.
  4. Masque la création d'agrégats. Dans une approche standard, le développeur doit créer manuellement une instance de BookAuthor. Dans le style DDD, cette complexité est encapsulée pour le code appelant.
  5. Permet aux propriétés d'avoir un accès en écriture privé
  6. L'une des raisons d'utiliser DDD est de verrouiller l'entité, c'est-à-dire Ne donnez pas la possibilité de modifier directement les propriétés. Comparons l'opération de modification avec et sans DDD.

Comparaison des changements de propriété


Eric Evans appelle l'un des principaux avantages des entités de style DDD: «Elles communiquent les décisions de conception concernant l'accès aux objets».
Remarque traducteur. La phrase originale est difficile à traduire en russe. Dans ce cas, les décisions de conception sont des décisions prises sur le fonctionnement du logiciel. Cela signifie que les décisions ont été discutées et confirmées. Le code avec des constructeurs qui initialisent correctement des entités et des méthodes avec des noms corrects qui reflètent la signification des opérations indique explicitement au développeur que les affectations de certaines valeurs ont été faites avec intention, et non par erreur, et ne sont pas le caprice d'un autre développeur ou les détails de mise en œuvre.
Je comprends cette phrase comme suit.

  1. Indiquez clairement comment modifier les données au sein d'une entité et quelles données doivent changer ensemble.
  2. Indiquez clairement quand vous ne devez pas modifier certaines données dans l'entité.
Comparons les deux approches. Le premier exemple est simple et le second est plus compliqué.

1. Changement de date de publication


Supposons que nous voulons d'abord travailler avec un brouillon d'un livre et ensuite seulement le publier. Au moment de la rédaction de l'ébauche, une date de publication estimée est fixée, qui sera très probablement modifiée au cours du processus d'édition. Pour stocker la date de publication, nous utiliserons la propriété PublishedOn.

a. Entité avec des propriétés publiques


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

b. Entité de style DDD


Dans le style DDD, le setter de la propriété est déclaré privé, nous allons donc utiliser une méthode d'accès spécialisée.

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

Ces deux cas sont presque les mêmes. La version DDD est encore un peu plus longue. Mais il y a encore une différence. Dans le style DDD, vous savez avec certitude que la date de publication peut être modifiée car il existe une méthode avec un nom évident. Vous savez également que vous ne pouvez pas modifier l'éditeur car la propriété Publisher n'a pas de méthode appropriée à modifier. Ces informations seront utiles à tout programmeur travaillant avec une classe de livres.

2. Gérez la remise pour le livre


Une autre exigence est que nous devons être en mesure de gérer les remises. La remise consiste en un nouveau prix et un commentaire, par exemple, "50% avant la fin de cette semaine!"

La mise en œuvre de cette règle est simple, mais pas trop évidente.

  1. La propriété OrgPrice est le prix sans remise.
  2. Prix ​​réel - Prix actuel auquel le livre est vendu. Si la remise est valide, le prix actuel sera différent de OrgPrice par la taille de la remise. Sinon, la valeur des propriétés sera égale.
  3. La propriété PromotionText doit contenir le texte de remise si la remise est appliquée ou null si la remise n'est pas actuellement appliquée.

Les règles sont assez évidentes pour la personne qui les a mises en œuvre. Cependant, pour un autre développeur, par exemple, développer une interface utilisateur pour ajouter une remise. L'ajout des méthodes AddPromotion et RemovePromotion à la classe d'entité masque les détails d'implémentation. Maintenant, un autre développeur a des méthodes publiques avec les noms correspondants. La sémantique de l'utilisation des méthodes est évidente.

Jetez un œil à l'implémentation des méthodes AddPromotion et 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; } 

Que rechercher:

  1. Lignes 4-10: l'ajout d'un commentaire PromotionalText est requis. La méthode vérifie que le texte n'est pas vide. Parce que L'utilisateur peut corriger cette erreur. La méthode renvoie une liste d'erreurs à corriger.
  2. Lignes 12, 13: la méthode fixe les valeurs des propriétés en fonction de l'implémentation que le développeur a choisie. L'utilisateur de la méthode AddPromotion n'a pas besoin de les connaître. Pour ajouter une remise, écrivez simplement:

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

La méthode RemovePromotion est beaucoup plus simple: elle n'implique pas de gestion des erreurs. Par conséquent, la valeur de retour est simplement nulle.

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

Ces deux exemples sont très différents l'un de l'autre. Dans le premier exemple, la modification de la propriété PublishOn est si simple que l'implémentation standard est correcte. Dans le deuxième exemple, les détails de l'implémentation ne sont pas évidents pour quelqu'un qui n'a pas travaillé avec la classe Book. Dans le second cas, le style DDD avec des méthodes d'accès spécialisées masque les détails de mise en œuvre et facilite la vie des autres développeurs. En outre, dans le deuxième exemple, le code contient une logique métier. Bien que la quantité de logique soit petite, nous pouvons la stocker directement dans les méthodes d'accès et renvoyer une liste d'erreurs si la méthode n'est pas utilisée correctement.

3. Travailler avec l'agrégat - Revues de la collection de propriétés


DDD propose de travailler avec l'unité uniquement via la racine. Dans notre cas, la propriété Reviews crée des problèmes. Même si setter est déclaré privé, le développeur peut toujours ajouter ou supprimer des objets à l'aide des méthodes add et remove, ou même appeler la méthode clear pour effacer la collection entière. Ici, la nouvelle fonctionnalité EF Core, les champs de support, nous aidera.

Le champ de support permet au développeur d'encapsuler la collection réelle et de fournir un accès public au lien d'interface IEnumerable <T>. L'interface IEnumerable <T> ne fournit pas de méthodes d'ajout, de suppression ou d'effacement. Dans le code ci-dessous est un exemple d'utilisation des champs de sauvegarde.

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

Pour que cela fonctionne, vous devez indiquer à EF Core que lors de la lecture à partir de la base de données, vous devez écrire dans un champ privé, pas une propriété publique. Le code de configuration est illustré ci-dessous.

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

Pour travailler avec des revues, j'ai ajouté deux méthodes: AddReview et RemoveReview à la classe de livres. La méthode AddReview est plus intéressante. Voici son code:

 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."); } } 

Que rechercher:

  1. Lignes 4-7: Je n'initialise pas intentionnellement le champ _reviews dans un constructeur privé sans paramètre qu'EF Core utilise lors du chargement d'entités à partir de la base de données. Cela permet à mon code de déterminer si la collection a été chargée à l'aide de la méthode .Include (p => p.Reviews). Dans le constructeur public, j'initialise le champ, donc NRE ne se produira pas lorsque vous travaillerez avec l'entité créée.
  2. Lignes 8-12: si la collection Reviews n'a pas été chargée, le code doit utiliser DbContext pour s'initialiser.
  3. Lignes 13-16: Si le livre a été créé avec succès et contient un ID, j'utilise une autre technique pour ajouter une révision: j'installe simplement la clé étrangère dans une instance de la classe Review et l'écris dans la base de données. Ceci est décrit plus en détail dans la section 3.4.5 de mon livre.
  4. Ligne 19: Si nous sommes ici, il y a une sorte de problème avec la logique du code. Je lève donc une exception.

J'ai conçu toutes mes méthodes d'accès pour les cas inversés où seule l'entité racine est chargée. La mise à jour de l'unité est à la discrétion des méthodes. Vous devrez peut-être charger des entités supplémentaires.

Conclusion


Pour créer des entités dans le style DDD avec EF Core, vous devez respecter les règles suivantes:

  1. Créez des constructeurs publics pour créer des instances de classe correctement initialisées. Si des erreurs peuvent se produire pendant le processus de création que l'utilisateur peut corriger, créez l'objet non pas à l'aide du constructeur public, mais à l'aide de la méthode d'usine qui renvoie Status <T>, où T est le type d'entité en cours de création
  2. Toutes les propriétés sont des créateurs de biens. C'est-à-dire toutes les propriétés sont en lecture seule en dehors de la classe.
  3. Pour les propriétés de navigation de collection, déclarez les champs de sauvegarde et le type de propriété publique déclarez IEnumerable <T>. Cela empêchera les autres développeurs de modifier les collections de manière incontrôlable.
  4. Au lieu de setters publics, créez des méthodes publiques pour toutes les opérations de changement d'objet autorisées. Ces méthodes doivent retourner null si l'opération ne peut pas échouer avec une erreur que l'utilisateur peut corriger ou Status <T> si c'est le cas.
  5. L'étendue de la responsabilité de l'entité est importante. Je pense qu'il est préférable de limiter les entités à changer la classe elle-même et d'autres classes à l'intérieur de l'agrégat, mais pas à l'extérieur. Les règles de validation devraient se limiter à vérifier l'exactitude de la création et du changement d'état des entités. C'est-à-dire Je ne vérifie pas les règles commerciales telles que les soldes de stock. Il existe un code logique métier spécial pour cela.
  6. Les méthodes de changement d'état doivent supposer que seule la racine d'agrégation est chargée. Si une méthode doit charger d'autres données, elle doit s'en occuper seule.
  7. Les méthodes de changement d'état doivent supposer que seule la racine d'agrégation est chargée. Si une méthode doit charger d'autres données, elle doit s'en occuper seule. Cette approche simplifie l'utilisation des entités par d'autres développeurs.

Avantages et inconvénients des entités DDD lorsque vous travaillez avec EF Core


J'aime l'approche critique de n'importe quel modèle ou architecture. Voici ce que je pense de l'utilisation des entités DDD.

Avantages


  1. L'utilisation de méthodes spécialisées pour changer d'état est une approche plus propre. C'est certainement une bonne solution, tout simplement parce que les méthodes correctement nommées révèlent bien mieux les intentions du code et rendent évident ce qui peut et ne peut pas être changé. De plus, les méthodes peuvent renvoyer une liste d'erreurs si l'utilisateur peut les corriger.
  2. La modification des agrégats uniquement via la racine fonctionne également bien
  3. Les détails de la relation un-à-plusieurs entre les classes Book et Review sont désormais masqués pour l'utilisateur. L'encapsulation est un principe de base de la POO.
  4. L'utilisation de constructeurs spécialisés vous permet de vous assurer que les entités sont créées et garanties d'être correctement initialisées.
  5. Déplacer le code d'initialisation vers le constructeur réduit considérablement la probabilité que le développeur n'interprète pas correctement la façon dont la classe doit être initialisée.

Inconvénients


  1. Mon approche contient des dépendances sur l'implémentation d'EF Core.
  2. Certaines personnes l'appellent même un anti-modèle. Le problème est que maintenant les entités du modèle sujet dépendent du code d'accès à la base de données. En termes de DDD, c'est mauvais. J'ai réalisé que si je ne l'avais pas fait, j'aurais dû compter sur l'appelant pour savoir ce qui devait être chargé. Cette approche rompt le principe de la séparation des préoccupations.
  3. DDD vous oblige à écrire plus de code.

Cela vaut-il vraiment la peine dans des cas simples, comme la mise à jour de la date de publication d'un livre?
Comme vous pouvez le voir, j'aime l'approche DDD. Cependant, il m'a fallu un certain temps pour le structurer correctement, mais pour le moment l'approche est déjà réglée et je l'applique dans les projets sur lesquels je travaille. J'ai déjà réussi à essayer ce style dans les petits projets et je suis satisfait, mais tous les avantages et les inconvénients restent à découvrir lorsque je l'utilise dans les grands projets.

Ma décision d'autoriser l'utilisation de code spécifique à EFCore dans les arguments des méthodes d'entité du modèle d'entité n'a pas été simple. J'ai essayé d'empêcher cela, mais à la fin je suis arrivé à la conclusion que le code appelant devait charger beaucoup de propriétés de navigation. Et si cela n'est pas fait, le changement ne sera tout simplement pas appliqué sans erreur (en particulier dans une relation un-à-un). Ce n'était pas acceptable pour moi, j'ai donc autorisé l'utilisation d'EF Core dans certaines méthodes (mais pas les constructeurs).

Un autre inconvénient est que DDD vous oblige à écrire beaucoup plus de code pour les opérations CRUD. Je ne sais toujours pas s'il faut continuer à manger un cactus et écrire des méthodes distinctes pour toutes les propriétés, ou dans certains cas, cela vaut la peine de s'éloigner d'un puritanisme si radical. Je sais qu'il n'y a qu'un chariot et un petit camion de CRUD ennuyeux, ce qui est plus facile à écrire directement. Seul le travail sur de vrais projets montrera lequel est le meilleur.

Autres aspects DDD non traités dans cet article


L'article s'est avéré trop long, je vais donc terminer ici. Mais cela signifie qu'il y a encore beaucoup de documents non divulgués. J'ai déjà écrit quelque chose, quelque chose que j'écrirai dans un avenir proche. Voici ce qui reste à la mer:

  1. Logique métier et DDD. J'utilise les concepts DDD dans le code logique métier depuis plusieurs années et, en utilisant les nouvelles fonctionnalités d'EF Core, je m'attends à pouvoir transférer une partie de la logique au code entité. Lire l'article «Encore une fois sur l'architecture de la couche logique métier avec Entity Framework (Core et v6)»
  2. DDD et le modèle de référentiel. Eric Evans recommande d'utiliser un référentiel afin d'abstraire l'accès aux données. , «» EF Core – . Pourquoi? - .
  3. DBContext' / (bounded contexts). DbContext'. , BookContext Book OrderContext, . , « » , . , .

Tout le code de cet article est disponible dans le référentiel GenericBizRunner sur GitHub . Ce référentiel contient un exemple d'application ASP.NET Core avec des méthodes d'accès spécialisées pour modifier la classe Book. Vous pouvez cloner le référentiel et exécuter l'application localement. Il utilise Sqlite en mémoire comme base de données, il doit donc fonctionner sur n'importe quelle infrastructure.

Bon développement!

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


All Articles