Validación de contexto en diseño controlado por dominio

Publicado: 2022-03-11

El diseño dirigido por dominios (DDD en resumen) no es una tecnología o una metodología. DDD proporciona una estructura de prácticas y terminología para tomar decisiones de diseño que enfocan y aceleran proyectos de software que se ocupan de dominios complicados. Tal como lo describen Eric Evans y Martin Fowler, los objetos de dominio son un lugar para colocar reglas de validación y lógica comercial.

Erick Evans:

Capa de Dominio (o Capa de Modelo): Responsable de representar conceptos del negocio, información sobre la situación del negocio y reglas de negocio. Aquí se controla y utiliza el estado que refleja la situación empresarial, aunque los detalles técnicos de almacenamiento se deleguen a la infraestructura. Esta capa es el corazón del software empresarial.

Martín Fowler:

La lógica que debe estar en un objeto de dominio es lógica de dominio: validaciones, cálculos, reglas comerciales, como quiera llamarlo.

Validación de contexto en diseño controlado por dominio

Poner toda la validación en objetos de dominio da como resultado objetos de dominio enormes y complejos con los que trabajar. Personalmente, prefiero la idea de desacoplar las validaciones de dominio en componentes de validación separados que se pueden reutilizar en cualquier momento y que se basarán en el contexto y la acción del usuario.

Como escribió Martin Fowler en un gran artículo: ContextualValidation.

Una cosa común que veo que hace la gente es desarrollar rutinas de validación para objetos. Estas rutinas vienen de varias maneras, pueden estar en el objeto o ser externas, pueden devolver un valor booleano o lanzar una excepción para indicar una falla. Una cosa que creo que constantemente hace tropezar a la gente es cuando piensan en la validez del objeto de una manera independiente del contexto, como implica un método isValid. […] Creo que es mucho más útil pensar en la validación como algo que está ligado a un contexto, típicamente una acción que quieres hacer. Por ejemplo, preguntar si este pedido es válido para completarse o si este cliente es válido para registrarse en el hotel. Entonces, en lugar de tener métodos como isValid, tenga métodos como isValidForCheckIn.

Propuesta de Validación de Acción

En este artículo, implementaremos una interfaz simple ItemValidator para la cual debe implementar un método de validación con el tipo de devolución ValidationResult . ValidationResult es un objeto que contiene el elemento que ha sido validado y también el objeto Mensajes . Este último contiene una acumulación de errores, advertencias y estados de validación de información (mensajes) que dependen del contexto de ejecución.

Los validadores son componentes desacoplados que se pueden reutilizar fácilmente en cualquier lugar donde se necesiten. Con este enfoque, todas las dependencias que se necesitan para las comprobaciones de validación se pueden inyectar fácilmente. Por ejemplo, para verificar en la base de datos si hay un usuario con un correo electrónico dado, solo se usa UserDomainService.

El desacoplamiento de los validadores será por contexto (acción). Por lo tanto, si la acción UserCreate y la acción UserUpdate tendrán componentes desacoplados o cualquier otra acción (UserActivate, UserDelete, AdCampaignLaunch, etc.), la validación puede crecer rápidamente.

Cada validador de acción debe tener un modelo de acción correspondiente que tendrá solo los campos de acción permitidos. Para la creación de usuarios, se necesitan los siguientes campos:

UsuarioCrearModelo:

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

Y para actualizar el usuario, se permiten los siguientes externalId , firstName y lastName . externalId se utiliza para la identificación del usuario y solo se permite cambiar el nombre y el apellido.

Modelo de actualización de usuario:

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

Las validaciones de integridad de campo se pueden compartir, la longitud máxima de firstName siempre es de 255 caracteres.

Durante la validación, es deseable no solo obtener el primer error que se produce, sino también una lista de todos los problemas encontrados. Por ejemplo, los siguientes 3 problemas pueden ocurrir al mismo tiempo y se pueden informar en consecuencia durante la ejecución:

  • formato de dirección no válido [ERROR]
  • el correo electrónico debe ser único entre los usuarios [ERROR]
  • contraseña demasiado corta [ERROR]

Para lograr ese tipo de validación, se necesita algo así como un generador de estado de validación, y para ese propósito se introduce Mensajes . Mensajes es un concepto que escuché de mi gran mentor hace años cuando lo presentó para respaldar la validación y también para varias otras cosas que se pueden hacer con él, ya que los mensajes no son solo para validación.

Tenga en cuenta que en las siguientes secciones usaremos Scala para ilustrar la implementación. En caso de que no sea un experto en Scala, no tema, ya que debería ser fácil de seguir.

Validación de mensajes en contexto

Mensajes es un objeto que representa el generador de estado de validación. Proporciona una manera fácil de recopilar errores, advertencias y mensajes de información durante la validación. Cada objeto Messages tiene una colección interna de objetos Message y también puede tener una referencia al objeto parentMessages .

Un objeto Message es un objeto que puede tener type , messageText , key (que es opcional y se usa para respaldar la validación de entradas específicas que se identifican por identificador) y, finalmente, childMessages que proporciona una excelente manera de crear árboles de mensajes componibles.

Un mensaje puede ser de uno de los siguientes tipos:

  • Información
  • Advertencia
  • Error

Los mensajes estructurados de esta manera nos permiten construirlos de forma iterativa y también permiten tomar decisiones sobre las próximas acciones en función del estado de los mensajes anteriores. Por ejemplo, al realizar la validación durante la creación del usuario:

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

Al examinar este código, puede ver el uso de ValidateUtils. Estas funciones de utilidad se utilizan para completar el objeto Mensajes en casos predefinidos. Puede consultar la implementación de ValidateUtils en el código de Github.

Durante la validación del correo electrónico, primero se verifica si el correo electrónico es válido llamando a ValidateUtils.validateEmail(… , y también se verifica si el correo electrónico tiene una longitud válida llamando a ValidateUtils.validateLengthIsLessThanOrEqual(… . Una vez realizadas estas dos validaciones, verificando si el correo electrónico ya está asignado a algún usuario, solo si las condiciones de validación de correo electrónico anteriores están pasando y eso se hace con if(!localMessages.hasErrors()) { … . De esta manera, se pueden evitar costosas llamadas a la base de datos. Esto es solo una parte de UserCreateValidator El código fuente completo se puede encontrar aquí.

Observe que destaca uno de los parámetros de validación: UserCreateEntity.EMAIL_FORM_ID . Este parámetro conecta el estado de validación con un ID de entrada específico.

En ejemplos anteriores, la siguiente acción se decide en función del hecho de que el objeto Mensajes tenga errores (usando el método hasErrors). Uno puede verificar fácilmente si hay algún mensaje de "ADVERTENCIA" y volver a intentarlo si es necesario.

Una cosa que se puede notar es la forma en que se usa localMessages . Los mensajes locales son mensajes que se han creado igual que cualquier mensaje, pero con parentMessages. Dicho esto, el objetivo es tener una referencia solo al estado de validación actual (en este ejemplo, emailValidation), por lo que se puede llamar a localMessages.hasErrors , donde se verifica solo si el contexto de emailValidation tiene errores. Además, cuando se agrega un mensaje a localMessages, también se agrega a parentMessages, por lo que todos los mensajes de validación existen en un contexto superior de UserCreateValidation.

Ahora que hemos visto Mensajes en acción, en el próximo capítulo nos centraremos en ItemValidator.

ItemValidator - Componente de validación reutilizable

ItemValidator es un rasgo simple (interfaz) que obliga a los desarrolladores a implementar el método de validación , que debe devolver ValidationResult.

ItemValidator:

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

Resultado de la Validación:

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

Cuando se implementan ItemValidators como UserCreateValidator para que sean componentes de inyección de dependencia, los objetos ItemValidator se pueden inyectar y reutilizar en cualquier objeto que necesite la validación de la acción UserCreate.

Después de ejecutar la validación, se comprueba si la validación fue exitosa. Si es así, los datos del usuario se conservan en la base de datos, pero si no, se devuelve la respuesta de la API que contiene errores de validación.

En la siguiente sección, veremos cómo podemos presentar errores de validación en la respuesta de la API RESTful y también cómo comunicarnos con los consumidores de la API sobre los estados de acción de ejecución.

Respuesta de API unificada: interacción de usuario simple

Después de que la acción del usuario se haya validado con éxito, en nuestro caso, la creación del usuario, los resultados de la acción de validación deben mostrarse al consumidor de la API RESTful. La mejor manera es tener una respuesta API unificada donde solo se cambiará el contexto (en términos de JSON, valor de "datos"). Con respuestas unificadas, los errores se pueden presentar fácilmente a los consumidores de API RESTful.

Estructura de respuesta unificada:

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

La respuesta unificada está estructurada para tener dos niveles de mensajes, global y local. Los mensajes locales son mensajes que se acoplan a entradas específicas. Por ejemplo, "el nombre de usuario es demasiado largo, se permiten como máximo 80 caracteres"_. Los mensajes globales son mensajes que reflejan el estado de todos los datos en la página, como "el usuario no estará activo hasta que se apruebe". Los mensajes locales y globales tienen tres niveles: error, advertencia e información. El valor de "datos" es específico del contexto. Al crear usuarios, el campo de datos contendrá datos de usuario, pero al obtener una lista de usuarios, el campo de datos será una matriz de usuarios.

Con este tipo de respuesta estructurada, se puede crear fácilmente el controlador de la interfaz de usuario del cliente, que será responsable de mostrar errores, advertencias y mensajes de información. Los mensajes globales se mostrarán en la parte superior de la página, porque están relacionados con el estado de acción de la API global, y los mensajes locales se pueden mostrar cerca de la entrada (campo) especificada, ya que están directamente relacionados con el valor del campo. Los mensajes de error se pueden presentar en color rojo, los mensajes de advertencia en amarillo y la información en azul.

Por ejemplo, en una aplicación de cliente basada en AngularJS, podemos tener dos directivas responsables de manejar los mensajes de respuesta locales y globales, de modo que solo estos dos controladores puedan manejar todas las respuestas de manera coherente.

La directiva para el mensaje local deberá aplicarse a un elemento principal del elemento real que contiene todos los mensajes.

mensajes locales.directiva.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 directiva para mensajes globales se incluirá en el documento de diseño raíz (index.html) y se registrará en un evento para manejar todos los mensajes globales.

mensajes globales.directiva.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); }); } } } } } } })();

Para un ejemplo más completo, consideremos la siguiente respuesta que contiene un mensaje 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 respuesta anterior puede conducir a algo como lo siguiente:

Por otro lado, con un mensaje global en respuesta:

 { "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]" } }

La aplicación del cliente ahora puede mostrar el mensaje con más prominencia:

En los ejemplos anteriores, se puede ver cómo se puede manejar una estructura de respuesta unificada para cualquier solicitud con el mismo controlador.

Conclusión

La aplicación de la validación en proyectos grandes puede volverse confusa y las reglas de validación se pueden encontrar en todas partes del código del proyecto. Mantener la validación consistente y bien estructurada hace que las cosas sean más fáciles y reutilizables.

Puede encontrar estas ideas implementadas en dos versiones diferentes de repeticiones a continuación:

  • Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Inyección de dependencia de primavera
  • Play 2.4 reactivo sin bloqueo, Scala 2.11.7, Slick 3.0, Postgres 9.4, inyección de dependencia Guice

En este artículo, presenté mis sugerencias sobre cómo admitir una validación de contexto componible profunda que se puede presentar fácilmente a un usuario. Espero que esto lo ayude a resolver los desafíos de la validación adecuada y el manejo de errores de una vez por todas. Por favor, siéntase libre de dejar sus comentarios y compartir sus pensamientos a continuación.