DEV Community

loading...

ActiveRecord - Value Objects with serialize

adamstomski profile image Adam Stomski ・4 min read

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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"> 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

   # ...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

Discussion (0)

pic
Editor guide