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.
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.
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? 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 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?:
Just ignore it. Itâs not struct responsibility to meet this requirement, and add it to the validation schema
Override constructor to check this invariant after initialization
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
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:
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.