45 votes

Validation des domaines dans une architecture CQRS

Danger... Danger Dr. Smith... Poste philosophique à venir

L'objectif de cet article est de déterminer si le fait de placer la logique de validation en dehors de mes entités de domaine (racine d'agrégat en fait) m'apporte plus de flexibilité ou si c'est code kamikaze

En fait, je veux savoir s'il existe un meilleur moyen de valider mes entités de domaine. Voici comment je compte procéder, mais j'aimerais avoir votre avis.

La première approche que j'ai envisagée est la suivante :

class Customer : EntityBase<Customer>
{
   public void ChangeEmail(string email)
   {
      if(string.IsNullOrWhitespace(email))   throw new DomainException(“...”);
      if(!email.IsEmail())  throw new DomainException();
      if(email.Contains(“@mailinator.com”))  throw new DomainException();
   }
}

En fait, je n'aime pas cette validation parce que même si j'encapsule la logique de validation dans l'entité correcte, cela viole le principe d'ouverture/fermeture (ouvert pour l'extension mais fermé pour la modification) et j'ai constaté qu'en violant ce principe, la maintenance du code devient un véritable casse-tête lorsque l'application devient plus complexe. Pourquoi ? Parce que les règles du domaine changent plus souvent qu'on ne veut bien l'admettre, et si les règles sont caché et intégré dans une entité comme celle-ci, elles sont difficiles à tester, difficiles à lire, difficiles à maintenir mais la vraie raison pour laquelle je n'aime pas cette approche est la suivante : si les règles de validation changent, je dois venir modifier l'entité de mon domaine. Il s'agit d'un exemple très simple, mais la validation pourrait être plus complexe dans RL.

Donc en suivant la philosophie de Udi Dahan, rendre les rôles explicites et la recommandation d'Eric Evans dans le livre bleu, la tentative suivante a été d'implémenter le modèle de spécification, quelque chose comme ceci

class EmailDomainIsAllowedSpecification : IDomainSpecification<Customer>
{
   private INotAllowedEmailDomainsResolver invalidEmailDomainsResolver;
   public bool IsSatisfiedBy(Customer customer)
   {
      return !this.invalidEmailDomainsResolver.GetInvalidEmailDomains().Contains(customer.Email);
   }
}

Mais je me rends compte que pour suivre cette approche, je devais d'abord faire muter mes entités afin de passer l'étape de l'échange de données. valeur en cours d'évaluation dans ce cas, l'e-mail, mais leur mutation entraînerait le déclenchement d'événements relatifs à mon domaine, ce que je ne voudrais pas voir se produire avant que le nouvel e-mail ne soit valide.

Après avoir examiné ces approches, j'ai choisi celle-ci, puisque je vais mettre en œuvre une architecture CQRS :

class EmailDomainIsAllowedValidator : IDomainInvariantValidator<Customer, ChangeEmailCommand>
{
   public void IsValid(Customer entity, ChangeEmailCommand command)
   {
      if(!command.Email.HasValidDomain())  throw new DomainException(“...”);
   }
}

C'est l'idée principale, l'entité est transmise au validateur au cas où nous aurions besoin d'une valeur de l'entité pour effectuer la validation, la commande contient les données provenant de l'utilisateur et comme les validateurs sont considérés comme étant injectable ils peuvent avoir des dépendances externes injectées si la validation l'exige.

Maintenant, le dilemme Je suis satisfait d'une telle conception car ma validation est encapsulée dans des objets individuels, ce qui présente de nombreux avantages : test unitaire facile, maintenance facile, invariants de domaine explicitement exprimés à l'aide du langage omniprésent, extension facile, logique de validation centralisée et utilisation conjointe de validateurs pour appliquer des règles de domaine complexes. Et même si je sais que je place la validation de mes entités en dehors d'elles (on pourrait parler d'une odeur de code - domaine anémique), je pense que le compromis est acceptable.

Mais il y a une chose que je n'ai pas trouvée : comment l'implémenter de manière propre. Comment dois-je utiliser ces composants...

Comme ils seront injectés, ils ne s'inscriront pas naturellement dans les entités de mon domaine, et je vois donc deux options :

  1. Passer les validateurs à chaque méthode de mon entité

  2. Valider mes objets en externe (depuis le gestionnaire de commandes)

Je ne suis pas satisfait de l'option 1. Je vais donc expliquer comment je vais procéder avec l'option 2.

class ChangeEmailCommandHandler : ICommandHandler<ChangeEmailCommand>
{
   // here I would get the validators required for this command injected
   private IEnumerable<IDomainInvariantValidator> validators;
   public void Execute(ChangeEmailCommand command)
   {
      using (var t = this.unitOfWork.BeginTransaction())
      {
         var customer = this.unitOfWork.Get<Customer>(command.CustomerId);
         // here I would validate them, something like this
         this.validators.ForEach(x =. x.IsValid(customer, command));
         // here I know the command is valid
         // the call to ChangeEmail will fire domain events as needed
         customer.ChangeEmail(command.Email);
         t.Commit();
      }
   }
}

Voilà, c'est fait. Pouvez-vous me faire part de vos réflexions à ce sujet ou partager vos expériences avec la validation des entités du Domaine

EDIT

Je pense que ma question n'est pas claire, mais le vrai problème est le suivant : le fait de cacher les règles du domaine a de sérieuses implications sur la maintenabilité future de l'application, et les règles du domaine changent souvent au cours du cycle de vie de l'application. Par conséquent, les mettre en œuvre en gardant cela à l'esprit nous permettrait de les étendre facilement. Imaginons maintenant qu'un moteur de règles soit implémenté à l'avenir. Si les règles sont encapsulées en dehors des entités du domaine, ce changement sera plus facile à mettre en œuvre.

Je suis conscient que le fait de placer la validation en dehors de mes entités brise l'encapsulation comme @jgauffin l'a mentionné dans sa réponse, mais je pense que les avantages de placer la validation dans des objets individuels sont beaucoup plus substantiels que de simplement conserver l'encapsulation d'une entité. Maintenant, je pense que l'encapsulation a plus de sens dans une architecture traditionnelle n-tier parce que les entités ont été utilisées à plusieurs endroits de la couche de domaine, mais dans une architecture CQRS, quand une commande arrive, il y aura un gestionnaire de commande accédant à une racine agrégée et effectuant des opérations sur la racine agrégée, créant ainsi une fenêtre parfaite pour placer la validation.

J'aimerais faire une petite comparaison entre les avantages de placer la validation dans une entité et ceux de la placer dans des objets individuels.

  • Validation dans les objets individuels

    • Pro. Facile à écrire
    • Pro. Facile à tester
    • Pour. C'est explicitement exprimé
    • Pro. Il fait partie de la conception du domaine, exprimée par le langage omniprésent actuel.
    • Pro. Puisqu'il fait maintenant partie de la conception, il peut être modélisé à l'aide de diagrammes UML.
    • Pro. Extrêmement facile à entretenir
    • Pro. Cela rend mes entités et la logique de validation faiblement couplées.
    • Pro. Facile à étendre
    • Pro. Après l'ASR
    • Pro. Suivre le principe d'ouverture/fermeture
    • Pro. Ne pas enfreindre la loi de Déméter (mmm) ?
    • Pro. I'est centralisé
    • Pro. Il pourrait être réutilisable
    • Pro. Si nécessaire, des dépendances externes peuvent être facilement injectées.
    • Pro. Si vous utilisez un modèle de plug-in, vous pouvez ajouter de nouveaux validateurs en déposant simplement les nouveaux assemblages sans avoir à recompiler l'ensemble de l'application.
    • Pro. L'implémentation d'un moteur de règles serait plus facile
    • Con. Rupture de l'encapsulation
    • Con. Si l'encapsulation est obligatoire, nous devrions passer les validateurs individuels à la méthode d'entité (agrégat).
  • Validation encapsulée dans l'entité

    • Pro. Encapsulé ?
    • Pro. Réutilisable ?

J'aimerais lire vos réflexions à ce sujet

11voto

SonOfPirate Points 2021

Je suis d'accord avec un certain nombre de concepts présentés dans d'autres réponses, mais je les ai réunis dans mon code.

Tout d'abord, je suis d'accord pour dire que l'utilisation d'objets de valeur pour les valeurs qui incluent un comportement est un excellent moyen d'encapsuler des règles commerciales communes et une adresse électronique est un candidat parfait. Cependant, j'ai tendance à limiter cela aux règles qui sont constantes et qui ne changeront pas fréquemment. Je suis sûr que vous recherchez une approche plus générale et que l'adresse électronique n'est qu'un exemple, donc je ne me concentrerai pas sur ce seul cas d'utilisation.

La clé de mon approche est de reconnaître que la validation sert différents objectifs à différents endroits d'une application. En d'autres termes, il faut valider uniquement ce qui est nécessaire pour garantir que l'opération en cours peut s'exécuter sans résultats inattendus ou involontaires. Cela conduit à la question suivante : quelle validation doit être effectuée à quel endroit ?

Dans votre exemple, je me demanderais si l'entité domaine se soucie vraiment que l'adresse e-mail soit conforme à un certain modèle et à d'autres règles ou si nous nous soucions simplement que 'email' ne puisse pas être nul ou vide lorsque ChangeEmail est appelé ? Dans ce dernier cas, une simple vérification de la présence d'une valeur est suffisante dans la méthode ChangeEmail.

Dans CQRS, tous les changements qui modifient l'état de l'application se produisent en tant que commandes avec l'implémentation dans les gestionnaires de commandes (comme vous l'avez montré). En général, je place tous les "crochets" dans les règles de gestion, etc. qui valident que l'opération PEUT être effectuée dans le gestionnaire de commandes. En fait, je suis votre approche consistant à injecter des validateurs dans le gestionnaire de commande, ce qui me permet d'étendre/remplacer l'ensemble de règles sans modifier le gestionnaire. Ces règles "dynamiques" me permettent de définir les règles de gestion, telles que ce qui constitue une adresse électronique valide, avant de modifier l'état de l'entité - ce qui permet de s'assurer qu'elle ne passe pas dans un état invalide. Mais dans ce cas, l'invalidité est définie par la logique métier et, comme vous l'avez souligné, elle est très volatile.

Ayant gravi les échelons de l'AAPC, j'ai trouvé ce changement difficile à adopter car il semble rompre l'encapsulation. Mais je crois que l'encapsulation n'est pas brisée si l'on prend un peu de recul et que l'on se demande quel rôle la validation joue vraiment dans le modèle.

J'ai trouvé ces nuances très importantes pour garder les idées claires sur ce sujet. Il y a la validation pour éviter les mauvaises données (par exemple les arguments manquants, les valeurs nulles, les chaînes vides, etc.) qui appartiennent à la méthode elle-même et il y a la validation pour s'assurer que les règles de gestion sont appliquées. Dans le premier cas, si le client doit avoir une adresse électronique, la seule règle dont je dois me préoccuper pour éviter que mon objet de domaine ne devienne invalide est de m'assurer qu'une adresse électronique a été fournie à la méthode ChangeEmail. Les autres règles sont des préoccupations de plus haut niveau concernant la validité de la valeur elle-même et n'ont pas vraiment d'incidence sur la validité de l'entité domaine elle-même.

Cela a été la source de nombreuses "discussions" avec des collègues développeurs, mais lorsque la plupart d'entre eux adoptent une vision plus large et examinent le rôle réel de la validation, ils ont tendance à voir la lumière.

Enfin, il y a aussi une place pour la validation de l'interface utilisateur (et par interface utilisateur, j'entends tout ce qui sert d'interface à l'application, qu'il s'agisse d'un écran, d'un point de terminaison de service ou autre). Je trouve parfaitement raisonnable de dupliquer une partie de la logique dans l'interface utilisateur afin de fournir une meilleure interactivité à l'utilisateur. Mais c'est parce que cette validation sert cet unique objectif que j'autorise une telle duplication. Cependant, l'utilisation d'objets validateurs/spécifications injectés favorise la réutilisation de cette manière sans les implications négatives de la définition de ces règles à plusieurs endroits.

Je ne sais pas si ça aide ou pas...

8voto

pjvds Points 661

Je ne suggérerais pas de placer de gros morceaux de code dans votre domaine pour la validation. Nous avons éliminé la plupart de nos validations maladroitement placées en les considérant comme une odeur de concepts manquants dans notre domaine. Dans votre exemple de code, je vois une validation pour une adresse e-mail. Un client n'a rien à voir avec la validation de l'adresse électronique.

Pourquoi ne pas faire un ValueObject appelé Email qui fait cette validation à la construction ?

D'après mon expérience, les validations placées maladroitement sont des indices de concepts manqués dans votre domaine. Vous pouvez les attraper dans les objets Validator, mais je préfère les objets value car vous intégrez le concept concerné dans votre domaine.

5voto

RobertMS Points 662

Je suis au début d'un projet et je vais implémenter ma validation en dehors de mes entités de domaine. Mes entités de domaine contiendront une logique pour protéger les invariants (tels que les arguments manquants, les valeurs nulles, les chaînes vides, les collections, etc.) ). Mais les règles de gestion réelles se trouveront dans les classes de validateurs. Je suis de l'avis de @SonOfPirate...

J'utilise FluentValidation qui me donnera essentiellement un tas de validateurs qui agissent sur les entités de mon domaine : aka, le pattern de spécification. En outre, conformément aux modèles décrits dans le livre bleu d'Eric, je peux construire les validateurs avec toutes les données dont ils peuvent avoir besoin pour effectuer les validations (que ce soit à partir de la base de données ou d'un autre référentiel ou service). J'aurais également la possibilité d'injecter toutes les dépendances ici aussi. Je peux également composer et réutiliser ces validateurs (par exemple, un validateur d'adresse peut être réutilisé dans un validateur d'employé et un validateur de société). J'ai une fabrique de validateurs qui agit comme un "localisateur de services" :

public class ParticipantService : IParticipantService
{
    public void Save(Participant participant)
    {
        IValidator<Participant> validator = _validatorFactory.GetValidator<Participant>();
        var results = validator.Validate(participant);
            //if the participant is valid, register the participant with the unit of work
            if (results.IsValid)
            {
                if (participant.IsNew)
                {
                    _unitOfWork.RegisterNew<Participant>(participant);
                }
                else if (participant.HasChanged)
                {
                    _unitOfWork.RegisterDirty<Participant>(participant);
                }
            }
            else
            {
                _unitOfWork.RollBack();
                //do some thing here to indicate the errors:generate an exception (or fault) that contains the validation errors. Or return the results
            }
    }

}

Et le validateur contiendrait du code, quelque chose comme ça :

   public class ParticipantValidator : AbstractValidator<Participant>
    {
        public ParticipantValidator(DateTime today, int ageLimit, List<string> validCompanyCodes, /*any other stuff you need*/)
        {...}

    public void BuildRules()
    {
             RuleFor(participant => participant.DateOfBirth)
                    .NotNull()
                    .LessThan(m_today.AddYears(m_ageLimit*-1))
                    .WithMessage(string.Format("Participant must be older than {0} years of age.", m_ageLimit));

            RuleFor(participant => participant.Address)
                .NotNull()
                .SetValidator(new AddressValidator());

            RuleFor(participant => participant.Email)
                .NotEmpty()
                .EmailAddress();
            ...
}

    }

Nous devons prendre en charge plusieurs types de présentation : sites web, winforms et chargement en masse de données via des services. Tout cela repose sur un ensemble de services qui exposent la fonctionnalité du système d'une manière unique et cohérente. Nous n'utilisons pas Entity Framework ou ORM pour des raisons que je ne vais pas vous ennuyer avec.

Voici pourquoi j'aime cette approche :

  • Les règles de gestion qui sont contenues dans les validateurs sont totalement testables à l'unité.
  • Je peux composer des règles plus complexes à partir de règles plus simples.
  • Je peux utiliser les validateurs à plusieurs endroits dans mon système (nous prenons en charge les sites Web et les formulaires Windows, ainsi que les services qui exposent des fonctionnalités). Ainsi, si une règle légèrement différente est requise pour un cas d'utilisation dans un service qui diffère des sites Web, je peux le gérer.
  • Toutes les connaissances sont exprimées en un seul endroit et je peux choisir où et comment les injecter et les composer.

5voto

Yevhen Bobrov Points 310

Tu as mis la validation au mauvais endroit.

Vous devriez utiliser les ValueObjects pour de telles choses. Regardez cette présentation http://www.infoq.com/presentations/Value-Objects-Dan-Bergh-Johnsson Il vous apprendra également ce que sont les données en tant que centres de gravité.

Il y a également un exemple de réutilisation de la validation des données, par exemple en utilisant des méthodes de validation statiques comme Email.IsValid(string).

2voto

Arthis Points 1483

Je ne peux pas dire que ce que j'ai fait est la chose parfaite à faire, car je suis encore moi-même aux prises avec ce problème et je mène un combat à la fois. Mais jusqu'à présent, j'ai fait ce qui suit :

J'ai des classes de base pour encapsuler la validation :

public interface ISpecification<TEntity> where TEntity : class, IAggregate
    {
        bool IsSatisfiedBy(TEntity entity);
    }

internal class AndSpecification<TEntity> : ISpecification<TEntity> where TEntity: class, IAggregate
    {
        private ISpecification<TEntity> Spec1;
        private ISpecification<TEntity> Spec2;

        internal AndSpecification(ISpecification<TEntity> s1, ISpecification<TEntity> s2)
        {
            Spec1 = s1;
            Spec2 = s2;
        }

        public bool IsSatisfiedBy(TEntity candidate)
        {
            return Spec1.IsSatisfiedBy(candidate) && Spec2.IsSatisfiedBy(candidate);
        }

    }

    internal class OrSpecification<TEntity> : ISpecification<TEntity> where TEntity : class, IAggregate
    {
        private ISpecification<TEntity> Spec1;
        private ISpecification<TEntity> Spec2;

        internal OrSpecification(ISpecification<TEntity> s1, ISpecification<TEntity> s2)
        {
            Spec1 = s1;
            Spec2 = s2;
        }

        public bool IsSatisfiedBy(TEntity candidate)
        {
            return Spec1.IsSatisfiedBy(candidate) || Spec2.IsSatisfiedBy(candidate);
        }
    }

    internal class NotSpecification<TEntity> : ISpecification<TEntity> where TEntity : class, IAggregate
    {
        private ISpecification<TEntity> Wrapped;

        internal NotSpecification(ISpecification<TEntity> x)
        {
            Wrapped = x;
        }

        public bool IsSatisfiedBy(TEntity candidate)
        {
            return !Wrapped.IsSatisfiedBy(candidate);
        }
    }

    public static class SpecsExtensionMethods
    {
        public static ISpecification<TEntity> And<TEntity>(this ISpecification<TEntity> s1, ISpecification<TEntity> s2) where TEntity : class, IAggregate
        {
            return new AndSpecification<TEntity>(s1, s2);
        }

        public static ISpecification<TEntity> Or<TEntity>(this ISpecification<TEntity> s1, ISpecification<TEntity> s2) where TEntity : class, IAggregate
        {
            return new OrSpecification<TEntity>(s1, s2);
        }

        public static ISpecification<TEntity> Not<TEntity>(this ISpecification<TEntity> s) where TEntity : class, IAggregate
        {
            return new NotSpecification<TEntity>(s);
        }
    }

et pour l'utiliser, je fais ce qui suit :

gestionnaire de commandes :

 public class MyCommandHandler :  CommandHandler<MyCommand>
{
  public override CommandValidation Execute(MyCommand cmd)
        {
            Contract.Requires<ArgumentNullException>(cmd != null);

           var existingAR= Repository.GetById<MyAggregate>(cmd.Id);

            if (existingIntervento.IsNull())
                throw new HandlerForDomainEventNotFoundException();

            existingIntervento.DoStuff(cmd.Id
                                , cmd.Date
                                ...
                                );

            Repository.Save(existingIntervento, cmd.GetCommitId());

            return existingIntervento.CommandValidationMessages;
        }

l'agrégat :

 public void DoStuff(Guid id, DateTime dateX,DateTime start, DateTime end, ...)
        {
            var is_date_valid = new Is_dateX_valid(dateX);
            var has_start_date_greater_than_end_date = new Has_start_date_greater_than_end_date(start, end);

        ISpecification<MyAggregate> specs = is_date_valid .And(has_start_date_greater_than_end_date );

        if (specs.IsSatisfiedBy(this))
        {
            var evt = new AgregateStuffed()
            {
                Id = id
                , DateX = dateX

                , End = end        
                , Start = start
                , ...
            };
            RaiseEvent(evt);
        }
    }

la spécification est maintenant intégrée dans ces deux classes :

public class Is_dateX_valid : ISpecification<MyAggregate>
    {
        private readonly DateTime _dateX;

        public Is_data_consuntivazione_valid(DateTime dateX)
        {
            Contract.Requires<ArgumentNullException>(dateX== DateTime.MinValue);

            _dateX= dateX;
        }

        public bool IsSatisfiedBy(MyAggregate i)
        {
            if (_dateX> DateTime.Now)
            {
                i.CommandValidationMessages.Add(new ValidationMessage("datex greater than now"));
                return false;
            }

            return true;
        }
    }

    public class Has_start_date_greater_than_end_date : ISpecification<MyAggregate>
    {
        private readonly DateTime _start;
        private readonly DateTime _end;

        public Has_start_date_greater_than_end_date(DateTime start, DateTime end)
        {
            Contract.Requires<ArgumentNullException>(start == DateTime.MinValue);
            Contract.Requires<ArgumentNullException>(start == DateTime.MinValue);

            _start = start;
            _end = end;
        }

        public bool IsSatisfiedBy(MyAggregate i)
        {
            if (_start > _end)
            {
                i.CommandValidationMessages.Add(new ValidationMessage(start date greater then end date"));
                return false;
            }

            return true;
        }
    }

Cela me permet de réutiliser certaines validations pour différents agrégats et c'est facile à tester. Si vous y voyez des flux. Je serais très heureux d'en discuter.

le vôtre,

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X