Regina Lee

Delegated Types in Rails, handling models with overlapping attributes

September 05, 2020

*This article was originally posted on the Blackbox Engineering blog

Let’s say we have a blog application that manages different types of posts, ex: Videos and Texts. The posts are similar enough that they will share certain attributes (like a title), but also different enough that they will require unique columns (a url column for video). In addition, we want to have a newsfeed feature where we display all posts by most recently created. This means we need to have a way to query across the entire Posts architecture.

This problem is generally solved in one of two ways in Rails: single table inheritance (STI) or abstract classes. Since ActiveRecord models do not have to be backed by a database table, these solutions leverage Plain Old Ruby Objects in order to represent this inheritance hierarchy. Polymorphic associations also often get thrown into the mix but don’t quite fit our use case (we’ll briefly touch on this). We’ll discuss the pros and cons of each of these approaches and shed light on a new Rails feature that combines the best of both worlds.

Single Table Inheritance (STI)

In an STI implementation, attributes are stored in a single parent class table and a type column holds the name of the subclass for the given row. Subclasses inherit from the parent class which allows us to write Text specific methods on the Text class without polluting the Video class. Generating a newsfeed is also very straightforward since all the data is in a single table, i.e. we can do things like Posts.order(created_at: :desc).

# == Schema

# Table name: posts

# id         :integer
# author     :string
# title      :string
# url        :string
# content    :string
# type       :string #['Video', 'Text]

class Post < ApplicationRecord; end

class Video < Post; end

class Text < Post
  def preview
    content.truncate(20)
  end
end

STI is simple to implement but can quickly get unwieldy as slight differences in models introduce new fields. This leads to wide tables that are full of null values storing information for a collection of now distinct models that are forced to share the same table.

Abstract Classes

Abstract Classes solve the problem of wide tables by giving each model its own table and connecting them via an Abstract Base Model - the parent class cannot be instantiated and must be subclassed to be utilized. Where the entire hierarchy was represented in a single table in STI, the opposite is true using abstract classes.

This gives us the ability to separate unique attributes to distinct tables, but now we have the opposite problem - repetitive columns. The author and title columns will have to be included in all subclasses. We have also lost the ability to query across the entire hierarchy (i.e. Posts.all), making our newsfeed unnecessarily cumbersome.

class Post < ApplicationRecord
  self.abstract_class = true
end

# == Schema

# Table name: videos

# id         :integer
# author     :string
# title      :string
# url        :string

class Video < Post; end

# == Schema

# Table name: texts

# id         :integer
# author     :string
# title      :string
# content    :string

class Text < Post; end

We’ve established that both methods have their strengths but are not without significant drawbacks. So what’s the solution?


Delegated Types

At the time of writing, this feature is only included in edge Rails and is expected to be included in the next minor release (6.1.0). However, the code can be pulled from the Rails codebase and included in ApplicationRecord to be used right away

Delegated types take the best of STI and abstract classes and merge them into a single solution. Both the superclass and subclasses are backed by database tables, allowing for common attributes to live in the parent class table and unique attributes to live in the subclass tables.

# == Schema

# Table name: posts

# id              :integer
# author          :string
# title           :string
# postable_type   :string
# postable_id     :string

class Post < ApplicationRecord
  delegated_type :postable, type: %w[Video Text]
end

# == Schema

# Table name: videos

# id              :integer
# url             :string

class Video < ApplicationRecord
  has_one :post, as: :postable
end

# == Schema

# Table name: texts

# id              :integer
# content         :string

class Text < ApplicationRecord
  has_one :post, as: :postable
end

So what’s happening here? By including delegated_type :postable on the Post model, it adds a polymorphic belongs_to relationship to postable under the hood. So far, this is the same as setting up a regular polymorphic association minus the syntactical difference. However, where polymorphic relationships define an interface for a “has many” relationship, delegated types define an interface for “has one”. That distinction is what allows us to isolate shared attributes in a separate table, but still be able to retrieve values specific to a delegated type row.

association diagram NB: Postable is an interface and not an actual table

Polymorphism has its role in the opposite use case - ex: “X and Y models can be commented on” vs “X and Y models are types of comments”.

Going back to delegated types, creating a new Video record looks something like this:

Post.create(
  postable: Video.create(url: 'www.video.com'),
  title: 'Sample Video',
  author: 'Jane Kim'
)

There is some fantastic Rails magic up there as the title and author attributes will be stored in the parent Post table! Videos and Texts are always created in the context of Posts so that they always retain common post attributes. Now, if we need to ask questions to the subclass models, the :postable role will delegate to the class defined in the postable_type column.

As for our newsfeed feature, we can still call Posts.all to retrieve all the blog posts that a user has written. To get the specific post type, we call .postable on the record. To get the common post attribute, we call .post on the subclass model.

Post.first.postable
# <Video id: 1, url: 'www.video.com'>

Video.first.post
# <Post id: 1, postable_type: 'Video', postable_id: 1, 
#       title: 'Sample Video', author: 'Jane Kim'>

We don’t have any repeated or nil values, we can still query across records, and can confidently scale the parent and subclasses’ attributes and behavior 🎉


Written By Regina Lee

Currently residing in Brooklyn, NY 🗽. Enjoying hobbies that begin with the letter "c" - climbing, ceramics, and cooking