Rails 5 attributes API, value objects and JSONB

A guide on how to use value objects in your Ruby on Rails applications with PostgreSQL JSONB.


On these snowy winter days we have been working hard on a new application for our existing client. The purpose of this application is to track commissions and calculate salaries for employees. This is completely new project and we wanted to keep it at the cutting edge of new technologies, so we have chosen Rails 5.1 as a backend and React with Redux as a frontend solution.

Rails 5.x hasn’t brought about a revolution in the world of ROR (was the case when Rails 3 was released). From my point of view Rails 5 is a good evolution of proven technology. One of new features that were implemented is Attributes API.

First of all, let's talk about terminology. What are Value Objects? Eric Evans in his Domain-Driven Design says that such objects matter only as the combination of their attributes. Two Value Objects with the same values for all their attributes are considered equal. Value objects should be immutable (read more about Value Objects on Martin Fowler site). On the other hand entities are objects that have a distinct identity that runs through time and different representations.

We at JetRockets usually have to deal with really big Rails projects that have a couple of hundred of models and are developed by us for several years. That is why we consistently integrate DDD approaches into our codebase and into the minds of our developers.

Let's take a look at a part of DB schema that we used in our application.

DB schema

We have a plan_channels table with JSONB column tiers, that is designed to store an array of attributes for each plan tier. Each tier is an object that has amount and rate attributes. in our case Plan::Channel is an entity and each tier should be a value object. Let's query our database without any modifications to Plan::Channel model.

Loading development environment (Rails 5.1.4)
irb(main):001:0> Plan::Channel.find 17
  Plan::Channel Load (23.4ms)  SELECT  "plan_channels".* FROM "plan_channels" WHERE "plan_channels"."id" = $1 LIMIT $2  [["id", 17], ["LIMIT", 1]]
=> #<Plan::Channel
  id: 17,
  plan_id: 7,
  calculation_method: "sliding_scale",
  type: "direct",
  tiers: [
    {"rate"=>5.0, "amount"=>20000.0},
    {"rate"=>6.0, "amount"=>30000.0},
    {"rate"=>7.0, "amount"=>40000.0},
    {"rate"=>8.0, "amount"=>45000.0},
    {"rate"=>9.0, "amount"=>50000.0},
    {"rate"=>10.0, "amount"=>60000.0}
  ],
  rate: nil>

irb(main):002:0>

As we see, all works well and we got an array of hashes [{"rate"=>5.0, "amount"=>20000.0}, …]. But what if we want to have a value object Plan::Channel::Tier and receive something like this:

irb(main):010:0> Plan::Channel.find 17
  Plan::Channel Load (0.4ms)  SELECT  "plan_channels".* FROM "plan_channels" WHERE "plan_channels"."id" = $1 LIMIT $2  [["id", 17], ["LIMIT", 1]]
=> #<Plan::Channel
  id: 17,
  plan_id: 7,
  calculation_method: "sliding_scale",
  type: "direct",
  tiers: [
    #<Plan::Channel::Tier:0x007f830c0fcb20 @rate=5.0, @amount=20000.0>,
    #<Plan::Channel::Tier:0x007f830c0fcad0 @rate=6.0, @amount=30000.0>,
    #<Plan::Channel::Tier:0x007f830c0fca80 @rate=7.0, @amount=40000.0>,
    #<Plan::Channel::Tier:0x007f830c0fca08 @rate=8.0, @amount=45000.0>,
    #<Plan::Channel::Tier:0x007f830c0fc9b8 @rate=9.0, @amount=50000.0>,
    #<Plan::Channel::Tier:0x007f830c0fc940 @rate=10.0, @amount=60000.0>
  ],
  rate: nil>

irb(main):002:0>

Our first thought might be to abuse #serialize for these objects as they come out of the DB and override the setter to handle the cases in which attributes are being assigned by the app itself. That would look something like this:

class Plan::Channel < ApplicationRecord
  # …

  serialize :tiers, Plan::Channel::TiersSerializer # responds to load and dump methods

  def tiers=(value)
    value = Plan::Channel::Tiers.new(value)
    super
  end

  # …
end

This seems to work at first, but then we realize that attributes can be set via #write_attribute, so we need to add:

class Plan::Channel < ApplicationRecord
  # …

  def write_attribute(name, value)
    if name == :tiers
      value = Plan::Channel::Tiers.new(value)
    end
    super
  end

  # …
end

We think we've fixed it, but then we find ourselves needing to deal with the way that serialized columns are always treated as dirty. This is where the ActiveRecord Attributes API comes in.

For our value objects, we'll have a Plan::Channel::Tier class and a Plan::Channel::Tiers class. A minimal example Plan::Channel::Tier class might look like this:

class Plan::Channel::Tier
  attr_reader :rate
  attr_reader :value

  def initialize(attributes = {})
    attributes.symbolize_keys!

    self.rate = attributes[:rate]
    self.amount = attributes[:amount]
  end

  def rate=(v)
    @rate = v.try(:to_f)
  end

  def amount=(v)
    @amount = v.try(:to_f)
  end

  def empty?
    rate.nil? || amount.nil?
  end

  def as_json
    {
      rate: rate,
      amount: amount
    }
  end
end

and Plan::Channel::Tiers class:

class Plan::Channel::Tiers
  extend Forwardable

  def_delegators :@collection, *[].public_methods

  def initialize(array_or_hash = [])
    collection = case array_or_hash
      when Hash
        [Plan::Channel::Tier.new(array_or_hash)]
      else
        Array(array_or_hash).map do |tier|
          tier.is_a?(Plan::Channel::Tier) ? tier : Plan::Channel::Tier.new(tier)
        end
      end

    @collection = collection.reject(&:empty?)
  end

  def to_a
    @collection
  end
end

Now it is time to tell ActiveRecord about our type, let's add a line to Plan::Channel class.

  class Plan::Channel
    # …
    attribute :tiers, Plan::Channel::Tiers::Type.new
    # …
  end

The problem is that Plan::Channel::Tiers::Type is still undefined, so we need to create it. Active Record PostgreSQL adapter comes with a JSON type (json.rb and abstract_json.rb), that is very close to what we need.

class Plan::Channel::Tiers::Type < ActiveRecord::Type::Value
  # …

  def type
    :jsonb
  end

  def cast(value)
    Plan::Channel::Tiers.new(value)
  end

  def deserialize(value)
    if String === value
      decoded = ::ActiveSupport::JSON.decode(value) rescue nil
      Plan::Channel::Tiers.new(decoded)
    else
      super
    end
  end

  def serialize(value)
    case value
    when Array, Hash, Plan::Channel::Tiers
      ::ActiveSupport::JSON.encode(value)
    else
      super
    end
  end

  # …
end

Let's take a closer look at this code.

  • We implemented our type as immutable. If you ever need mutable types, you should simply include ActiveModel::Type::Helpers::Mutable at the top of class definition.
  • #cast method is called when your app sets the attribute.
  • #deserialize receives the serialized data from the database and returns an object.
  • #serialize serializes the data for the database.

Now we should try it all together.

irb(main):014:0> c = Plan::Channel.new(:plan_id => 7, type: 'direct', calculation_method: 'sliding_scale', :tiers => [{ rate: 5, amount: 10000 }, { rate: 6, amount: 20000 }])
=> #<Plan::Channel
  id: nil,
  plan_id: 7,
  calculation_method: "sliding_scale",
  type: "direct",
  tiers: [
    #<Plan::Channel::Tier:0x007f830ccd3db8 @rate=5.0, @amount=10000.0>,
    #<Plan::Channel::Tier:0x007f830ccd3d40 @rate=6.0, @amount=20000.0>
  ],
  rate: nil>

irb(main):015:0> c.save
   (0.2ms)  BEGIN
  SQL (32.8ms)  INSERT INTO "plan_channels" ("plan_id", "calculation_method", "type", "tiers") VALUES ($1, $2, $3, $4) RETURNING "id"  [["plan_id", 7], ["calculation_method", "sliding_scale"], ["type", "direct"], ["tiers", "[{\"rate\":5.0,\"amount\":10000.0},{\"rate\":6.0,\"amount\":20000.0}]"]]
   (2.5ms)  COMMIT
=> true

irb(main):016:0> c = Plan::Channel.last
  Plan::Channel Load (0.5ms)  SELECT  "plan_channels".* FROM "plan_channels" ORDER BY "plan_channels"."id" DESC LIMIT $1  [["LIMIT", 1]]
=> #<Plan::Channel
  id: 28,
  plan_id: 7,
  calculation_method: "sliding_scale",
  type: "direct",
  tiers: [
    #<Plan::Channel::Tier:0x007f830cc8f870 @rate=5.0, @amount=10000.0>,
    #<Plan::Channel::Tier:0x007f830cc8f7f8 @rate=6.0, @amount=20000.0>
  ],
  rate: nil>

irb(main):017:0>

As you can see, we got an array that contains Plan::Channel::Tier objects.

With this article, I hope you will start using value level objects more freely in your Rails apps instead of making your models fat with a big pack of code.


Igor
Alexandrov

CTO at JetRockets

See what people are saying

Rectangle 100 x e0c65e6b b532 42d6 8be8 061e14722a41
Brunno dos Santos ( @squiter )
Brunno in his twitter mentioned the article.

Explore more of JetRockets