Base classes for value objects in Rails projects.
This is a part of collection of patterns extracted from Rails projects with a special focus on separation and composability of data validators.
Add this line to your application's Gemfile:
gem 'tram-value'
And then execute:
$ bundle
Or install it yourself as:
$ gem install tram-value
Value object is a domain-specific data structure written in pure Ruby which support several simple interfaces:
- Constructors - method
new
with its aliasescall
,[]
andload
takes raw object (like strings, hashes etc.) and wrap it to a value object - Attributes (params and options) defined via DSL of dry-initializer
- Wrappers - class-level helpers to add side effects to raw constructor. For example, method
maybe
wraps raw constructor in a container, that returnsnil
if argument isnil
- Validators - using class helper
examiner
(see tram-examiner) you can add a standalone validator to value objects - Dumpers - class method
.dump
along with an instance method#call
convert value object back to raw data, storable in the database columns. All undefined methods are delegated to the#call
- Memoizer
let
to simplify memoization in a one line of code
class Email < Tram::Value
param :source # dry-initializer DSL to define one parameter for the object
examiner do # define validation rules via tram-examiner / ActiveModel::Validations
validates :domain, presence: true
validates :domain, format: { in: /(\w+\.)*\w+\@(\w+\.)+\w{2,3}/ } if "source.present?"
end
# Add memoized methods
let(:address) { call.split("@").last }
let(:domain) { call.split("@").first }
# Define (memoized) dumper and its aliases
let(:call) { source.to_s.downcase }
alias_method :to_s, :call
alias_method :to_str, :call
end
Lets check instance methods
email = Email["[email protected]"] # => #<Email["[email protected]"]>
email.source # => "[email protected]"
email.address # => "foo"
email.domain # => "bar.baz"
email.to_str # => "[email protected]"
email.call # => "[email protected]"
email[1] # => "o" (because of authomatic deletagion to the #call)
email.valid? # => true
Then explore wrappers DSL
email = Email[nil] # => #<Email[""]>
email = Email.maybe[nil] # => nil
email = Email.guard(nil, as: "undefined")[nil] # => "undefined"
With this DSL you can use Email
as a [Rails serializer][rails-serializers] to add domain-specificity to you models:
Email.load "[email protected]" # => #<Email["[email protected]"]>
Email.dump Email["[email protected]"] # => "[email protected]"
class User < ActiveRecord::Base
serialize :email, Email.maybe
end
user = User.new email: "[email protected]"
user.email # => #<Email["[email protected]"]>
For convenience we provide three subclasses to simplify building value objects of your own:
Value object, whose constructor takes one argument source
and assigns it to #call
(you can reload it later). It also defines comparison of value objects to other ones by their #call
.
Let's rewrite previous definition for Email
via new subclass.
class Email < Tram::Value::Plain
let(:call) { source.to_s.downcase }
alias_method :to_s, :call
alias_method :to_str, :call
examiner do # define validation rules via tram-examiner / ActiveModel::Validations
validates :domain, presence: true
validates :domain, format: { in: /(\w+\.)*\w+\@(\w+\.)+\w{2,3}/ } if "source.present?"
end
# Add memoized methods
let(:address) { call.split("@").last }
let(:domain) { call.split("@").first }
end
Not much of simplification (except we have source
predefined). But now we have comparison out of the box:
Email["[email protected]"] == Email["[email protected]"] # => true
Email["[email protected]"] < Email["[email protected]"] # => true
Last but not least, you can send value object as an argument several times. Every time the source object will be returned back without re-instantiation. This time you don't care whether you use value, or raw data:
email = Email["[email protected]"]
Email[email].eql? email # => true
This class inherited from Tram::Value::Plain
adds specifics for pretty common case, when a value based on some string. Let's try it out:
class Email < Tram::Value::String
examiner do # define validation rules via tram-examiner / ActiveModel::Validations
validates :domain, presence: true
validates :domain, format: { in: /(\w+\.)*\w+\@(\w+\.)+\w{2,3}/ } if "source.present?"
end
# Add memoized methods
let(:address) { call.split("@").last }
let(:domain) { call.split("@").first }
end
Now you shouldn't care about to_s
and to_str
- they are predefined. Another sugar is that string values are comparable not only to other values of the same type (like plain values does), but to any object that is convertable to the same string via to_s
or to_str
:
Email["[email protected]"] == "[email protected]" # => true
Email["[email protected]"] == :"[email protected]" # => true
This is another class of common objects, built from hashes.
class Address < Tram::Value::Struct
# attribute is an alias for dry-initializer `option`
attribute :city, proc(&:to_s)
attribute :street, proc(&:to_s)
attribute :house, proc(&:to_s)
attribute :flat, proc(&:to_s)
examiner do
validates :city, :street, :house, presence: true
end
end
This time call
aliased as to_h
or to_hash
:
address = Address[city: :Moscow, street: "Chaplygina St.", house: 6, flat: nil]
# => #<Address[{ city: "Moscow", street: "Chaplygina St.", house: "6", flat: "" }]>
address.call # => { city: "Moscow", street: "Chaplygina St.", house: "6", flat: "" }
address.to_h # => { city: "Moscow", street: "Chaplygina St.", house: "6", flat: "" }
address.valid? # => true
Undefined values are ignored (look at the absence of flat and compare it with the example above):
address = Address[city: :Moscow, street: "Chaplygina St.", house: 6]
# => #<Address[{ city: "Moscow", street: "Chaplygina St.", house: "6" }]>
Validation is available as well:
address = Address[] # => #<Address[{}]>
address.errors # => { city: ["shouldn't be blank", street: ["shouldn't be blank"], house: "shouldn't be blank"] }
address.valid? # => false
Struct may be nested. When struct value is dumped (hashified), it goes through all nested arrays, hashes, and value objects and dumps them as well:
require "tram-validators"
class User < Tram::Value::Struct
attribute :name, proc(&:to_s)
attribute :address, Address
attribute :email, Email
examiner do
validates :name, presence: true
# ValidityValidator is defined in `tram-validators` collection
# and checks that given attribute is valid per se, then collects
# errors under corresponding keys
validates :address, :email, validity: { nested_keys: true }
end
end
user = User.new name: "Andy", address: { city: :Moscow, street: "Tverskaya St.", house: 34 }, email: "[email protected]"
# => #<User[name: "Andy", address: { city: Moscow, street: "Tverskaya St.", house: "34" }, email: "[email protected]"]>
user.address # => #<Address[{ city: Moscow, street: "Tverskaya St.", house: "34" }]>
user.email # => #<Email["[email protected]"]>
# It dumps all the nested structures at once:
user.to_h # => { name: "Andy", address: { city: Moscow, street: "Tverskaya St.", house: "34" }, email: "[email protected]" }
Every value object class supports several decorators to add some side effects:
class User < Tram::Value::Struct
attribute :address, Address.maybe # => sets address to `nil` if `nil` is provided
attribute :email, Email.either_present_or(nil) # => sets email to `nil` if any blank value provided
attribute :name, HumanName.either_present_or_undefined # => treats name as undefined if blank value provided
attribute :gender, Gender.guard(proc(&:blank?), as: "alien") # => substitutes blank values by "alien" string
examiner do
validates :address, :name, :email, validity: true, allow_nil: true
validates :gender, validity: true, unless: -> { gender == "alien" }
end
end
user = User[{ address: nil, email: "", name: "", gender: "" }]
# => #<User[{ address: nil, email: nil, gender: "alien" }]>
user.valid? # => true
Another important wrapper is valid
. This wrapper applies validate!
to resulting value object and raises if it isn't valid. You should use this technics to prevent instantiation of invalid objects (or parts).
user = User.valid[address: {}]
# => BOOM! (address should have city, street and house)
The last thing to mention is sometimes you need decorate an arbitrary Ruby class, or even a proc, with methods like maybe
, guard
etc. You can do this in by wrapping any object that responds to either new
or call
to Tram::Value[]
:
class User < Tram::Value::Struct
attribute :name, Tram::Value[String].guard(nil, as: "Unknown")
attribute :age, Tram::Value[proc(&:to_i)].maybe
attribute :gender, Tram::Value[proc(&:to_h)].either_present_or_undefined
end
User[name: :Andy, age: "22", gender: :male]
# => #<User[{ name: "Andy", age: 22, gender: "male" }]
User[name: nil, age: nil, gender: nil]
# => User[{ name: "Unknown", age: nil }] (there is no gender because it is treated undefined)
The gem is available as open source under the terms of the MIT License.