Validação de contexto no design orientado a domínio

Publicados: 2022-03-11

O design orientado a domínio (DDD em resumo) não é uma tecnologia ou uma metodologia. O DDD fornece uma estrutura de práticas e terminologia para tomar decisões de design que focam e aceleram projetos de software que lidam com domínios complicados. Conforme descrito por Eric Evans e Martin Fowler, os objetos Domain são um lugar para colocar regras de validação e lógica de negócios.

Eric Evans:

Camada de Domínio (ou Camada de Modelo): Responsável por representar conceitos do negócio, informações sobre a situação do negócio e regras de negócio. O estado que reflete a situação do negócio é controlado e usado aqui, embora os detalhes técnicos de armazenamento sejam delegados à infraestrutura. Essa camada é o coração do software de negócios.

Martin Fowler:

A lógica que deve estar em um objeto de domínio é a lógica de domínio - validações, cálculos, regras de negócios - como você quiser chamá-la.

Validação de contexto no design orientado a domínio

Colocar toda a validação em objetos de domínio resulta em objetos de domínio enormes e complexos para trabalhar. Pessoalmente, prefiro a ideia de dissociar as validações de domínio em componentes validadores separados que podem ser reutilizados a qualquer momento e que serão baseados no contexto e na ação do usuário.

Como Martin Fowler escreveu em um ótimo artigo: ContextualValidation.

Uma coisa comum que vejo as pessoas fazerem é desenvolver rotinas de validação para objetos. Essas rotinas vêm de várias maneiras, podem estar no objeto ou externo, podem retornar um booleano ou lançar uma exceção para indicar falha. Uma coisa que eu acho que constantemente engana as pessoas é quando elas pensam na validade do objeto de uma maneira independente do contexto, como um método isValid implica. […] Eu acho que é muito mais útil pensar em validação como algo que está ligado a um contexto, normalmente uma ação que você quer fazer. Como perguntar se este pedido é válido para ser preenchido, ou este cliente é válido para fazer check-in no hotel. Então, em vez de ter métodos como isValid, tenha métodos como isValidForCheckIn.

Proposta de validação de ação

Neste artigo vamos implementar uma interface simples ItemValidator para a qual você precisa implementar um método de validação com o tipo de retorno ValidationResult . ValidationResult é um objeto que contém o item que foi validado e também o objeto Messages . Este último contém um acúmulo de erros, avisos e estados de validação de informações (mensagens) dependentes do contexto de execução.

Os validadores são componentes desacoplados que podem ser facilmente reutilizados em qualquer lugar em que sejam necessários. Com essa abordagem, todas as dependências necessárias para verificações de validação podem ser facilmente injetadas. Por exemplo, para verificar no banco de dados se há um usuário com um determinado email, apenas UserDomainService é usado.

O desacoplamento dos validadores será por contexto (ação). Portanto, se a ação UserCreate e a ação UserUpdate tiverem componentes desacoplados ou qualquer outra ação (UserActivate, UserDelete, AdCampaignLaunch etc.), a validação pode crescer rapidamente.

Cada validador de ação deve ter um modelo de ação correspondente que terá apenas os campos de ação permitidos. Para criar usuários, são necessários os seguintes campos:

UserCreateModel:

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

E para atualizar o usuário são permitidos externalId , firstName e lastName . externalId é usado para identificação do usuário e apenas a alteração de firstName e lastName é permitida.

UserUpdateModel:

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

As validações de integridade de campo podem ser compartilhadas, o comprimento máximo de firstName é sempre de 255 caracteres.

Durante a validação, é desejável obter não apenas o primeiro erro que ocorrer, mas uma lista de todos os problemas encontrados. Por exemplo, os 3 problemas a seguir podem ocorrer ao mesmo tempo e podem ser relatados adequadamente durante a execução:

  • formato de endereço inválido [ERROR]
  • e-mail deve ser único entre os usuários [ERROR]
  • senha muito curta [ERROR]

Para obter esse tipo de validação, é necessário algo como o construtor de estado de validação e, para esse fim, o Messages é introduzido. Mensagens é um conceito que ouvi do meu grande mentor anos atrás quando ele o introduziu para dar suporte à validação e também para várias outras coisas que podem ser feitas com ele, pois as mensagens não são apenas para validação.

Observe que nas seções a seguir usaremos Scala para ilustrar a implementação. Caso você não seja um especialista em Scala, não tenha medo, pois deve ser fácil acompanhar.

Mensagens na validação de contexto

Messages é um objeto que representa o construtor de estado de validação. Ele fornece uma maneira fácil de coletar erros, avisos e mensagens de informação durante a validação. Cada objeto Messages tem uma coleção interna de objetos Message e também pode ter uma referência ao objeto parentMessages .

Um objeto Message é um objeto que pode ter type , messageText , key (que é opcional e é usado para dar suporte à validação de entradas específicas que são identificadas por identificador) e, finalmente, childMessages que fornece uma ótima maneira de construir árvores de mensagens compostas.

Uma mensagem pode ser de um dos seguintes tipos:

  • Em formação
  • Aviso
  • Erro

Mensagens estruturadas dessa forma nos permitem construí-las de forma iterativa e também permite que decisões sejam tomadas sobre as próximas ações com base no estado das mensagens anteriores. Por exemplo, realizando a validação durante a criação do usuário:

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

Olhando para este código, você pode ver o uso de ValidateUtils. Essas funções de utilitário são usadas para preencher o objeto Messages em casos predefinidos. Você pode conferir a implementação do ValidateUtils no código do Github.

Durante a validação do email, primeiro é verificado se o email é válido chamando ValidateUtils.validateEmail(… , e também é verificado se o email tem um comprimento válido chamando ValidateUtils.validateLengthIsLessThanOrEqual(… . Feitas essas duas validações, verificando se o email já está atribuído a algum usuário é executado, somente se as condições anteriores de validação de e-mail estiverem passando e isso é feito com if(!localMessages.hasErrors()) { … . Desta forma, chamadas de banco de dados caras podem ser evitadas. Isso é apenas parte do UserCreateValidator . O código fonte completo pode ser encontrado aqui.

Observe que um dos parâmetros de validação se destaca: UserCreateEntity.EMAIL_FORM_ID . Este parâmetro conecta o estado de validação a um ID de entrada específico.

Nos exemplos anteriores, a próxima ação é decidida com base no fato de que o objeto Messages possui erros (usando o método hasErrors). Pode-se verificar facilmente se existem mensagens de “AVISO” e tentar novamente, se necessário.

Uma coisa que pode ser notada é a forma como localMessages é usado. Mensagens locais são mensagens que foram criadas da mesma forma que qualquer mensagem, mas com parentMessages. Com isso dito, o objetivo é ter uma referência apenas ao estado de validação atual (neste exemplo emailValidation), então localMessages.hasErrors pode ser chamado, onde é verificado apenas se o contexto emailValidation tiverErrors. Além disso, quando uma mensagem é adicionada a localMessages, ela também é adicionada a parentMessages e, portanto, todas as mensagens de validação existem em um contexto superior de UserCreateValidation.

Agora que vimos o Messages em ação, no próximo capítulo focaremos no ItemValidator.

ItemValidator - Componente de validação reutilizável

ItemValidator é um trait simples (interface) que força os desenvolvedores a implementar o método validate , que precisa retornar ValidationResult.

Validador de itens:

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

Resultado da Validação:

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

Quando ItemValidators, como UserCreateValidator, são implementados para serem componentes de injeção de dependência, os objetos ItemValidator podem ser injetados e reutilizados em qualquer objeto que precise de validação de ação UserCreate.

Após a validação ser executada, é verificado se a validação foi bem sucedida. Se for, os dados do usuário serão persistidos no banco de dados, mas se não for, a resposta da API contendo erros de validação será retornada.

Na próxima seção veremos como podemos apresentar erros de validação na resposta da API RESTful e também como comunicar com os consumidores da API sobre os estados das ações de execução.

Resposta da API unificada - interação simples do usuário

Depois que a ação do usuário foi validada com sucesso, no nosso caso, a criação do usuário, os resultados da ação de validação precisam ser exibidos para o consumidor da API RESTful. A melhor maneira é ter uma resposta de API unificada onde apenas o contexto será trocado (em termos de JSON, valor de “dados”). Com respostas unificadas, os erros podem ser apresentados facilmente aos consumidores da API RESTful.

Estrutura de resposta unificada:

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

A resposta unificada é estruturada para ter duas camadas de mensagens, global e local. As mensagens locais são mensagens que são acopladas a entradas específicas. Como “nome de usuário é muito longo, no máximo 80 caracteres são permitidos”_. Mensagens globais são mensagens que refletem o estado de todos os dados na página, como “o usuário não estará ativo até que seja aprovado”. As mensagens locais e globais têm três níveis - erro, aviso e informação. O valor de “dados” é específico para o contexto. Ao criar usuários, o campo de dados conterá dados do usuário, mas ao obter uma lista de usuários, o campo de dados será uma matriz de usuários.

Com esse tipo de resposta estruturada, pode-se criar facilmente o manipulador de UI do cliente, que será responsável por exibir erros, avisos e mensagens informativas. As mensagens globais serão exibidas na parte superior da página, pois estão relacionadas ao estado de ação global da API, e as mensagens locais podem ser exibidas perto da entrada (campo) especificada, pois estão diretamente relacionadas ao valor do campo. As mensagens de erro podem ser apresentadas na cor vermelha, as mensagens de aviso em amarelo e as informações em azul.

Por exemplo, em um aplicativo cliente baseado em AngularJS, podemos ter duas diretivas responsáveis ​​por lidar com mensagens de resposta locais e globais, de modo que apenas esses dois manipuladores possam lidar com todas as respostas de maneira consistente.

A diretiva para a mensagem local precisará ser aplicada a um elemento pai do elemento real que contém todas as mensagens.

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

A diretiva para mensagens globais será incluída no documento de layout raiz (index.html) e será registrada em um evento para lidar com todas as mensagens globais.

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

Para um exemplo mais completo, consideremos a seguinte resposta contendo uma mensagem 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=" } }

A resposta acima pode levar a algo como:

Por outro lado, com uma mensagem global em resposta:

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

O aplicativo cliente agora pode mostrar a mensagem com mais destaque:

Nos exemplos acima, pode-se ver como uma estrutura de resposta unificada pode ser tratada para qualquer solicitação com o mesmo manipulador.

Conclusão

A aplicação de validação em grandes projetos pode se tornar confusa e as regras de validação podem ser encontradas em todo o código do projeto. Manter a validação consistente e bem estruturada torna as coisas mais fáceis e reutilizáveis.

Você pode encontrar essas ideias implementadas em duas versões diferentes de clichês abaixo:

  • Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
  • Reativo sem bloqueio Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice Dependency Injection

Neste artigo, apresentei minhas sugestões sobre como dar suporte à validação de contexto de composição profunda que pode ser facilmente apresentada a um usuário. Espero que isso ajude você a resolver os desafios de validação adequada e tratamento de erros de uma vez por todas. Por favor, sinta-se à vontade para deixar seus comentários e compartilhar seus pensamentos abaixo.