Polymorphic associations are one of Ruby on Rails’ most powerful features, allowing developers to create flexible relationships between models. I’ve spent years refining my approach to these associations, and I’d like to share five techniques that have consistently improved performance and maintainability in my applications.
Optimizing Database Indexes for Polymorphic Associations
Proper indexing is crucial for polymorphic associations. Without the right indexes, your database queries can slow to a crawl as your application scales.
The standard polymorphic association creates two columns: {name}_type
and {name}_id
. By default, Rails doesn’t automatically create indexes on these columns, which can lead to performance issues as your data grows.
I always ensure my migrations include composite indexes on both columns:
class AddIndexToComments < ActiveRecord::Migration[6.1]
def change
add_index :comments, [:commentable_type, :commentable_id]
end
end
This composite index significantly improves query performance when fetching records based on their polymorphic relationship. In high-traffic applications, I’ve seen query times improve by 10-20x with proper indexing.
For cases where I frequently query by just the type column, I add a separate index:
add_index :comments, :commentable_type
Remember that indexes have a cost during write operations, so I’m selective about adding them based on my query patterns.
Implementing Efficient Eager Loading Strategies
N+1 queries can devastate performance in applications with polymorphic associations. The challenge is that standard eager loading approaches don’t work well with polymorphic relationships because different types of models need to be loaded.
I’ve found two effective strategies:
First, if I know which types I’ll be loading, I can split the query by type:
def load_commentables_efficiently(comments)
# Group comments by commentable_type
comments_by_type = comments.group_by(&:commentable_type)
# Preload each type separately
comments_by_type.each do |type, type_comments|
klass = type.constantize
ids = type_comments.map(&:commentable_id)
records = klass.where(id: ids).index_by(&:id)
# Assign the preloaded records to comments
type_comments.each do |comment|
comment.instance_variable_set(:@commentable, records[comment.commentable_id])
end
end
comments
end
# Usage
comments = Comment.limit(100)
load_commentables_efficiently(comments)
For Rails 5.2+, I use the improved preloading API:
comments = Comment.includes(:commentable).where(id: comment_ids)
This works because Rails now intelligently handles polymorphic associations during eager loading.
Creating Type-Specific Association Scopes
Working with polymorphic associations often requires filtering by type. I’ve found that adding scopes to both sides of the association creates cleaner, more maintainable code:
# In the Comment model
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
scope :for_posts, -> { where(commentable_type: 'Post') }
scope :for_photos, -> { where(commentable_type: 'Photo') }
scope :for_videos, -> { where(commentable_type: 'Video') }
end
# In the parent models
class Post < ApplicationRecord
has_many :comments, as: :commentable
has_many :recent_comments, -> { order(created_at: :desc).limit(5) },
as: :commentable,
class_name: 'Comment'
end
These scopes make my code more readable and allow for more efficient queries. I can now write:
post.recent_comments
# Instead of
post.comments.order(created_at: :desc).limit(5)
The database receives optimized queries, and my code is more expressive.
Implementing Type-Specific Validations and Callbacks
Different parent models often need different validation rules for their associated records. I’ve developed a pattern for type-specific validations that keeps my code clean and maintainable:
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
# Apply validations based on the commentable type
validate :apply_type_specific_validations
private
def apply_type_specific_validations
case commentable_type
when 'Post'
validate_for_post
when 'Photo'
validate_for_photo
when 'Video'
validate_for_video
end
end
def validate_for_post
errors.add(:content, "must be at least 10 characters for posts") if content.present? && content.length < 10
end
def validate_for_photo
errors.add(:content, "must be focused on the image") unless content.present? && content.include?('photo')
end
def validate_for_video
errors.add(:content, "must include a timestamp for videos") unless content.present? && content.match?(/\d{2}:\d{2}/)
end
end
This approach keeps validation logic organized and makes it easier to add new parent types. I also apply a similar pattern to callbacks:
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
after_create :process_based_on_type
private
def process_based_on_type
case commentable_type
when 'Post'
notify_post_author
when 'Photo'
tag_photo_subjects
when 'Video'
add_to_video_transcript
end
end
def notify_post_author
CommentMailer.post_comment_notification(self).deliver_later
end
def tag_photo_subjects
# Logic for photo comments
end
def add_to_video_transcript
# Logic for video comments
end
end
Optimizing Query Performance with Counter Caches
Counting associated records is a common operation that can become expensive with polymorphic associations. I implement custom counter caches to solve this problem:
# In the parent models
class Post < ApplicationRecord
has_many :comments, as: :commentable
end
class Photo < ApplicationRecord
has_many :comments, as: :commentable
end
# In the Comment model
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
after_create :increment_counter_cache
after_destroy :decrement_counter_cache
private
def increment_counter_cache
update_counter_cache(1)
end
def decrement_counter_cache
update_counter_cache(-1)
end
def update_counter_cache(value)
return unless commentable.present?
if commentable.class.column_names.include?('comments_count')
# Use direct SQL to avoid race conditions
commentable.class.where(id: commentable.id).update_all(
"comments_count = COALESCE(comments_count, 0) + #{value}"
)
end
end
end
# In the migration
class AddCommentsCountToPosts < ActiveRecord::Migration[6.1]
def change
add_column :posts, :comments_count, :integer, default: 0
add_column :photos, :comments_count, :integer, default: 0
# Populate existing counts
reversible do |dir|
dir.up do
execute <<-SQL
UPDATE posts
SET comments_count = (
SELECT COUNT(*)
FROM comments
WHERE commentable_type = 'Post' AND commentable_id = posts.id
)
SQL
execute <<-SQL
UPDATE photos
SET comments_count = (
SELECT COUNT(*)
FROM comments
WHERE commentable_type = 'Photo' AND commentable_id = photos.id
)
SQL
end
end
end
end
With counter caches in place, I can call post.comments_count
instead of post.comments.count
, avoiding expensive database queries. In my real-world applications, this technique has reduced page load times by up to 30% for pages that display multiple parent objects with their comment counts.
I also create a rake task to repair counter caches if they get out of sync:
namespace :counters do
desc "Reset counter caches for polymorphic associations"
task reset: :environment do
puts "Resetting comment counters for Posts..."
Post.find_each do |post|
Post.reset_counters(post.id, :comments)
end
puts "Resetting comment counters for Photos..."
Photo.find_each do |photo|
Photo.reset_counters(photo.id, :comments)
end
puts "Counter caches reset successfully!"
end
end
Bonus Technique: STI with Polymorphic Associations
For more complex systems, I sometimes combine Single Table Inheritance (STI) with polymorphic associations to create powerful, flexible relationship structures:
# Base class for activities
class Activity < ApplicationRecord
belongs_to :trackable, polymorphic: true
belongs_to :user
end
# Specific activity types
class CommentActivity < Activity
validates :trackable, presence: true
after_create :notify_post_author
private
def notify_post_author
if trackable_type == 'Post'
ActivityMailer.new_comment_notification(self).deliver_later
end
end
end
class LikeActivity < Activity
validates :trackable, presence: true
end
# Usage
post = Post.find(1)
user = User.find(1)
CommentActivity.create!(
trackable: post,
user: user,
data: { content: "Great article!" }
)
This approach gives me the benefits of polymorphic associations while keeping activity-specific logic organized in separate classes.
Real-World Implementation Example
Let me share a complete implementation I used for a content management system where different types of content (articles, videos, galleries) could have comments, tags, and ratings:
# Schema
# create_table :contents do |t|
# t.string :type
# t.string :title
# t.text :body
# t.integer :comments_count, default: 0
# t.integer :ratings_count, default: 0
# t.float :average_rating, default: 0.0
# t.timestamps
# end
#
# create_table :comments do |t|
# t.string :commentable_type
# t.integer :commentable_id
# t.integer :user_id
# t.text :body
# t.timestamps
# end
#
# create_table :ratings do |t|
# t.string :ratable_type
# t.integer :ratable_id
# t.integer :user_id
# t.integer :score
# t.timestamps
# end
# Base content class using STI
class Content < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
has_many :ratings, as: :ratable, dependent: :destroy
def self.types
%w[Article Video Gallery]
end
def rating_average
if ratings_count > 0
average_rating
else
nil
end
end
end
class Article < Content
validates :body, presence: true, length: { minimum: 500 }
end
class Video < Content
validates :body, presence: true
# Video-specific attributes and validations
end
class Gallery < Content
# Gallery-specific attributes and validations
end
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
belongs_to :user
validates :body, presence: true
after_create :increment_counter
after_destroy :decrement_counter
private
def increment_counter
update_counter(1)
end
def decrement_counter
update_counter(-1)
end
def update_counter(value)
commentable.class.where(id: commentable.id).update_all(
"comments_count = comments_count + #{value}"
)
end
end
class Rating < ApplicationRecord
belongs_to :ratable, polymorphic: true
belongs_to :user
validates :score, presence: true, inclusion: { in: 1..5 }
validates :user_id, uniqueness: { scope: [:ratable_id, :ratable_type] }
after_save :update_average_rating
after_destroy :update_average_rating
private
def update_average_rating
new_count = ratable.ratings.count
new_average = new_count > 0 ? ratable.ratings.average(:score).to_f : 0
ratable.class.where(id: ratable.id).update_all(
ratings_count: new_count,
average_rating: new_average
)
end
end
In my controllers, I use shared concerns to handle polymorphic relationships consistently:
module Commentable
extend ActiveSupport::Concern
included do
before_action :load_commentable, only: [:show, :comments]
end
def comments
@comments = @commentable.comments.includes(:user).page(params[:page])
render 'comments/index'
end
private
def load_commentable
resource = controller_name.singularize
@commentable = resource.classify.constantize.find(params[:id])
end
end
class ArticlesController < ApplicationController
include Commentable
# Controller actions
end
class VideosController < ApplicationController
include Commentable
# Controller actions
end
This approach lets me reuse logic across different content types while maintaining the flexibility of polymorphic associations.
Through years of working with Rails applications at scale, I’ve found these techniques to be invaluable for maintaining performance and code clarity. By optimizing database indexes, implementing efficient eager loading, creating type-specific scopes and validations, and using counter caches, you can build robust systems that leverage polymorphic associations without sacrificing performance.
Remember that each application has unique needs, so adapt these techniques to fit your specific requirements. Monitor your database performance and be prepared to refine your approach as your application grows.