Quand l'obsession primitive n'est-elle pas une odeur de code?

22

J'ai lu récemment de nombreux articles qui décrivent l'obsession primitive comme une odeur de code.

Il y a deux avantages à éviter l'obsession primitive:

  1. Cela rend le modèle de domaine plus explicite. Par exemple, je peux parler à un analyste commercial d'un code postal au lieu d'une chaîne qui contient un code postal.

  2. Toute la validation se fait en un seul endroit plutôt qu'à travers l'application.

Il y a beaucoup d'articles qui décrivent quand c'est une odeur de code. Par exemple, je peux voir l'avantage de supprimer l'obsession primitive pour un code postal comme celui-ci:

public class Address
{
    public ZipCode ZipCode { get; set; }
}

Voici le constructeur du ZipCode:

public ZipCode(string value)
    {
        // Perform regex matching to verify XXXXX or XXXXX-XXXX format
        _value = value;
    }

Vous violeriez le principe DRY en mettant cette logique de validation partout où un code postal est utilisé.

Mais qu'en est-il des objets suivants:

  1. Date de naissance: vérifiez que la date est supérieure à celle indiquée et inférieure à la date d'aujourd'hui.

  2. Salaire: Vérifiez que supérieur ou égal à zéro.

Souhaitez-vous créer un objet DateOfBirth et un objet Salary? L'avantage est que vous pouvez en parler lors de la description du modèle de domaine. Cependant, est-ce un cas de sur-ingénierie car il n'y a pas beaucoup de validation. Existe-t-il une règle qui décrit quand et quand ne pas supprimer l'obsession primitive ou devez-vous toujours le faire si possible?

Je suppose que je pourrais créer un alias de type au lieu d'une classe, ce qui aiderait au point un ci-dessus.

w0051977
la source
8
"Vous violeriez le principe DRY en mettant cette logique de validation partout où un code postal est utilisé." Ce n'est pas vrai. La validation doit être effectuée dès que les données sont entrées dans votre module . S'il y a plus d'un "point d'entrée", la validation doit être dans une unité réutilisable , et cela ne doit pas être (ni être) le DTO ...
Timothy Truckle
1
Comment donnez-vous «la réflexion» et la «date d'aujourd'hui» au DateOfBirthconstructeur pour qu'il vérifie?
Caleth
11
Un autre avantage de la création de types personnalisés est la sécurité des types. Si vous en avez Salaryet que Distancevous vous y opposez, vous ne pouvez pas les utiliser accidentellement de manière interchangeable. Vous pouvez le faire s'ils sont tous les deux de type double.
Scroog1
3
@ w0051977 Votre déclaration (telle que je l'ai comprise) impliquait que toute autre chose que d'avoir la validation dans le constructeur des DTO violerait DRY. En fait, la validation devrait être en dehors du DTO ...
Timothy Truckle
2
Pour moi, tout est une question de portée. Si vous donnez aux primitives une large portée, il existe de nombreuses façons de les utiliser et de les manipuler de manière incorrecte. Donc, vous voulez généralement leur donner une portée plus étroite, et une façon de le faire est de concevoir une classe représentant un concept en utilisant une primitive, stockée en privé comme interne, pour l'implémenter. Maintenant, la portée de la primitive est étroite et il est peu probable qu'elle soit mal utilisée / mal gérée, et vous pouvez efficacement conserver les invariants. Mais si la portée de la primitive était étroite au début, cela pourrait être exagéré et introduire beaucoup de couplage et de code supplémentaires à maintenir.

Réponses:

17

L'obsession primitive utilise des types de données primitifs pour représenter des idées de domaine.

L'inverse serait la «modélisation de domaine», ou peut-être «sur l'ingénierie».

Souhaitez-vous créer un objet DateOfBirth et un objet Salary?

L'introduction d'un objet Salary peut être une bonne idée pour la raison suivante: les nombres sont rarement seuls dans le modèle de domaine, ils ont presque toujours une dimension et une unité. Normalement, nous ne modélisons rien d'utile si nous ajoutons une longueur à un temps ou à une masse, et nous obtenons rarement de bons résultats lorsque nous mélangeons mètres et pieds.

Quant à DateOfBirth, probablement - il y a deux questions à considérer. Tout d'abord, la création d'une date non primitive vous permet de centrer toutes les préoccupations étranges liées aux mathématiques de la date. De nombreuses langues en fournissent une prête à l'emploi; DateTime , java.util.Date . Ce sont des implémentations de dates indépendantes du domaine, mais ce ne sont pas des primitives.

Deuxièmement, ce DateOfBirthn'est pas vraiment une date; ici aux États-Unis, la «date de naissance» est une construction culturelle / fiction juridique. Nous avons tendance à mesurer la date de naissance à partir de la date locale de naissance d'une personne; Bob, né en Californie, pourrait avoir une date de naissance "antérieure" à Alice, née à New York, même s'il est le plus jeune des deux.

Existe-t-il une règle qui décrit quand et quand ne pas supprimer l'obsession primitive ou devez-vous toujours le faire si possible.

Certainement pas toujours; aux limites, les applications ne sont pas orientées objet . Il est assez courant de voir des primitives utilisées pour décrire les comportements dans les tests .

VoiceOfUnreason
la source
1
Le premier commentaire après la citation en haut semble être non séquentiel. De plus, il ne fait que reformuler l'objet de la question. C'est une bonne réponse sinon mais je trouve cela vraiment distrayant.
JimmyJames
ni C # DateTime ni java.util.Date ne sont des types sous-jacents appropriés pour DateOfBirth.
kevin cline
Peut-être remplacer java.util.Dateparjava.time.LocalDate
Koray Tugay
7

Pour être honnête: cela dépend.

Il y a toujours un risque de sur-ingénierie de votre code. Dans quelle mesure DateOfBirth et Salary seront-ils utilisés? Les utiliserez-vous uniquement dans trois classes étroitement couplées, ou seront-elles utilisées partout dans l'application? Pourriez-vous les «encapsuler» simplement dans leur propre type / classe pour appliquer cette contrainte, ou pouvez-vous penser à plus de contraintes / fonctions qui y appartiennent réellement?

Prenons l'exemple du Salaire: avez-vous des opérations avec "Salaire" (par exemple, gérer différentes devises, ou peut-être une fonction toString ())? Considérez ce qu'est / fait Salaire lorsque vous ne le regardez pas comme une simple primitive, et il y a de bonnes chances pour que Salaire soit sa propre classe.

CharonX
la source
Un alias de type est-il une bonne alternative?
w0051977
@ w0051977 je suis d'accord avec charonx et l'alias de type pourrait être une alternative
techagrammer
@ w0051977 un alias de type peut être une alternative si l'objectif principal est d'imposer un typage strict, pour indiquer explicitement ce qu'est une certaine valeur (salaire) pour éviter une affectation accidentelle de "dollars flottants" (par heure? semaine? mois?) à "salaire flottant" (par mois? année?). Cela dépend vraiment de vos besoins.
CharonX
@CharonX, je crois qu'une décimale devrait être utilisée pour un salaire et non un flottant. Êtes-vous d'accord?
w0051977
@ w0051977 Si vous avez un bon type décimal, alors celui-ci serait préférable, oui. (Je travaille sur un projet C ++ en ce moment, donc les booléens, les entiers et les flottants sont au premier plan de mon esprit)
CharonX
5

Une règle de base possible peut dépendre de la couche du programme. Pour le domaine (DDD) aka Entities Layer (Martin, 2018), cela pourrait tout aussi bien être «d'éviter les primitives pour tout ce qui représente un domaine / concept d'entreprise». Les justifications sont celles énoncées par le PO: un modèle de domaine plus expressif, la validation des règles métier, rendant les concepts implicites explicites (Evans, 2004).

Un alias de type peut être une alternative légère (Ghosh, 2017) et refactorisé en classe d'entité si nécessaire. Par exemple, nous pouvons d'abord exiger que ce Salarysoit le cas >=0, puis décider plus tard de refuser $100.33333tout ce qui précède $10,000,000(ce qui mettrait le client en faillite). L'utilisation de Nonnegativeprimitives pour représenter Salaryet d'autres concepts compliquerait cette refactorisation.

L'évitement des primitives peut également aider à éviter une ingénierie excessive. Supposons que nous devons combiner le salaire et la date de naissance dans une structure de données: par exemple, pour avoir moins de paramètres de méthode ou pour passer des données entre les modules. Ensuite, nous pouvons utiliser un tuple avec type (Salary, DateOfBirth). En effet, un tuple avec des primitives (Nonnegative, Nonnegative),, n'est pas informatif, alors que certains gonflés masqueraient class EmployeeDataentre autres les champs requis. La signature dans say calcPension(d: (Salary, DateOfBirth))est plus ciblée que dans calcPension(d: EmployeeData), ce qui viole le principe de ségrégation d'interface. De même, un spécialiste class SalaryAndDateOfBirthsemble maladroit et est probablement une exagération. Plus tard, nous pouvons choisir de définir une classe de données; les tuples et les types de domaines élémentaires nous permettent de différer ces décisions.

Dans une couche externe (par exemple GUI), il peut être judicieux de "dépouiller" les entités jusqu'à leurs primitives constitutives (par exemple pour les mettre dans un DAO). Cela empêche les fuites d'abstractions de domaine dans les couches externes, comme le préconise Martin (2018).

Références
E. Evans, "Domain-Driven Design", 2004
D. Ghosh, "Functional and Reactive Domain Modeling", 2017
RC Martin, "Clean architecture", 2018

Tupolev._
la source
+1 pour toutes les références.
w0051977
4

Mieux vaut souffrir d' obsession primitive ou être astronaute en architecture ?

Les deux cas sont pathologiques, dans un cas, vous avez trop peu d'abstractions, conduisant à la répétition et confondant facilement une pomme avec une orange, et dans l'autre, vous avez oublié de vous arrêter déjà et de commencer à faire avancer les choses, ce qui rend difficile de faire quoi que ce soit fait .

Comme presque toujours, vous voulez la modération, une voie médiane, espérons-le, bien pensée.

N'oubliez pas qu'une propriété a un nom, en plus d'un type. En outre, la décomposition d'une adresse en ses parties constituantes peut être trop contraignante si elle est toujours effectuée de la même manière. Tout le monde n'est pas au centre-ville de New York.

Déduplicateur
la source
3

Si vous aviez une classe Salary, elle pourrait avoir des méthodes comme ApplyRaise.

D'un autre côté, votre classe ZipCode ne doit pas avoir de validation interne pour éviter de dupliquer la validation partout où vous pourriez avoir une classe ZipCodeValidator qui pourrait être injectée, donc si votre système doit fonctionner à la fois aux adresses américaines et britanniques, vous pouvez simplement injecter le validateur correct et lorsque vous devez gérer des adresses AUS, vous pouvez simplement ajouter un nouveau validateur.

Une autre préoccupation est que si vous devez écrire des données dans une base de données via EntityFramework, il devra savoir comment gérer le salaire ou le ZipCode.

Il n'y a pas de réponse claire pour savoir où tracer la ligne entre les classes intelligentes, mais je dirai que j'ai tendance à déplacer la logique métier, comme la validation, vers des classes de logique métier dont les classes de données sont des données pures comme cela semble pour mieux travailler avec EntityFramework.

Quant à l'utilisation des alias de type, le nom de membre / propriété doit donner toutes les informations nécessaires sur le contenu, donc je n'utiliserais pas d'alias de type.

Courbé
la source
Un alias de type est-il une bonne alternative?
w0051977
2

(Quelle est probablement la question)

Quand l'utilisation du type primitif n'est-elle pas une odeur de code?

(Répondre)

Lorsque le paramètre n'a pas de règles, utilisez un type primitif.

Utilisez un type primitif pour:

htmlEntityEncode(string value)

Utilisez un objet comme:

numberOfDaysSinceUnixEpoch(SimpleDate value)

L'exemple ci ont des règles qu'il contient , par exemple, l'objet SimpleDateest composé de Year, Monthet Day. Grâce à l'utilisation d'Object dans ce cas, le concept d' SimpleDateêtre valide peut être encapsulé dans l'objet.

Stoked PHP Engineer
la source
1

Mis à part les exemples canoniques d'adresses e-mail ou de codes postaux donnés ailleurs dans cette question, la refactorisation loin de Obsession primitive peut être particulièrement utile avec les identifiants d'entité (voir https://andrewlock.net/using-strongly-typed-entity -ids-to-éviter-primitive-obsession-part-1 / pour un exemple de comment le faire dans .NET).

J'ai perdu le compte du nombre de fois où un bogue s'est glissé parce qu'une méthode avait une signature comme celle-ci:

int leaveId = 12345;
int submitterId = 23456;
int approverId = 34567;

SubmitLeaveApplication(leaveId, approverId, submitterId);

public void SubmitLeaveApplication(int leaveId, int submitterId, int approverId) {
  // implementation here
}

Compile très bien, et si vous n'êtes pas rigoureux avec vos tests unitaires, cela peut aussi réussir. Cependant, refactorisez ces ID d'entité dans des classes spécifiques au domaine, et hé bien, les erreurs de compilation:

LeaveId leaveId = 12345;
SubmitterId submitterId = 23456;
ApproverId approverId = 34567;

SubmitLeaveApplication(leaveId, approverId, submitterId);

public void SubmitLeaveApplication(LeaveId leaveId, SubmitterId submitterId, ApproverId approverId) {
  // implementation here
}

Imaginez cette méthode mise à l'échelle jusqu'à 10 paramètres ou plus, tous de inttype de données (sans parler de l'odeur du code de la liste des paramètres longs ) .Elle devient encore pire lorsque vous utilisez quelque chose comme AutoMapper pour permuter entre les objets de domaine et les DTO, et une refactorisation que vous faites n'est pas capté par le mappage automagique.

David Keaveny
la source
0

Vous violeriez le principe DRY en mettant cette logique de validation partout où un code postal est utilisé.

D'un autre côté, lorsque vous traitez avec de nombreux pays différents et leurs différents systèmes de code postal, cela signifie que vous ne pouvez pas valider un code postal sans connaître le pays en question. Votre ZipCodeclasse doit donc également stocker le pays.

Mais stockez-vous ensuite séparément le pays à la fois dans le Address(dont le code postal fait également partie) et dans le code postal (pour validation)?

  • Si vous le faites, vous violez également DRY. Même si vous n'appelez pas cela une violation DRY (car chaque instance sert un objectif différent), cela prend inutilement de la mémoire supplémentaire, en plus d'ouvrir la porte aux bogues lorsque les deux valeurs de pays sont différentes (ce qu'elles ne devraient logiquement jamais être).
    • Ou, alternativement, cela vous oblige à synchroniser les deux points de données pour vous assurer qu'ils sont toujours les mêmes, ce qui suggère que vous devriez vraiment stocker ces données en un seul point, ce qui va à l'encontre de l'objectif.
  • Si vous ne le faites pas, ce n'est pas une ZipCodeclasse mais une Addressclasse, qui contiendra à nouveau une string ZipCodequi signifie que nous avons bouclé la boucle.

Par exemple, je peux parler à un analyste commercial d'un code postal au lieu d'une chaîne qui contient un code postal.

L'avantage est que vous pouvez en parler lors de la description du modèle de domaine.

Je ne comprends pas votre affirmation sous-jacente selon laquelle lorsqu'un élément d'information a un type de variable donné, vous êtes en quelque sorte obligé de mentionner ce type chaque fois que vous parlez à un analyste commercial.

Pourquoi? Pourquoi ne pouvez-vous pas simplement parler du "code postal" et omettre complètement le type spécifique? Quel genre de discussions avez-vous avec votre analyste commercial (pas technique!) Où le type de propriété est la quintessence de la conversation?

D'où je viens, les codes postaux sont toujours numériques. Nous avons donc le choix, nous pourrions le stocker comme un intou comme un string. Nous avons tendance à utiliser une chaîne car il n'y a aucune attente d'opérations mathématiques sur les données, mais jamais un analyste métier ne m'a dit que cela devait être une chaîne. Cette décision est laissée au développeur (ou sans doute à l'analyste technique, bien que, selon mon expérience, ils ne traitent pas directement avec le vif du sujet).

Un analyste métier ne se soucie pas du type de données, tant que l'application fait ce qu'elle est censée faire.


La validation est une bête délicate à affronter, car elle repose sur ce que les humains attendent.

D'une part, je ne suis pas d'accord avec l'argument de validation comme moyen de montrer pourquoi l'obsession primitive devrait être évitée, car je ne suis pas d'accord que (en tant que vérité universelle) les données doivent toujours être validées à tout moment.

Par exemple, que se passe-t-il s'il s'agit d'une recherche plus compliquée? Plutôt qu'une simple vérification de format, que se passe-t-il si votre validation implique de contacter une API externe et d'attendre une réponse? Voulez-vous vraiment forcer votre application à appeler cette API externe pour chaque ZipCodeobjet que vous instanciez?
C'est peut-être une exigence commerciale stricte, et alors cela est bien sûr justifiable. Mais ce n'est pas une vérité universelle. Il y aura de nombreux cas d'utilisation où cela représente plus un fardeau qu'une solution.

Comme deuxième exemple, lorsque vous entrez votre adresse dans un formulaire, il est courant de saisir votre code postal avant votre pays. Bien qu'il soit agréable d'avoir un retour de validation immédiat dans l'interface utilisateur, ce serait en fait un obstacle pour moi (en tant qu'utilisateur) si l'application m'avertissait d'un "mauvais" format de code postal, car la véritable source du problème est (par exemple) que mon pays n'est pas le pays sélectionné par défaut, et donc la validation s'est produite pour le mauvais pays.
C'est le mauvais message d'erreur, qui distrait l'utilisateur et provoque une confusion inutile.

Tout comme la validation perpétuelle n'est pas une vérité universelle, mes exemples ne le sont pas non plus. C'est contextuel . Certains domaines d'application nécessitent avant tout la validation des données. D'autres domaines ne placent pas la validation aussi haut dans la liste des priorités parce que les tracas qu'ils entraînent entrent en conflit avec leurs priorités réelles (par exemple, l'expérience utilisateur ou la capacité de stocker initialement des données erronées afin qu'elles puissent être corrigées au lieu de ne jamais les autoriser). stocké)

Date de naissance: vérifiez que la date est supérieure à celle indiquée et inférieure à la date d'aujourd'hui.
Salaire: Vérifiez que supérieur ou égal à zéro.

Le problème avec ces validations est qu'elles sont incomplètes, redondantes ou indiquent un problème beaucoup plus important .

Vérifier qu'une date est supérieure à la mentalité est redondant. La mentalité signifie littéralement que c'est la date la plus petite possible. D'ailleurs, où tracez-vous la ligne de pertinence? Quel est l'intérêt d'empêcher DateTime.MinDatemais d'autoriser DateTime.MinDate.AddSeconds(1)? Vous choisissez une valeur particulière qui n'est pas particulièrement fausse par rapport à de nombreuses autres valeurs.

Mon anniversaire est le 2 janvier 1978 (ce n'est pas le cas, mais supposons que ce soit le cas). Mais disons que les données de votre application sont fausses, et à la place, il est dit que mon anniversaire est:

  • 1er janvier 1978
  • 1 janv.1722
  • 1er janv.2355

Toutes ces dates sont fausses. Aucun d'eux n'est "plus juste" que l'autre. Mais votre règle de validation ne prendrait que l' un de ces trois exemples.

Vous avez également complètement omis le contexte de la façon dont vous utilisez ces données. Si cela est utilisé par exemple dans un bot de rappel d'anniversaire, je dirais que la validation est inutile car il n'y a pas de mauvaise conséquence particulière à remplir une mauvaise date.
D'un autre côté, s'il s'agit de données gouvernementales et que vous avez besoin de la date de naissance pour authentifier l'identité d'une personne (et si vous ne le faites pas, cela entraîne de graves conséquences, par exemple le refus de la sécurité sociale à quelqu'un), alors l'exactitude des données est primordiale et vous devez pleinement valider les données. La validation proposée que vous avez maintenant n'est pas adéquate.

Pour un salaire, il y a du bon sens en ce sens qu'il ne peut pas être négatif. Mais si vous vous attendez de manière réaliste à ce que des données absurdes soient entrées, je vous suggère de rechercher la source de ces données absurdes. Parce que s'ils ne peuvent pas leur faire confiance pour entrer des données sensibles, vous ne pouvez pas non plus leur faire confiance pour entrer des données correctes .

Si au lieu de cela le salaire est calculé par votre application, et qu'il est possible de se retrouver avec un nombre négatif (et correct)), une meilleure approche serait Math.Max(myValue, 0)de transformer les nombres négatifs en 0, plutôt que de ne pas échouer la validation. Parce que si votre logique a décidé que le résultat est un nombre négatif, l'échec de la validation signifie qu'il devra refaire le calcul, et il n'y a aucune raison de penser qu'il fournira un nombre différent la deuxième fois.
Et s'il arrive avec un nombre différent, cela vous amène à nouveau à soupçonner que le calcul n'est pas cohérent et ne peut donc pas être fiable.

Cela ne veut pas dire que la validation n'est pas utile. Mais la validation inutile est mauvaise, à la fois parce qu'elle demande des efforts sans vraiment résoudre un problème et donne aux gens un faux sentiment de sécurité.

Flater
la source
La date de naissance d'une personne peut en fait dépasser la date actuelle, si un bébé naît en ce moment dans un fuseau horaire qui a déjà été ignoré le lendemain. Et un hôpital pourrait enregistrer les «dates de naissance attendues» dans une base de données qui pourrait prendre des mois dans le futur. Souhaitez-vous un type différent pour cela?
gnasher729
@ gnasher729: Je ne suis pas sûr de suivre, il semble que vous soyez d'accord avec moi (la validation est contextuelle et pas universellement correcte), mais la formulation de votre commentaire suggère que vous pensez que je suis en désaccord. Ou suis-je en train de mal lire?
Flater