Validation approach for Domain Objects

Hello. Thanks for great gems set for better architecture.

I have a common question about best practices for Domain entities and value objects.

The main question: should I include validation rules to the object itself. Consider the easy Address value object:

    class Address < Dry::Types::Struct
      include Dry::Types.module

      attribute :address_line1, Strict::String
      attribute :address_line2, Strict::String
      attribute :city, Strict::String
      attribute :state, Strict::String
      attribute :zip, Strict::String
    end

Despite it uses strict string type, it allows blank string: ‘’.
Should I add presence validation to object attributes, or validation user input with dry-validations schema is enough.

It seems that obvious answer is: yes, I should.

But I afraid to repeat myself, because I definitely should validate user input with dry-validation schema, and it seems, I can’t use the same schema in object. So, I’ll have validation rules duplication between object attributes and user input schema.

How do you resolve this problem?

You shouldn’t mix these things up. Types are not validations. Think of them as, let’s say, database columns. For instance, you have a table named users and have a column named email in it. Let’s suppose in the current state of your domain this field cannot be null or greater than 200 characters, so you declare the column as having type string/varchar with the upper length limit set to 200 and with not null option. This way the field cannot have all possible string values, only some restricted subset of the initial unlimited string type. Once you have the table created, you can deduce validations for it, but the table itself knows nothing about them and your decisions. You can also validate field value with some crazy regex constraint directly in the database, but I think there is very few people in the world who does so. dry-types has very powerful abilities of adding restrictions to unrestricted data (similar to dependent types known in statically compiled languages), but you cannot create the whole set of validation schemas from them even in a simple application. Validate all input your application has, but be more confident with the internals.

Thanks for the explanation.

Let me ask you more clarification questions:

Our goal is make sure any object exists only in valid states: https://solnic.codes/2015/12/28/invalid-object-is-an-anti-pattern.html.

With said above, we can build object with invalid email - is it valid or invalid state?

Let’s assume we have ENUM field with only a couple values: :one, :two, :three. Should we limit that values on dry-types or dry-validation level? Is object with enum: :four invalid?

Isn’t it better to override struct constuctor to validate arguments agains some schema again, to be totally safe?

class Address << Dry::Types::Struct
  def initialize(attributes)
    result = AddressSchema.call(attributes)
    raise unless result.success? 
    super(result.output)
  end
end

Safe from what? :slight_smile: You’re speaking of objects but objects are just an intermediate representation of your data, and you should treat them this way, because it allows you to avoid an inevitable collapse of complexity when you add more and more responsibility to your class making reasoning about things very, very hard.

There will never be a simple answer, it always depends. I’d use a type for enumeration because the type is a separate entity which I can pass throughout the application and use it for validation, coercion, maybe some source for metaprogramming. You defo can add any validations you want into your domain object, but you won’t be safe until you put triggers to your database to validate all incoming input because your data is there, not in your domain objects. So until you want to write a bunch of stored procedures any action you take would be just another trade-off :slight_smile: And even in this case your data won’t become invalid just because you changed some validation logic in your code.

I should mention that Dry::Struct objects are meant to be simple data objects. Typed attributes are available so that you can avoid situations where invalid objects can be instantiated. For data validation simply use dry-v schemas. If you want to have strict-structs, just define attributes with constrained types. There’s no need to apply validation schemas inside struct constructors, it would be an overkill.

If you have have types that can be shared between “domain layer” and “http layer” (where params are validated) you can define constrained types and use them in dry-v schemas. This is still an experimental feature but we’re gonna make it work robustly.

Here’s an example:

module Types
  include Dry::Types.module

  Nums = Strict::String.enum("one", "two", "three")
end

Dry::Validation.Schema do
  required(:some_nums).filled(Types::Nums)
end

This way you can share constrained domain types with your validation schemas, so that core concepts are not duplicated.

Thanks guys. You’re rock, and dry-rb is really a breath of fresh air in Ruby community.

Last question: assume that I have a data-struct with two properties: one and two.
Any of them may be empty, but there should be at least one of that attributes. How can I implement this invariant?:

  1. Just ignore it. It’s not struct responsibility to meet this requirement, and add it to the validation schema
  2. Override constructor to check this invariant after initialization
  3. Mix or first two: implement invariant in the validation schema, validate attributes in the constructor against schema.

I’ve just realized, that calling validation schema from struct isn’t good idea, because one validation schema can’t serve all purposes: verifying hash used for initialization and to verifying hash came from the HTTP request.

So, my thought process ends with some separated mechanism for dry-struct for defining such invariants.
And it still can be accomplished with constructor overriding.

Anyway, my understanding becomes more clear, thanks!

Nothing stops you from overriding the constructor, just remember that it is more like an integrity constraint on your data, not a validation rule. As I said, I use it and it works nice, because it’s just the same as ZeroDivisionError for me, I can’t handle it nicely, no validation, no welcoming messages for users. So, depending on your confidence, I’d go with 1 or 3. Also remember that confidence is subject to change over time :slight_smile:

Just want to share another example to show, how is it possible to implement any invariant within types system.

Realize, that we have a simple struct ResidencyDuration, that indicates how long user lives at his currect address. It consists of two integers: :months and :years. Let’s assume we have rule, that ether months or years can be nil, what really means 0, but at least something should be set.

We can implement it as following:

      module ResidencyDurations
        class YearsAndMonths < Dry::Types::Struct
          attribute :months, Types::Strict::Int
          attribute :years, Types::Strict::Int
        end

        class YearsOnly < Dry::Types::Struct
          attribute :years, Types::Strict::Int
          attribute :months, Types::Strict::Nil
        end

        class MonthsOnly < Dry::Types::Struct
          attribute :years, Types::Strict::Nil
          attribute :months, Types::Strict::Int
        end
      end

      ResidencyDuration = ResidencyDurations::YearsAndMonths |
                          ResidencyDurations::YearsOnly |
                          ResidencyDurations::MonthsOnly

So, we have composited type with all invariants built-in typing code

P.S.S. btw, that’s great idea to composite complex types from this states. This way we can enforce invariants on the most basic level. Such as:

Customer = NewCustomer | ActiveCustomer | ArchivedCustomer

That gives an ability to deal with different types of customers in different actions. That works even better If we had a static types safery.

P.S.S. I know, we could just convert nil to 0 on some level before Domain layer, but in this case, I just show the example how it’s possible to express that invariant.

2 Likes