DEV Community

Felice Forby
Felice Forby

Posted on • Edited on

Understanding Rails Polymorphic Associations: A Case Study

I recently got to implement a polymorphic table in Rails at work. I've read about polymorphism a few times in books or blogs, but I never quite understood it. Having a real-life situation to apply the concept to at work, along with some conversations with my team's lead engineer finally made it click!

A lot of the examples in other articles and in even in the documentation are somewhat contrived, so I'd like to share what I did on the job as a real-world mini case study in hopes that this helps someone else.

The situation

My company provides employee benefits (e.g. dental and vision insurance) for other companies and we have many types of users that can interact with our platform such as company HR admins, brokers who sell insurance, and the members who have insurance. Naturally, we need to send a lot of emails to these people. The goal of my work project was to consolidate the different email categories we have and better keep track of the recipients for emails that we send out. For example, we have emails related to invoicing and insurance renewals, and they are sent to different people depending on their role within the system.

In regards to modeling, I needed to model a table that represented recipients of a set of emails under a certain category, where one category like "Invoicing" could contain several different specific emails. I named the model EmailCategoryRecipient, and each "recipient" could be associated with different kinds of user profiles we have in the system: a CompanyContact (e.g. an HR person at a company) and a BrokerProfile (e.g. the broker who sold the insurance to the company). An EmailCategoryRecipient belongs to a profile, where the profiles could be different classes.

Considering the model relationships, it sounded exactly like the kind of situation a model with a polymorphic association could help with!

The polymorphic association and database table

Creating the polymorphic association

A polymorphic association lets you create a model that can belong to more than one class, but uses only one belongs_to association instead of multiple. This multifaceted association is usually represented by the name of the concept + the -able prefix. In my case, the associations were different kinds of profiles so I called it profileable, but you can name these associations anything you want.

Here's what it would look like to set up the polymorphic relationship in the models I mentioned above:

class EmailCategoryRecipient < ApplicationRecord
  belongs_to :profileable, polymorphic: true
end 

class CompanyContact < ApplicationRecord
  has_many :email_category_recipients, as: :profileable
end

class BrokerProfile < ApplicationRecord
  has_many :email_category_recipients, as: :profileable
end
Enter fullscreen mode Exit fullscreen mode

Again, even though EmailCategoryRecipient only has one belongs_to for profileable, it can represent different types of classes—a CompanyContact or a BrokerProfile. This is possible because I used the polymorphic: true option on the belongs_to association. As you can imagine, there could be many other things that need to have email recipients associated to them as well, like a Member or an Admin profile. Any number of these associations could be captured in the polymorphic profileable attribute.

Creating a migration to set up the polymorphic association

To make the above relationships work, you need to create a migration that sets up a table that can handle the polymorphic associations. Rails gives us handy shortcuts to do so. It would look something like this:

class CreateEmailCategoryRecipients < ActiveRecord::Migration[7.1]
  def change
    create_table :email_category_recipients do |t|
      t.string :category_name # just to keep track of category, this has nothing to do with the polymorphic piece
      t.references :profileable, polymorphic: true

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Even though it's only one line, the t.references :profileable, polymorphic: true will actually create two columns and an index. The columns created are an *_id column and *_type column which are based on the polymorphic name. (in this case profileable). This one-liner is a nice shortcut method, but you could also write the migration explicitly like below. It would be equivalent to the migration above:

class CreateEmailCategoryRecipients < ActiveRecord::Migration[7.1]
  def change
    create_table :email_category_recipients do |t|
      t.string  :category_name # again, this is just an additional attribute not related to polymorphic columns
      t.bigint  :profileable_id
      t.string  :profileable_type

      t.timestamps
    end

    add_index :email_category_recipients, [:profileable_type, :profileable_id]
  end
end
Enter fullscreen mode Exit fullscreen mode

Let me explain the two columns profileable_id and profileable_type. The profileable_id holds the id (primary key) of the associated object while the profileable_type keeps track of what kind of object it is, which is the stringified class name of that object ("CompanyContact" or "BrokerProfile" in this case). We need the *_type column in addition to the *_id column because Rails needs to know what database table to look in to fetch the associated record, as the different types are stored in separate database tables. When you assign an object as a profileable, Rails will take care of filling out these two columns automatically with that object's id and class.

Something to think about: Polymorphism is not the only way to do this

While polymorphic relationships are pretty cool and can provide an elegant solution, I want to call out that you don't actually need to use it every time you have to associate multiple similar classes to a model. In fact, there are a lot of times when it's not the right tool for the job.

The alternative is to just associate each thing separately with the standard belongs_to and respective *_id column.

Modeling EmailCategoryRecipient as a non-polymorphic relationship would look like this:

class EmailCategoryRecipient < ApplicationRecord
  belongs_to :company_contact
  belongs_to :broker_profile
end

class CompanyContact < ApplicationRecord
  has_many :email_category_recipients
end

class BrokerProfile < ApplicationRecord
  has_many :email_category_recipients
end
Enter fullscreen mode Exit fullscreen mode

The migration for the above setup would look like this:

class CreateEmailCategoryRecipients < ActiveRecord::Migration[7.1]
  def change
    create_table :email_category_recipients do |t|
      t.string :category_name
      # t.belongs_to is another shortcut method that will create indices for you too, unless specified not to
      t.belongs_to :company_contact
      t.belongs_to :broker_profile

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Or alternatively with out using the shortcut t.belongs_to:

class CreateEmailCategoryRecipients < ActiveRecord::Migration[7.1]
  def change
    create_table :email_category_recipients do |t|
      t.string :category_name
      t.bigint :company_contact_id
      t.bigint :broker_profile_id

      t.timestamps
    end

    add_index :email_category_recipients, :company_contact_id
    add_index :email_category_recipients, :broker_profile_id
  end
end
Enter fullscreen mode Exit fullscreen mode

If you were to go this route, you would need to assign any BrokerProfile objects directly to the EmailCategoryRecipient's broker_profile attribute (or the id to the broker_profile_id) and the same goes for CompanyContact.

When would you NOT want to use polymorphism

The big benefits of using polymorphic associations are that it helps keep the code clean, simple, and flexible.

That being said, I think that polymorphism can also make your code more abstract and therefore harder to understand at a glance, especially for people who aren't familiar with how polymorphic tables work or for someone who doesn't yet understand that model's relationship to other models. For example, it's not immediately obvious what classes profileable should refer to unless you just "know" from the get go. You may need to go searching through the codebase to find what classes can be associated with the polymorphic one.

Another thing to think about is whether the polymorphic objects you plan to associate with polymorphic type are similar or not. That is, do they share similar interface (attributes and methods) in places where it matters? It depends on the use case, but it may be extra hassle if the different classes aren't similar in their interface.

For example, if you needed to pull email off of CompanyContact and BrokerProfile but one model used the attribute name email and the other email_address, you would have to check the class type to figure out what method could be called, alias the method in one class to match the other, or come up with another solution to avoid getting errors if the wrong method gets called on the wrong object. If you need to do this for multiple attributes on these classes, it may defeat the purpose of using a polymorphic association in the first place.

You could also run into data integrity issues if the "wrong" kind of object gets saved into the polymorphic table or if a non-existent object gets saved into the table. With polymorphic associations, Rails does not check the referential integrity of the record, that is, does it actually exist in the database. Queries will generally be slower as well, since Rails needs to check both the id and the type of the object.

The final outcome

To bring this case study to an end, I want to touch on the final decision I made, which was actually not to use the polymorphic association. I ended up using explicit id columns for each associated object instead. Though I had already migrated the new tables, it was easy to changed because we hadn't started using them yet.

There are a couple reasons why I went the other direction. The main reason is that I found out we needed to have an additional association with the EmailCategoryRecipient model that was called PlatformConfig. The purpose of this model is to store configurations of the insurance partner platforms we work with, including the contact email for that partner. For our company, it represents another kind of recipient, but it is not a type of profile, and therefore it didn't make sense to consider it a profileable type. It also seemed awkward to have both a polymorphic entity for profiles and a separate non-polymorphic entity for the platforms.

The second reason is that the CompanyContact and BrokerProfile models were originally created at different times for different purposes. CompanyContact was implemented long before our concept of a user "profile" was even introduced. For our use case, it would have been ideal if we could "trust" that the different profileable objects have similar interfaces, but that wasn't necessarily the case, so we decided the explicit belongs_to relationships were more appropriate.

One of the drawbacks here is that when we need to associate another type of profile to EmailCategoryRecipient in the future, we will need to change the table in order to add another belongs_to relationship. On the other hand, having explicit relationships makes it more obvious what kind of object we are dealing with at any given time.

References

Top comments (0)