Using Rails's Attributes API to serialize Value Objects

Continuing my deep dive into Rails features 1, I recently read about the Attributes API and more particularly about how it can be used with custom types.

The attribute API

The attributes method allows you to define an attribute with a type on a model. This type can be a completely custom.

In our example, we have spaceships that are defined by their category. Those categories are both immutable and interchangeable, and are good candidates to be transformed into value objects.

Our current model looks like this:

# app/models/ship.rb
class Ship < ApplicationRecord
  validates :name, presence: true
  validates :category, presence: true

  validates :category, inclusion: { in: Ship::Category::VALID_CATEGORIES }
end

And our Ship::Category value type looks like this:

# app/models/ship/category.rb
class Ship::Category
  VALID_CATEGORIES = %w(
    shuttle supply_carrier troop_carrier war_ship
  ).freeze

  def initialize(category)
    raise "invalid category '#{category.inspect}'" unless category.in?(VALID_CATEGORIES)

    @raw_category = category
  end

  def to_s
    raw_category
  end

  # Conform to comparable.
  def <=>(other)
    VALID_CATEGORIES.index(to_s) <=> VALID_CATEGORIES.index(other.to_s)
  end

  private

  attr_reader :raw_category
end

Now, let’s update our model to retrieve our value object:

# app/models/ship.rb
class Ship < ApplicationRecord
  # ...

  def category
    @category ||= Ship::Category.new(self[:category])
  end
end

While this does what we want, this can be improved by creating a custom type.

Creating a custom type

We create our custom type by inheriting from ActiveRecord::Type::Value and overriding the necessary methods:

For our type it looks like this:

# app/types/ship_category.rb

class CategoryType < ActiveRecord::Type::Value
  def type
    :string
  end

  def cast(value)
    Ship::Category.new(value)
  end

  def deserialize(value)
    cast(value)
  end

  def serialize(value)
    value.to_s
  end
end

Registering our type

Now that our type is created, we need to register it so ActiveRecord knows about it:

# config/initializers/types.rb
ActiveRecord::Type.register(:ship_category, CategoryType)

You will need to restart your Rails server or re-register your type every time you update it.

Using it in our model

Finally, we can use it in our model:

class Ship < ApplicationRecord
  attribute :category, :ship_category

  validates :name, presence: true
  validates :category, presence: true
end

At this point, you might be wondering: Why would I do this? Personally, I feel like it is cleaner to let Rails handle the instantiation of objects instead of the usual memoization-with-instance-variables dance.

Furthermore, it allows you to add additional features to your model without having pollute the model class. In our case, we allow our ships to be compared based on their categories by implementing Comparable in our Category.

However, there are ways to make this particular use case fall down. In our example above, we limit the category to the values defined in VALID_CATEGORIES. This means that creating a row in our database with a value that isn’t valid will make our application raise when trying to instantiate the row into a Ship object:

INSERT INTO ships(id, name, category, created_at, updated_at)
VALUES (10, 'USS Enterprise', 'interstellar_liner', NOW(), NOW());
enterprise = Ship.find(10)
# => Error: invalid category 'interstellar_liner'

For the purpose of this blog post, I chose a fairly simple example to present the attributes API. To achieve a similar result, you could also define the categories as a ActiveRecord Enum, as a Postgres Enum2 or even have them in their own table and have a Postgres association between a Ship and its category, that is backed by a foreign Key to achieve integrity of your data.

Thanks to Bachir Çaoui and Baicheng Yu for reviewing drafts versions of this post.


Further reading

  1. See my previous post about Namespaced Rails validators

  2. However, this would require you to change your schema format to :sql or to override Rails’s Schema dumper to handle Postgres Enums (But more on that in a future post).