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.
The underestimated serialize
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 (minimum?
, maximum?
, seconds
methods). Its then simply registered into ActiveRecord by using serialize
macro.
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.
Useful links:
Top comments (0)