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.
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.