Discover how polymorphic relationships in ActiveRecord can elegantly separate billing accounts from collaborative groups - a practical guide based on real-world implementation.
Introduction
I was recently implementing a feature to support billing multi-user accounts when I realized my initial approach was missing something. It became clear as I progressed that I made a mistake in assuming the work I was doing on creating multi-user accounts would also apply for a feature planned for later in the year which would allow users to collaborate with other users in groups.
My initial direction was flawed because I conflated two separate domain entities when planning: groups and accounts. Although similar in that they would represent aggregators of User
records, they were different in their purpose within the domain model. An Account
represents a record containing billing information for one or more users which connects to the subscription billing vendor. In contrast, a Group
represents an organizational unit tying together individual users working together in some capacity to perform tasks within the application.
I mistakenly assumed that I could use the Account
model to handle both areas of concern, but I could see how various gotcha’s could complicate things further down the line. It became clear that the Account
and Group
models needed to be separated, but how?
A User
record can be tied to an Account
record through a join table named account_memberships
. The account_memberships
table has two columns: user_id
, a foreign key to the users
table; and account_id
, a foreign key to the accounts
table. A User
can have zero or more AccountMembership
records, which are in turn tied to an Account
record. Likewise, an Account
can have zero or more AccountMembership
records tied to it which are in turn tied to different User
records. The relationships are modeled in the ERD below.
The implementation for a Group
is similar: a User
record and a Group
record can be tied together through a join table that contains foreign keys to both tables. In this instance, the join table would need a group_id
column that serves as a foreign key to the groups
table, but the rest of the implementation would remain the same.
While this implementation could work, we can do better.
Polymorphism
Polymorphism is a concept in which objects of different classes can receive and handle the same message being sent from another class. This relationship between objects can be implemented in several ways, including inheritance, encapsulation, or certain design patterns. Rails includes support for polymorphic relationships between entities that we can use to help simplify our code.
We can start by creating a single entity that is capable of joining both the Account
and Group
models with the User
model. Previously, we used the group_memberships
and account_memberships
tables as join tables, but this can be simplified to a single memberships
table that handles both.
This table will need a column for user_id
as a foreign key to the users
table, and it will also need two new columns: organizable_id
and organizable_type
. The organizable_id
is a foreign key to the table corresponding with the class name stored in the organizable_type
column. “Organizable” is simply a generic term that we can use to represent either an Account
or a Group
–you could use whatever makes sense for your scenario.
The final ERD shows how the memberships
table will serve as a single join table used by both the accounts
and groups
tables to relate to the users
table.
Implementation
To get started, we can add a migration to create the table with both columns. Using t.references
allows us to easily set-up foreign keys and indices, and for organizable
, we’ll need to specify polymorphic: true
so Rails knows that it should create the *_id
and *_type
columns.
1 2 3 4 5 6 7 8 9 10 |
class CreateMemberships < ActiveRecord::Migration[7.0] def change create_table :memberships do |t| t.references :user t.references :organizable, polymorphic: true t.timestamps end end end |
Next, we’ll add associations to the Membership
model which show that a Membership
belongs to a User
as well as an “organizable” (Account
or Group
). Using the polymorphic: true
option tells ActiveRecord that the association is polymorphic and it should use the *_id
and *_type
columns to derive the associated records.
1 2 3 4 |
class Membership < ApplicationRecord belongs_to :user belongs_to :organizable, polymorphic: true end |
We can add the has_many
association for the memberships
table to the Account
model, but we’ll need to specify the as: :organizable
option so ActiveRecord know the relationship is polymorphic, thus requiring lookup via *_id
and *_type
. The association to the users
is a has_many :through
relationship given that memberships
is a join table between the two.
1 2 3 4 |
class Account < ApplicationRecord has_many :memberships, as: :organizable has_many :users, through: :memberships end |
The Group
model has an identical implementation for its associations to Membership
and User
.
1 2 3 4 |
class Group < ApplicationRecord has_many :memberships, as: :organizable has_many :users, through: :memberships end |
The User
model will also need to have the association with memberships
added. However, the polymorphic nature of the organizable_id
and organizable_type
columns complicates things a bit when attempting to form the relationship between the User
model and the Group
and Account
models. We’ll need to pass the source
and source_type
options to specify some additional details about the relationship so that Rails knows which model we are providing the polymorphic relationship for.
1 2 3 4 5 |
class User < ApplicationRecord has_many :memberships has_many :accounts, through: :memberships, source: :organizable, source_type: 'Account' has_many :groups, through: :memberships, source: :organizable, source_type: 'Group' end |
Conclusion
We’ve shown that polymorphism can allow us to simplify our implementation of two related entities, and Rails makes this easy for us. When adding a reference column, we can pass the polymorphic: true
option to generate *_id
and *_type
columns which are used by ActiveRecord to look up associated records of different related models. These associations are defined in the corresponding models; we pass polymorphic: true
as an option for the belongs_to
association, and then add an association on the corresponding model with the as:
option provided.
This work is licensed under a Creative Commons Attribution 4.0 International License.