Rails 5 attributes API, value objects and 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

Sr. Full-Stack Developer / Partner 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