I love Value Objects. They can improve and clean up your project by insane amount, yet they are simple and easy to understand for almost everyone. Not only easy to understand but actually easy to write as well!
But wait, most of the Value Object implementations are just Plain Ruby Objects right? What if I use ActiveRecord?
Doesn't matter if you like it, or just have to live with the legacy written by hardcore Rails developer - ActiveRecord has a way to inject Value Objects. In both cases they will improve your life and domain model of your code. Lets take a look at one of the simplest possibilities.
One of the simplest method to use is the serialize method. All it does it is loading data into object using
load method and dumps the data into db using
dump. Its simple yet powerful mechanism. Lets get some example (implemented as internal class for simplicity):
class CreateMeetings < ActiveRecord::Migration[6.0] def change create_table :meetings do |t| t.integer :time_limit, default: 1 t.timestamps end end end class Meeting < ApplicationRecord class TimeLimit include Comparable def self.dump(time_limit) time_limit.to_i end def self.load(time_limit) new(time_limit) end def initialize(time_limit) @time_limit = position.to_i end def to_i time_limit end def <=>(other) to_i <=> other.to_i end def minimum? time_limit == 1 end def maximum? time_limit == 60 end def seconds time_limit.minutes.seconds end private attr_reader :time_limit end serialize :time_limit, TimeLimit end
As you can see there is implementation of
TimeLimit value object, which allows us to have domain related behaviors (
seconds methods). Its then simply registered into ActiveRecord by using
serialize :time_limit, TimeLimit
Lets see it in action:
:004 > meeting = Meeting.new :005 > meeting => #<Meeting id: nil, time_limit: #<Meeting::TimeLimit:0x00007f9744443120 @time_limit=1>, created_at: nil, updated_at: nil> :006 > meeting.time_limit.minimum? => true
As you can see its clearly visible that there is separate object used. We can call all the methods on it directly, without any Rails magic involved. Lets try to change the value using setter:
:014 > meeting.time_limit = 5 :015 > meeting => #<Meeting id: nil, time_limit: #<Meeting::TimeLimit:0x000056020ecfec50 @time_limit=5>, created_at: nil, updated_at: nil>
Interesting right? A new object with our value! ActiveRecord is using the
load method to create it for us. Lets do a save then!
:016 > meeting.save (0.1ms) begin transaction Meeting Create (0.5ms) INSERT INTO "meetings" ("time_limit", "created_at", "updated_at") VALUES (?, ?, ?) [["time_limit", 5], ["created_at", "2021-04-12 20:32:06.867606"], ["updated_at", "2021-04-12 20:32:06.867606"]] (17.9ms) commit transaction => true :017 > meeting => #<Meeting id: 2, time_limit: #<Meeting::TimeLimit:0x000056020ebf89c8 @time_limit=5>, created_at: "2021-04-12 20:32:06", updated_at: "2021-04-12 20:32:06">
Great so it saves a correct value! Turns out it used our
dump method correctly. But this is the same record in memory, lets fetch our newly created meeting from db instead:
:024 > meeting = Meeting.last Meeting Load (0.2ms) SELECT "meetings".* FROM "meetings" ORDER BY "meetings"."id" DESC LIMIT ? [["LIMIT", 1]] :025 > meeting => #<Meeting id: 2, time_limit: #<Meeting::TimeLimit:0x000056020b8a3618 @time_limit=5>, created_at: "2021-04-12 20:32:06", updated_at: "2021-04-12 20:32:06"> :026 > meeting.time_limit.seconds => 300 seconds
This is so cool right? Simple yet powerful interface, almost no magic involved. This allows us to create Plain Old Ruby Objects and inject them into our ActiveRecord based entities without problems. No need to spread your
maximum logic all over the place (Service Objects I'm looking at you...), we can encapsulate them and still use our beloved(hated?) models!
For "get rid of AR" developers: you can use this mechanism to inject a domain modeling into legacy code without problems. Ruby duck typing is your friend:
# using ActiveRecord: meeting.time_limit.seconds # using some PORO or w/e: meeting.time_limit.seconds
Yes, there is no difference, you can refactor at any point of time, and your code will remain untouched. Isn't that beautiful?
There is one thing to mention that might catch you off guard: object creation validation. Lets modify our example a bit:
class TimeLimit # ... InvalidLimitError = Class.new(StandardError) def initialize(time_limit) raise InvalidLimitError unless (1..60).include?(time_limit.to_i) @time_limit = time_limit.to_i end # ...
And try to create invalid limit:
:034 > meeting.time_limit = 100 :035 > meeting.id :036 > meeting.time_limit Traceback (most recent call last): 3: from app/models/meeting.rb:10:in `load' 2: from app/models/meeting.rb:10:in `new' 1: from app/models/meeting.rb:16:in `initialize' Meeting::TimeLimit::InvalidLimitError (Meeting::TimeLimit::InvalidLimitError)
As you can see, ActiveRecord does a lazy loading on serialized attributes, so the
load method is called after first call - not on the assignment. Keep that in mind when writing this kind of code.