Validation de contexte dans la conception pilotée par le domaine

Publié: 2022-03-11

La conception pilotée par domaine (DDD en abrégé) n'est pas une technologie ou une méthodologie. DDD fournit une structure de pratiques et de terminologie pour prendre des décisions de conception qui ciblent et accélèrent les projets logiciels traitant de domaines complexes. Comme décrit par Eric Evans et Martin Fowler, les objets de domaine sont un endroit où placer les règles de validation et la logique métier.

Éric Evans :

Couche de domaine (ou couche de modèle) : responsable de la représentation des concepts de l'entreprise, des informations sur la situation de l'entreprise et des règles commerciales. L'état qui reflète la situation de l'entreprise est contrôlé et utilisé ici, même si les détails techniques de son stockage sont délégués à l'infrastructure. Cette couche est le cœur du logiciel métier.

Martin Fowler :

La logique qui devrait être dans un objet de domaine est la logique de domaine - validations, calculs, règles métier - comme vous voulez l'appeler.

Validation de contexte dans la conception pilotée par le domaine

Mettre toute la validation dans des objets de domaine entraîne des objets de domaine énormes et complexes avec lesquels travailler. Personnellement, je préfère de loin l'idée de découpler les validations de domaine en composants de validation distincts qui peuvent être réutilisés à tout moment et qui seront basés sur le contexte et l'action de l'utilisateur.

Comme Martin Fowler l'a écrit dans un excellent article : ContextualValidation.

Une chose courante que je vois faire est de développer des routines de validation pour les objets. Ces routines se présentent sous différentes formes, elles peuvent être dans l'objet ou externes, elles peuvent retourner un booléen ou lancer une exception pour indiquer un échec. Une chose qui, à mon avis, fait constamment trébucher les gens, c'est lorsqu'ils pensent à la validité d'objet d'une manière indépendante du contexte, comme l'implique une méthode isValid. […] Je pense qu'il est beaucoup plus utile de penser à la validation comme quelque chose qui est lié à un contexte, généralement une action que vous voulez faire. Par exemple, demander si cette commande est valide pour être remplie, ou si ce client est valide pour s'enregistrer à l'hôtel. Donc, plutôt que d'avoir des méthodes comme isValid, ayez des méthodes comme isValidForCheckIn.

Proposition de validation d'action

Dans cet article, nous allons implémenter une interface simple ItemValidator pour laquelle vous devez implémenter une méthode de validation avec le type de retour ValidationResult . ValidationResult est un objet contenant l'élément qui a été validé ainsi que l'objet Messages . Ce dernier contient une accumulation d'erreurs, d'avertissements et d'états de validation d'informations (messages) dépendant du contexte d'exécution.

Les validateurs sont des composants découplés qui peuvent être facilement réutilisés partout où ils sont nécessaires. Avec cette approche, toutes les dépendances nécessaires aux contrôles de validation peuvent être facilement injectées. Par exemple, pour vérifier dans la base de données s'il existe un utilisateur avec une adresse e-mail donnée, seul UserDomainService est utilisé.

Le découplage des validateurs se fera par contexte (action). Ainsi, si l'action UserCreate et l'action UserUpdate auront des composants découplés ou toute autre action (UserActivate, UserDelete, AdCampaignLaunch, etc.), la validation peut rapidement croître.

Chaque validateur d'action doit avoir un modèle d'action correspondant qui n'aura que les champs d'action autorisés. Pour créer des utilisateurs, les champs suivants sont nécessaires :

UserCreateModel :

 { "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" }

Et pour mettre à jour l'utilisateur, les éléments suivants sont autorisés externalId , firstName et lastName . externalId est utilisé pour l'identification de l'utilisateur et seule la modification de firstName et lastName est autorisée.

Modèle de mise à jour utilisateur :

 { "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }

Les validations d'intégrité des champs peuvent être partagées, la longueur maximale du prénom est toujours de 255 caractères.

Lors de la validation, il est souhaitable non seulement d'obtenir la première erreur qui se produit, mais aussi une liste de tous les problèmes rencontrés. Par exemple, les 3 problèmes suivants peuvent se produire en même temps et peuvent être signalés en conséquence lors de l'exécution :

  • format d'adresse invalide [ERREUR]
  • l'e-mail doit être unique parmi les utilisateurs [ERREUR]
  • mot de passe trop court [ERROR]

Pour réaliser ce type de validation, quelque chose comme un générateur d'état de validation est nécessaire, et à cette fin, Messages est introduit. Les messages sont un concept que j'ai entendu de mon grand mentor il y a des années lorsqu'il l'a introduit pour soutenir la validation et aussi pour diverses autres choses qui peuvent être faites avec, car les messages ne sont pas seulement pour la validation.

Notez que dans les sections suivantes, nous utiliserons Scala pour illustrer l'implémentation. Juste au cas où vous ne seriez pas un expert de Scala, ne craignez rien car il devrait néanmoins être facile à suivre.

Messages dans la validation de contexte

Messages est un objet qui représente le générateur d'état de validation. Il fournit un moyen simple de collecter les erreurs, les avertissements et les messages d'information lors de la validation. Chaque objet Messages possède une collection interne d'objets Message et peut également avoir une référence à l'objet parentMessages .

Un objet Message est un objet qui peut avoir type , messageText , key (qui est facultatif et est utilisé pour prendre en charge la validation d'entrées spécifiques identifiées par identifiant) et enfin childMessages qui constitue un excellent moyen de créer des arborescences de messages composables.

Un message peut être de l'un des types suivants :

  • Information
  • Avertissement
  • Erreur

Les messages structurés de cette manière nous permettent de les construire de manière itérative et permettent également de prendre des décisions sur les prochaines actions en fonction de l'état des messages précédents. Par exemple, effectuer une validation lors de la création d'un utilisateur :

 @Component class UserCreateValidator @Autowired (private val entityDomainService: UserDomainService) extends ItemValidator[UserCreateEntity] { Asserts.argumentIsNotNull(entityDomainService) private val MAX_ALLOWED_LENGTH = 80 private val MAX_ALLOWED_CHARACTER_ERROR = s"must be less than or equal to $MAX_ALLOWED_LENGTH character" override def validate(item: UserCreateEntity): ValidationResult[UserCreateEntity] = { Asserts.argumentIsNotNull(item) val validationMessages = Messages.of validateFirstName (item, validationMessages) validateLastName (item, validationMessages) validateEmail (item, validationMessages) validateUserName (item, validationMessages) validatePassword (item, validationMessages) ValidationResult( validatedItem = item, messages = validationMessages ) } private def validateFirstName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.firstName ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.FIRST_NAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } private def validateLastName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.lastName ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.LAST_NAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } private def validateEmail(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.email ValidateUtils.validateEmail( fieldValue, UserCreateEntity.EMAIL_FORM_ID, localMessages ) ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.EMAIL_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) if(!localMessages.hasErrors()) { val doesExistWithEmail = this.entityDomainService.doesExistByByEmail(fieldValue) ValidateUtils.isFalse( doesExistWithEmail, localMessages, UserCreateEntity.EMAIL_FORM_ID.value, "User already exists with this email" ) } } private def validateUserName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.username ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.USERNAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) if(!localMessages.hasErrors()) { val doesExistWithUsername = this.entityDomainService.doesExistByUsername(fieldValue) ValidateUtils.isFalse( doesExistWithUsername, localMessages, UserCreateEntity.USERNAME_FORM_ID.value, "User already exists with this username" ) } } private def validatePassword(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.password ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.PASSWORD_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } }

En regardant dans ce code, vous pouvez voir l'utilisation de ValidateUtils. Ces fonctions utilitaires permettent de renseigner l'objet Messages dans des cas prédéfinis. Vous pouvez consulter l'implémentation de ValidateUtils sur le code Github.

Lors de la validation de l'e-mail, il est d'abord vérifié si l'e-mail est valide en appelant ValidateUtils.validateEmail(… , et il est également vérifié si l'e-mail a une longueur valide en appelant ValidateUtils.validateLengthIsLessThanOrEqual(… . Une fois ces deux validations effectuées, vérifier si l'e-mail est déjà attribué à un utilisateur est effectuée, uniquement si les conditions de validation de l'e-mail préalable sont remplies et cela se fait avec if(!localMessages.hasErrors()) { … .De cette façon, des appels de base de données coûteux peuvent être évités.Ce n'est qu'une partie de UserCreateValidator Le code source complet peut être trouvé ici.

Notez que l'un des paramètres de validation ressort : UserCreateEntity.EMAIL_FORM_ID . Ce paramètre connecte l'état de validation à un ID d'entrée spécifique.

Dans les exemples précédents, l'action suivante est décidée en fonction du fait que l'objet Messages contient des erreurs (à l'aide de la méthode hasErrors). On peut facilement vérifier s'il y a des messages "WARNING" et réessayer si nécessaire.

Une chose qui peut être remarquée est la façon dont localMessages est utilisé. Les messages locaux sont des messages qui ont été créés de la même manière que n'importe quel message, mais avec parentMessages. Cela dit, le but est d'avoir une référence uniquement à l'état de validation actuel (dans cet exemple emailValidation), donc localMessages.hasErrors peut être appelé, où il est vérifié uniquement si le contexte emailValidation hasErrors. De plus, lorsqu'un message est ajouté à localMessages, il est également ajouté à parentMessages et ainsi tous les messages de validation existent dans un contexte supérieur de UserCreateValidation.

Maintenant que nous avons vu Messages en action, dans le chapitre suivant, nous nous concentrerons sur ItemValidator.

ItemValidator - Composant de validation réutilisable

ItemValidator est un trait simple (interface) qui oblige les développeurs à implémenter la méthode validate , qui doit renvoyer ValidationResult.

ItemValidator :

 trait ItemValidator[T] { def validate(item:T) : ValidationResult[T] }

Résultat de la validation :

 case class ValidationResult[T: Writes]( validatedItem : T, messages : Messages ) { Asserts.argumentIsNotNull(validatedItem) Asserts.argumentIsNotNull(messages) def isValid :Boolean = { !messages.hasErrors } def errorsRestResponse = { Asserts.argumentIsTrue(!this.isValid) ResponseTools.of( data = Some(this.validatedItem), messages = Some(messages) ) } }

Lorsque ItemValidators tels que UserCreateValidator sont implémentés pour être des composants d'injection de dépendances, les objets ItemValidator peuvent être injectés et réutilisés dans tout objet nécessitant une validation d'action UserCreate.

Une fois la validation exécutée, il est vérifié si la validation a réussi. Si c'est le cas, les données utilisateur sont conservées dans la base de données, mais si ce n'est pas le cas, la réponse de l'API contenant les erreurs de validation est renvoyée.

Dans la section suivante, nous verrons comment nous pouvons présenter les erreurs de validation dans la réponse de l'API RESTful et également comment communiquer avec les consommateurs d'API sur les états d'action d'exécution.

Réponse API unifiée - Interaction utilisateur simple

Une fois l'action de l'utilisateur validée avec succès, dans notre cas de création d'utilisateur, les résultats de l'action de validation doivent être affichés pour le consommateur d'API RESTful. Le meilleur moyen est d'avoir une réponse API unifiée où seul le contexte sera changé (en termes de JSON, valeur de "données"). Avec des réponses unifiées, les erreurs peuvent être présentées facilement aux consommateurs d'API RESTful.

Structure de réponse unifiée :

 { "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [] }, "data":{} }

La réponse unifiée est structurée pour avoir deux niveaux de messages, global et local. Les messages locaux sont des messages couplés à des entrées spécifiques. Comme "le nom d'utilisateur est trop long, au plus 80 caractères sont autorisés"_. Les messages globaux sont des messages qui reflètent l'état de toutes les données sur la page, tels que "l'utilisateur ne sera pas actif tant qu'il n'aura pas été approuvé". Les messages locaux et globaux ont trois niveaux : erreur, avertissement et information. La valeur de « données » est spécifique au contexte. Lors de la création d'utilisateurs, le champ de données contiendra des données utilisateur, mais lors de l'obtention d'une liste d'utilisateurs, le champ de données sera un tableau d'utilisateurs.

Avec ce type de réponse structurée, le gestionnaire d'interface utilisateur client peut être créé facilement, qui sera responsable de l'affichage des erreurs, des avertissements et des messages d'information. Les messages globaux seront affichés en haut de la page, car ils sont liés à l'état d'action de l'API globale, et les messages locaux peuvent être affichés près de l'entrée spécifiée (champ), car ils sont directement liés à la valeur du champ. Les messages d'erreur peuvent être présentés en rouge, les messages d'avertissement en jaune et les informations en bleu.

Par exemple, dans une application cliente basée sur AngularJS, nous pouvons avoir deux directives chargées de gérer les messages de réponse locaux et globaux, de sorte que seuls ces deux gestionnaires peuvent traiter toutes les réponses de manière cohérente.

La directive pour le message local devra être appliquée à un élément parent de l'élément réel contenant tous les messages.

localmessages.directive.js :

 (function() { 'use strict'; angular .module('reactiveClient') .directive('localMessagesValidationDirective', localMessagesValidationDirective); /** @ngInject */ function localMessagesValidationDirective(_) { return { restrict: 'AE', transclude: true, scope: { binder: '=' }, template: '<div ng-transclude></div>', link: function (scope, element) { var messagesWatchCleanUp = scope.$watch('binder', messagesBinderWatchCallback); scope.$on('$destroy', function() { messagesWatchCleanUp(); }); function messagesBinderWatchCallback (messagesResponse) { if (messagesResponse != undefined && messagesResponse.messages != undefined) { if (messagesResponse.messages.local.length > 0) { element.find('.alert').remove(); _.forEach(messagesResponse.messages.local, function (localMsg) { var selector = element.find('[]').parent(); _.forEach(localMsg.info, function (msg) { var infoMsg = '<div class="form-control validation-alert alert alert-info alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(infoMsg); }); _.forEach(localMsg.warnings, function (msg) { var warningMsg = '<div class="form-control validation-alert alert alert-warning alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(warningMsg); }); _.forEach(localMsg.errors, function (msg) { var errorMsg = '<div class="form-control validation-alert alert alert-danger alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(errorMsg); }); }); } } } } } } })();

La directive pour les messages globaux sera incluse dans le document de mise en page racine (index.html) et s'enregistrera dans un événement pour gérer tous les messages globaux.

globalmessages.directive.js :

 (function() { 'use strict'; angular .module('reactiveClient') .directive('globalMessagesValidationDirective', globalMessagesValidationDirective); /** @ngInject */ function globalMessagesValidationDirective(_, toastr, $rootScope, $log) { return { restrict: 'AE', link: function (scope) { var cleanUpListener = $rootScope.$on('globalMessages', globalMessagesWatchCallback); scope.$on('$destroy', function() { cleanUpListener(); }); function globalMessagesWatchCallback (event, messagesResponse) { $log.log('received rootScope event: ' + event); if (messagesResponse != undefined && messagesResponse.messages != undefined) { if (messagesResponse.messages.global != undefined) { _.forEach(messagesResponse.messages.global.info, function (msg) { toastr.info(msg); }); _.forEach(messagesResponse.messages.global.warnings, function (msg) { toastr.warning(msg); }); _.forEach(messagesResponse.messages.global.errors, function (msg) { toastr.error(msg); }); } } } } } } })();

Pour un exemple plus complet, considérons la réponse suivante contenant un message local :

 { "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [ { "inputId" : "email", "errors" : ["User already exists with this email"], "warnings" : [], "info" : [] } ] }, "data":{ "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" } }

La réponse ci-dessus peut conduire à quelque chose comme suit :

D'autre part, avec un message global en réponse :

 { "messages" : { "global" : { "info": ["User successfully created."], "warnings": ["User will not be available for login until is activated"], "errors": [] }, "local" : [] }, "data":{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John", "lastName": "Doe", "email": "[email protected]" } }

L'application cliente peut désormais afficher le message avec plus d'importance :

Dans les exemples ci-dessus, on peut voir comment une structure de réponse unifiée peut être gérée pour toute demande avec le même gestionnaire.

Conclusion

L'application de la validation sur de grands projets peut devenir déroutante, et les règles de validation peuvent être trouvées partout dans le code du projet. Garder la validation cohérente et bien structurée rend les choses plus faciles et réutilisables.

Vous pouvez trouver ces idées mises en œuvre dans deux versions différentes de passe-partout ci-dessous :

  • Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
  • Jeu réactif non bloquant 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice Dependency Injection

Dans cet article, j'ai présenté mes suggestions sur la manière de prendre en charge une validation contextuelle approfondie et composable qui peut être facilement présentée à un utilisateur. J'espère que cela vous aidera à résoudre une fois pour toutes les défis de la validation et de la gestion des erreurs. N'hésitez pas à laisser vos commentaires et à partager vos réflexions ci-dessous.