Conception pilotée par domaine: objets de valeur et noyau d'entité dans la pratique

Sur Habré et pas seulement une quantité décente d'articles ont été écrits sur la conception pilotée par domaine - à la fois en général sur l'architecture et avec des exemples sur .Net. Mais en même temps, une partie aussi importante de cette architecture que les objets de valeur est souvent mal mentionnée.

Dans cet article, je vais essayer de découvrir les nuances de l'implémentation d'objets de valeur dans .Net Core à l'aide du Entity Framework Core.

Sous un chat, il y a beaucoup de code.

Un peu de théorie


Le cœur de l'architecture de Domain Driven Design est le domaine - le domaine auquel le logiciel en cours de développement est appliqué. Voici toute la logique métier de l'application, qui interagit généralement avec différentes données. Les données peuvent être de deux types:

  • Objet Entity
  • Objet de valeur (ci-après - VO)

Entity Object définit une entité dans la logique métier et possède toujours un identifiant par lequel l'entité peut être trouvée ou comparée avec une autre entité. Si deux entités ont un identifiant identique, il s'agit de la même entité. Changer presque toujours.
L'objet de valeur est un type immuable, dont la valeur est définie lors de la création et ne change pas tout au long de la vie de l'objet. Il n'a pas d'identifiant. Si deux VO sont structurellement identiques, ils sont équivalents.

L'entité peut contenir d'autres entités et VO. Les VO peuvent inclure d'autres VO, mais pas l'Entité.

Ainsi, la logique du domaine doit fonctionner exclusivement avec Entity et VO - cela garantit sa cohérence. Types de données de base tels que chaîne, entier, etc. souvent, ils ne peuvent pas agir en tant que VO, car ils peuvent simplement violer l'état du domaine - ce qui est presque une catastrophe dans le cadre de DDD.

Un exemple. Dans les différents manuels, la classe Personne, qui en a eu assez de tout le monde, est souvent présentée comme ceci:

public class Person { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } } 

Simple et clair - identifiant, nom et âge, où pouvez-vous faire une erreur?

Et il peut y avoir plusieurs erreurs ici - par exemple, du point de vue de la logique métier, le nom est obligatoire, il ne peut pas être de longueur nulle ou plus de 100 caractères et ne doit pas contenir de caractères spéciaux, de ponctuation, etc. Et l'âge ne peut être inférieur à 10 ans ni supérieur à 120 ans.

Du point de vue du langage de programmation, 5 est un entier tout à fait normal, de même une chaîne vide. Mais le domaine est déjà dans un état incorrect.

Passons à la pratique


À ce stade, nous savons que la VO doit être immuable et contenir une valeur valable pour la logique métier.

L'immunité est obtenue en initialisant la propriété en lecture seule lors de la création de l'objet.
La validation de la valeur a lieu dans le constructeur (clause Guard). Il est souhaitable de rendre la vérification elle-même accessible au public - afin que d'autres couches puissent valider les données reçues du client (le même navigateur).

Créons une VO pour le nom et l'âge. De plus, nous compliquons un peu la tâche - ajoutez un PersonalName combinant FirstName et LastName, et appliquez-le à Person.

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


Nom personnel
 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; } } 


Âge
 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(); } } 


Et enfin personne:

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

Par conséquent, nous ne pouvons pas créer de personne sans nom complet ni âge. De plus, nous ne pouvons pas créer un «mauvais» nom ou un «mauvais» âge. Un bon programmeur vérifiera sûrement les données reçues dans le contrôleur en utilisant les méthodes Name.IsValid ("John") et Age.IsValid (35) et, en cas de données incorrectes, en informera le client.

Si nous établissons une règle partout dans le modèle pour utiliser uniquement Entity et VO, alors nous nous protégerons contre un grand nombre d'erreurs - des données incorrectes n'entreront tout simplement pas dans le modèle.

Persistance


Maintenant, nous devons enregistrer nos données dans l'entrepôt de données et les obtenir sur demande. Nous utiliserons Entity Framework Core comme ORM, et l'entrepôt de données est MS SQL Server.

DDD définit clairement: La persistance est une sous-espèce de la couche infrastructure car elle masque une implémentation spécifique de l'accès aux données.

Le domaine n'a besoin de rien savoir sur la persistance, cela ne détermine que les interfaces des référentiels.

Et Persistence contient des implémentations spécifiques, des configurations de mappage, ainsi qu'un objet UnitOfWork.

Il y a deux opinions quant à savoir s'il vaut la peine de créer des référentiels et des unités de travail.

D'une part - non, ce n'est pas nécessaire, car dans Entity Framework Core, tout est déjà implémenté. Si nous avons une architecture à plusieurs niveaux de la forme DAL -> Business Logic -> Présentation, qui est basée sur le stockage de données, alors pourquoi ne pas utiliser directement les capacités d'EF Core.

Mais le domaine dans DDD ne dépend pas du stockage des données et de l'ORM utilisé - ce sont toutes les subtilités de l'implémentation qui sont encapsulées dans Persistence et ne présentent aucun intérêt pour personne d'autre. Si nous fournissons DbContext à d'autres couches, nous divulguons immédiatement les détails d'implémentation, nous nous lions étroitement à l'ORM sélectionné et obtenons le DAL - comme base de toute logique métier, mais cela ne devrait pas l'être. En gros, le domaine ne devrait pas remarquer de changement dans l'ORM et même la perte de persistance en tant que couche.

Ainsi, l'interface du référentiel de personnes, dans le domaine:

 public interface IPersons { Task Add(Person person); Task<IReadOnlyList<Person>> GetList(); } 

et sa mise en œuvre dans 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(); } } 

Cela ne semble rien de compliqué, mais il y a un problème. Prêt à l'emploi Entity Framework Core ne fonctionne qu'avec les types de base (chaîne, int, DateTime, etc.) et ne connaît rien de PersonalName et Age. Apprenons à EF Core à comprendre nos objets de valeur.

La configuration


L'API Fluent est la plus appropriée pour configurer Entity dans DDD. Les attributs ne conviennent pas, car le domaine n'a pas besoin de connaître les nuances du mappage.

Créez une classe en persistance avec la configuration de base 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(); } } 

et branchez-le dans le DbContext:

 protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new PersonConfiguration()); } 

Cartographie


Section pour laquelle ce document a été rédigé.

Pour le moment, il existe deux façons plus ou moins pratiques de mapper des classes non standard aux types de base - Conversions de valeurs et Types possédés.

Conversions de valeur


Cette fonctionnalité est apparue dans Entity Framework Core 2.1 et vous permet de déterminer la conversion entre les deux types de données.

Écrivons le convertisseur pour Age (dans cette section, tout le code est dans 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(); 

Syntaxe simple et concise, mais pas sans défauts:

  1. Impossible de convertir null;
  2. Il n'est pas possible de convertir une seule propriété en plusieurs colonnes dans une table et vice versa;
  3. EF Core ne peut pas convertir une expression LINQ avec cette propriété en une requête SQL.

Je m'attarderai sur le dernier point plus en détail. Ajoutez une méthode au référentiel qui renvoie une liste de personnes de plus d'un âge donné:

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

Il y a une condition pour l'âge, mais EF Core ne pourra pas le convertir en requête SQL et, atteignant Where (), il chargera la table entière dans la mémoire de l'application et, alors seulement, en utilisant LINQ, il remplira la condition p.Age.Value> age.Value .

En général, Value Conversions est une option de mappage simple et rapide, mais vous devez vous souvenir de cette fonctionnalité d'EF Core, sinon, à un moment donné, lors de l'interrogation de grandes tables, la mémoire peut s'épuiser.

Types possédés


Les types possédés sont apparus dans Entity Framework Core 2.0 et ont remplacé les types complexes de Entity Framework standard.

Faisons de l'âge comme type de propriété:

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

Pas mal. Et les types possédés ne présentent pas certains des inconvénients des conversions de valeur, à savoir les points 2 et 3.

2. Il est possible de convertir une propriété en plusieurs colonnes dans le tableau et vice versa

Ce dont vous avez besoin pour PersonalName, bien que la syntaxe soit déjà un peu surchargée:

 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 peut convertir une expression LINQ avec cette propriété en une requête SQL.
Ajoutez le tri par nom et prénom lors du chargement de la liste:

 public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons .OrderBy(p => p.PersonalName.LastName.Value) .ThenBy(p => p.PersonalName.FirstName.Value) .ToListAsync(); } 

Une telle expression sera correctement convertie en requête SQL et le tri est effectué côté serveur SQL, et non dans l'application.

Bien sûr, il y a aussi des inconvénients.

  1. Les problèmes avec null n'ont pas disparu;
  2. Les champs Types possédés ne peuvent pas être en lecture seule et doivent avoir un setter protégé ou privé.
  3. Les types possédés sont implémentés en tant qu'entité régulière, ce qui signifie:
    • Ils ont un identifiant (comme une propriété shadow, c'est-à-dire qu'il n'apparaît pas dans la classe de domaine);
    • EF Core suit toutes les modifications apportées aux types de propriété, exactement les mêmes que pour l'entité normale.

D'une part, ce n'est pas du tout ce que devraient être les objets de valeur. Ils ne doivent pas avoir d'identifiants. Les VO ne doivent pas être suivis pour les changements - car ils sont initialement immuables, les propriétés de l'entité parent doivent être suivies, mais pas les propriétés de VO.

D'un autre côté, ce sont des détails d'implémentation qui peuvent être omis, mais encore une fois, n'oubliez pas. Le suivi des modifications affecte les performances. Si cela n'est pas perceptible avec des échantillons d'entité unique (par exemple, par Id) ou de petites listes, puis avec une sélection de grandes listes d'entités «lourdes» (nombreuses propriétés de VO), la baisse des performances sera très perceptible précisément en raison du suivi.

Présentation


Nous avons compris comment implémenter des objets de valeur dans un domaine et un référentiel. Il est temps de tout utiliser. Créons deux pages simples - avec la liste Personne et le formulaire pour ajouter Personne.

Le code du contrôleur sans méthodes d'action ressemble à ceci:

 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; } // Actions private static PersonModel CreateModel(Person person) { return new PersonModel { FirstName = person.PersonalName.FirstName.Value, LastName = person.PersonalName.LastName.Value, Age = person.Age.Value }; } } 

Ajouter une action pour obtenir la liste des personnes:

 [HttpGet] public async Task<IActionResult> Index() { var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View(result); } 

Afficher
 @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> 


Rien de compliqué - nous avons chargé la liste, créé un objet de transfert de données (PersonModel) pour chaque

Personne et envoyé à la vue correspondante.

Résultat


Beaucoup plus intéressant est l'ajout de Person:

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

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


Il y a une validation obligatoire des données entrantes:

 if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); } 

Si cela n'est pas fait, lors de la création d'un VO avec une valeur incorrecte, une ArgumentException sera levée (rappelez-vous la clause de garde dans les constructeurs VO). Avec la vérification, il est beaucoup plus facile d'envoyer un message à l'utilisateur qu'une des valeurs est incorrecte.

Résultat


Ici, vous devez faire une petite digression - dans Asp Net Core, il existe un moyen régulier de validation des données - en utilisant des attributs. Mais en DDD, cette méthode de validation n'est pas correcte pour plusieurs raisons:

  • Les capacités d'attribut peuvent ne pas être suffisantes pour la logique de validation;
  • Toute logique métier, y compris les règles de validation des paramètres, est définie exclusivement par le domaine. Il a le monopole à ce sujet et toutes les autres couches doivent en tenir compte. Les attributs peuvent être utilisés, mais vous ne devez pas vous en remettre à eux. Si l'attribut saute des données incorrectes, nous obtiendrons à nouveau une exception lors de la création d'un VO.

Revenons à AddPerson (). Après la validation des données, PersonalName, Age, puis Person sont créés. Ensuite, ajoutez l'objet au référentiel et enregistrez les modifications (Commit). Il est très important que Commit ne soit pas invoqué dans le référentiel EfPersons. La tâche du référentiel est d'effectuer une action avec les données, pas plus. La validation ne se fait que de l'extérieur, quand exactement - le programmeur décide. Sinon, une situation est possible lorsqu'une erreur se produit au milieu d'une certaine itération commerciale - certaines des données sont enregistrées et d'autres pas. Nous recevons le domaine à l'état "cassé". Si la validation est effectuée à la toute fin, alors si l'erreur se produit, la transaction sera simplement annulée.

Conclusion


J'ai donné des exemples de la mise en œuvre des objets de valeur en général et des nuances de la cartographie dans Entity Framework Core. J'espère que le matériel sera utile pour comprendre comment appliquer les éléments de Domain Driven Design dans la pratique.

Code source complet du projet PersonsDemo - GitHub

Le matériel ne révèle pas le problème de l'interaction avec les objets de valeur facultatifs (annulables) - si PersonalName ou Age n'étaient pas des propriétés requises de Person. Je voulais décrire cela dans cet article, mais il est déjà sorti un peu surchargé. S'il y a un intérêt pour cette question - écrivez dans les commentaires, la suite sera.

Pour les fans de «belles architectures» en général et de conception pilotée par domaine en particulier, je recommande fortement la ressource Enterprise Craftsmanship .

Il existe de nombreux articles utiles sur la construction correcte de l'architecture et des exemples d'implémentation sur .Net. Certaines idées y ont été empruntées, mises en œuvre avec succès dans des projets de «combat» et reflétées en partie dans cet article.

La documentation officielle pour les types de propriété et les conversions de valeur a également été utilisée.

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


All Articles