As Ruby on Rails developers, we tend to add too much code in the same ActiveRecord
model. Very often, this is because we fail to identify new objects. This article presents a concrete tool that helps keep our ActiveRecord
models sizes under control thanks to the serialize method.
Identifying value objects in Rails
Let's take a concrete example. Imagine that we are working on an invoicing system, each invoice being composed of multiple line items. Our Invoice model would look like this:
class Invoice < ApplicationRecord
has_many :line_items, dependent: :destroy
end
In our example, a LineItem
will have a quantity
stored as an integer and a unit_price
stored as a decimal. The LineItem
model would look like this:
class LineItem < ApplicationRecord
belongs_to :invoice
validates :quantity, numericality: { only_integer: true, greater_than: 0 }
validates :unit_price, numericality: { greater_than: 0 }
def total_price
(unit_price * quantity).round(2)
end
end
Let's now imagine a new requirement: we have to handle VAT rates of 0%, 10%, and 20% on line items, 0% VAT rate being for tourist taxes. The most straightforward implementation would probably be to add all of the VAT rates in the LineItem
model as it already exists. We would add a tax_code string field in our line_items
table, and our LineItem
model would start growing:
class LineItem < ApplicationRecord
# previous associations and validations
TAX_CODES = { vat_0: 0.0, vat_10: 0.1, vat_20: 0.2 }
validates :tax_code, inclusion: { in: TAX_CODES.keys.map(&:to_s) }
def tax_rate
TAX_CODES[tax_code.to_sym]
end
def tourist_tax?
tax_code.to_sym == :vat_0
end
# We now have to take the tax_rate into account
def total_price
(unit_price * quantity * (1 + tax_rate)).round(2)
end
end
This is a very simple example of course but failing to identify value objects creates two issues:
- Our
LineItem
model is doing too much. Failing to identify new objects will make the model longer and longer, and it will harm readability sooner or later. - Tax-related information is now entangled inside of our LineItem model. If we want to reuse tax codes elsewhere in our application, we are stuck.
Tax codes represent something important in our application and we should give them a proper place to live.
Extracting value objects in Rails
We are first going to give this object a new home and a new name. With a few minor changes, we have extracted all the methods in the TaxCode
object:
# app/models/tax_code.rb
class TaxCode
TAX_CODES = { vat_0: 0.0, vat_10: 0.1, vat_20: 0.2 }
attr_reader :key
def initialize(key)
@key = key.to_sym
end
def tax_rate
TAX_CODES[key]
end
def tourist_tax?
key == :vat_0
end
end
Our LineItem
model is now much smaller and focused on its main responsibility as we removed all the tax code related code:
class LineItem < ApplicationRecord
# previous associations and validations
validates :tax_code, inclusion: { in: TaxCode::TAX_CODES.keys.map(&:to_s) }
def total_price
(unit_price * quantity * (1 + tax_rate)).round(2)
end
def tax_rate
TaxCode.new(tax_code).tax_rate
end
end
There is still one issue to solve: The LineItem#tax_code
does not return a TaxCode
object. Let's demonstrate this issue in the rails console:
line_item = LineItem.create(quantity: 1, unit_price: 100, tax_code: :vat_20)
line_item.tax_code
=> "vat_20"
We want to get a TaxCode
object when calling the LineItem#tax_code
method. Luckily, Rails provides a way to do precisely that, thanks to the serialize method. Let's instruct the LineItem model that its tax_code method should return a TaxCode object:
class LineItem < ApplicationController
serialize :tax_code, TaxCode
# all the previous code...
end
For the .serialize
method to work, TaxCode
should implement both the .load
and .dump
class methods. As mentioned in the documentation, .dump
will be called to serialize an object and should return the serialized value to be stored in the database. On the other hand, .load
will be called to reverse the process and deserialize from the database. In our case, that means converting a string into a TaxCode
object.
Let's add those two class methods to the TaxCode
class:
# app/models/tax_code.rb
class TaxCode
def self.load(key)
# We don't want to build TaxCodes with a nil key
new(key) if key
end
def self.dump(tax_code)
case tax_code
when self then tax_code.key
else tax_code
end
end
# all the previous code...
end
Great! Now the LineItem#tax_code
method should return a TaxCode
object! Let's update the validations and the LineItem#total_price
method accordingly:
class LineItem
# all the previous code
validates :tax_code, inclusion: { in: TaxCode.all }
def total_price
(unit_price * quantity * (1 + tax_code.tax_rate)).round(2)
end
end
Let's add the .all
method on TaxCode
and the #==
method to be able to compare two tax codes as it is necessary for the inclusion validation to be able to compare them:
# app/models/tax_code.rb
class TaxCode
# all the previous code...
def self.all
TAX_CODES.keys.map { |key| new(key) }
end
# Tax codes are equal if they have the same key
def ==(other)
if other.is_a?(self.class)
key == other.key
end
end
end
Let's restart our rails console and try again:
line_item = LineItem.create(quantity: 1, unit_price: 100, tax_code: :vat_20)
line_item.tax_code
=> <TaxCode:0x00007fd0c6316728 @key=:vat_20>
That was a lot of work! Let's look at the final implementation of the LineItem
model:
class LineItem < ApplicationRecord
belongs_to :invoice
validates :quantity, numericality: { only_integer: true, greater_than: 0 }
validates :unit_price, numericality: { greater_than: 0 }
validates :tax_code, inclusion: { in: TaxCode.all }
serialize :tax_code, TaxCode
def total_price
(unit_price * quantity * (1 + tax_code.tax_rate)).round(2)
end
end
As we can see, our model is very small and only focused on line items related calculations. All taxes related code has been extracted to the TaxCode class.
We can notice the same beneficial effects in the TaxCode
class. All tax-related code is isolated in a small class with a single responsibility:
# app/models/tax_code.rb
class TaxCode
TAX_CODES = { vat_0: 0.0, vat_10: 0.1, vat_20: 0.2 }
def self.load(key)
new(key) if key
end
def self.dump(tax_code)
case tax_code
when self then tax_code.key
else tax_code
end
end
def self.all
TAX_CODES.keys.map { |key| new(key) }
end
attr_reader :key
def initialize(key)
@key = key.to_sym
end
def tax_rate
TAX_CODES[key]
end
def tourist_tax?
key == :vat_0
end
def ==(other)
if other.is_a?(self.class)
key == other.key
end
end
end
Our TaxCode
model is now completely extracted and can be reused anywhere inside our application!
Serialization in the wild
Admittedly, our value object example is a bit simple. Let's explore a real-world example. ActionText
is a part of the Ruby on Rails framework and enables us to add rich text to our applications with just one line of code. To add rich text to an Article
model for example, we would simply add the following line to our model:
class Article < ApplicationRecord
has_rich_text :content
end
Under the hood, the .has_rich_text
method defines a has_one
association with an object of class ActionText::RichText
. The ActionText::RichText
object holds the rich text inside the database in a body field that gets serialized in an ActionText::Content
object.
module ActionText
class RichText < Record
serialize :body, ActionText::Content
# ...
end
end
The ActionText::Content
object does much more than our simple TaxCode
example of course; it defines many more methods! The .load
and .dump
methods are defined in the ActionText::Serialization
concern that is included in ActionText::Content
.
Did you like this article?
You can follow me on Twitter as I announce all my articles there!
Top comments (2)
ššš
Awesome !