Domain Validation in a CQRS architecture

前端 未结 11 1498
南旧
南旧 2020-12-12 11:25

Danger ... Danger Dr. Smith... Philosophical post ahead

The purpose of this post is to determine if placing the validation logic outside of my domain entiti

相关标签:
11条回答
  • 2020-12-12 11:32

    The validation in your example is validation of a value object, not an entity (or aggregate root).

    I would separate the validation into distinct areas.

    1. Validate internal characteristics of the Email value object internally.

    I adhere to the rule that aggregates should never be in an invalid state. I extend this principal to value objects where practical.

    Use createNew() to instantiate an email from user input. This forces it to be valid according to your current rules (the "user@email.com" format, for example).

    Use createExisting() to instantiate an email from persistent storage. This performs no validation, which is important - you don't want an exception to be thrown for a stored email that was valid yesterday but invalid today.

    class Email
    {
        private String value_;
    
        // Error codes
        const Error E_LENGTH = "An email address must be at least 3 characters long.";
        const Error E_FORMAT = "An email address must be in the 'user@email.com' format.";
    
        // Private constructor, forcing the use of factory functions
        private Email(String value)
        {
            this.value_ = value;
        }
    
        // Factory functions
        static public Email createNew(String value)
        {
            validateLength(value, E_LENGTH);
            validateFormat(value, E_FORMAT);
        }
    
        static public Email createExisting(String value)
        {
            return new Email(value);
        }
    
        // Static validation methods
        static public void validateLength(String value, Error error = E_LENGTH)
        {
            if (value.length() < 3)
            {
                throw new DomainException(error);
            }
        }
    
        static public void validateFormat(String value, Error error = E_FORMAT)
        {
            if (/* regular expression fails */)
            {
                throw new DomainException(error);
            }
        }
    
    }
    
    1. Validate "external" characteristics of the Email value object externally, e.g., in a service.

      class EmailDnsValidator implements IEmailValidator
      {
          const E_MX_MISSING = "The domain of your email address does not have an MX record.";
      
          private DnsProvider dnsProvider_;
      
          EmailDnsValidator(DnsProvider dnsProvider)
          {
              dnsProvider_ = dnsProvider;
          }
      
          public void validate(String value, Error error = E_MX_MISSING)
          {
              if (!dnsProvider_.hasMxRecord(/* domain part of email address */))
              {
                  throw new DomainException(error);
              }
          }
      }
      
      class EmailDomainBlacklistValidator implements IEmailValidator
      {
          const Error E_DOMAIN_FORBIDDEN = "The domain of your email address is blacklisted.";
      
          public void validate(String value, Error error = E_DOMAIN_FORBIDDEN)
          {
              if (/* domain of value is on the blacklist */))
              {
                  throw new DomainException(error);
              }
          }
      }
      

    Advantages:

    • Use of the createNew() and createExisting() factory functions allow control over internal validation.

    • It is possible to "opt out" of certain validation routines, e.g., skip the length check, using the validation methods directly.

    • It is also possible to "opt out" of external validation (DNS MX records and domain blacklisting). E.g., a project I worked on initially validated the existance of MX records for a domain, but eventually removed this because of the number of customers using "dynamic IP" type solutions.

    • It is easy to query your persistent store for email addresses that do not fit the current validation rules, but running a simple query and treating each email as "new" rather than "existing" - if an exception is thrown, there's a problem. From there you can issue, for example, a FlagCustomerAsHavingABadEmail command, using the exception error message as guidance for the user when they see the message.

    • Allowing the programmer to supply the error code provides flexibility. For example, when sending a UpdateEmailAddress command, the error of "Your email address must be at least 3 characters long" is self explanatory. However, when updating multiple email addresses (home and work), the above error message does not indicate WHICH email was wrong. Supplying the error code/message allows you to provide richer feedback to the end user.

    0 讨论(0)
  • 2020-12-12 11:37

    You can use a message based solution with Domain Events as explained here.

    Exceptions are not the right method for all validation errors, is not said that a not valid entity is an exceptional case.

    If the validation is not trivial, the logic to validate the aggregate can be executed directly on the server and while you are trying to set new input you can raise a Domain Event to tell to the user (or the application that is using your domain) why the input is not correct.

    0 讨论(0)
  • 2020-12-12 11:43

    I cannot say what I did is the perfect thing to do for I am still struggling with this problem myself and fighting one fight at a time. But I have been doing so far the following thing :

    I have basic classes for encapsulating 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);
            }
        }
    

    and to use it, I do the following :

    command handler :

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

    the aggregate :

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

    the specification is now embedded in these two 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;
            }
        }
    

    This allows me to reuse some validations for different aggregate and it is easy to test. If you see any flows in it. I would be real happy to discuss it.

    yours,

    0 讨论(0)
  • 2020-12-12 11:44

    I agree with a number of the concepts presented in other responses, but I put them together in my code.

    First, I agree that using Value Objects for values that include behavior is a great way to encapsulate common business rules and an e-mail address is a perfect candidate. However, I tend to limit this to rules that are constant and will not change frequently. I'm sure you are looking for a more general approach and e-mail is just an example, so I won't focus on that one use-case.

    The key to my approach is recognizing that validation serves different purposes at different locations in an application. Put simply, validate only what is required to ensure that the current operation can execute without unexpected/unintended results. That leads to the question what validation should occur where?

    In your example, I would ask myself if the domain entity really cares that the e-mail address conforms to some pattern and other rules or do we simply care that 'email' cannot be null or blank when ChangeEmail is called? If the latter, than a simple check to ensure a value is present is all that is needed in the ChangeEmail method.

    In CQRS, all changes that modify the state of the application occur as commands with the implementation in command handlers (as you've shown). I will typically place any 'hooks' into business rules, etc. that validate that the operation MAY be performed in the command handler. I actually follow your approach of injecting validators into the command handler which allows me to extend/replace the rule set without making changes to the handler. These 'dynamic' rules allow me to define the business rules, such as what constitutes a valid e-mail address, before I change the state of the entity - further ensuring it does not go into an invalid state. But 'invalidity' in this case is defined by the business logic and, as you pointed out, is highly volitile.

    Having come up through the CSLA ranks, I found this change difficult to adopt because it does seem to break encapsulation. But, I agrue that encapsulation is not broken if you take a step back and ask what role validation truly serves in the model.

    I've found these nuances to be very important in keeping my head clear on this subject. There is validation to prevent bad data (eg missing arguments, null values, empty strings, etc) that belongs in the method itself and there is validation to ensure the business rules are enforced. In the case of the former, if the Customer must have an e-mail address, then the only rule I need to be concerned about to prevent my domain object from becoming invalid is to ensure that an e-mail address has been provided to the ChangeEmail method. The other rules are higher level concerns regarding the validity of the value itself and really have no affect on the validity of the domain entity itself.

    This has been the source of a lot of 'discussions' with fellow developers but when most take a broader view and investigate the role validation really serves, they tend to see the light.

    Finally, there is also a place for UI validation (and by UI I mean whatever serves as the interface to the application be it a screen, service endpoint or whatever). I find it perfectly reasonably to duplicate some of the logic in the UI to provide better interactivity for the user. But it is because this validation serves that single purpose why I allow such duplication. However, using injected validator/specification objects promotes reuse in this way without the negative implications of having these rules defined in multiple locations.

    Not sure if that helps or not...

    0 讨论(0)
  • 2020-12-12 11:44

    You put validation in the wrong place.

    You should use ValueObjects for such things. Watch this presentation http://www.infoq.com/presentations/Value-Objects-Dan-Bergh-Johnsson It will also teach you about Data as Centers of Gravity.

    There also a sample of how to reuse data validation, like for example using static validation methods ala Email.IsValid(string)

    0 讨论(0)
  • 2020-12-12 11:44

    From my OO experience (I am not a DDD expert) moving your code from the entity to a higher abstraction level (into a command handler) will cause code duplication. This is because every time a command handler gets an email address, it has to instantiate email validation rules. This kind of code will rot after a while, and it will smell very badly. In the current example it might not, if you don't have another command which changes the email address, but in other situations it surely will...

    If you don't want to move the rules back to a lower abstraction level, like the entity or an email value object, then I strongly suggest you to reduce the pain by grouping the rules. So in your email example the following 3 rules:

      if(string.IsNullOrWhitespace(email))   throw new DomainException(“...”);
      if(!email.IsEmail())  throw new DomainException();
      if(email.Contains(“@mailinator.com”))  throw new DomainException();
    

    can be part of an EmailValidationRule group which you can reuse easier.

    From my point of view there is no explicit answer to the question where to put the validation logic. It can be part of every object depending on the abstraction level. In you current case the formal checking of the email address can be part of an EmailValueObject and the mailinator rule can be part of a higher abstraction level concept in which you state that your user cannot have an email address pointing on that domain. So for example if somebody wants to contact with your user without registration, then you can check her email against formal validation, but you don't have to check her email against the mailinator rule. And so on...

    So I completely agree with @pjvds who claimed that this kind of awkward placed validation is a sign of a bad design. I don't think you will have any gain by breaking encapsulation, but it's your choice and it will be your pain.

    0 讨论(0)
提交回复
热议问题