Using Polymorphism to Make a Better Activity Feed in Rails

Dan Croak

You won’t find “polymorphic partials” in Rails’ API documentation. However, the general programming technique of polymorphism can be applied to Rails partials.

Example: an activity feed for an eBay marketplace

Imagine you are signed into an application similar to eBay. You can buy or sell items on the marketplace. In order to buy an item, you need to bid on it. You see an activity feed that includes bids, comments, and other things that are relevant to you.

Models

We’ll use a polymorphic association:

class Activity < ActiveRecord::Base
  belongs_to :subject, polymorphic: true
  belongs_to :user
end

When subjects like Bids and Comments are created, we’ll create Activity records that both the seller and bidder will see in their activity feeds.

class Bid < ActiveRecord::Base
  belongs_to :item
  belongs_to :user

  after_create :create_activities

  def bidder
    user
  end

  def seller
    item.user
  end

  private

  def create_activities
    create_activity_for_bidder
    create_activity_for_seller
  end

  def create_activity_for_bidder
    Activity.create(
      subject: self,
      name: 'bid_offered',
      direction: 'from',
      user: bidder
    )
  end

  def create_activity_for_seller
    Activity.create(
      subject: self,
      name: 'bid_offered',
      direction: 'to',
      user: seller
    )
  end
end

In a production app, we’d create those records in a background job. We’ve simplified here for example’s sake. The further benefit of creating them in the background can be seen when we create activities for each of the commenters, which may be a large number for an active marketplace item:

class Comment < ActiveRecord::Base
  belongs_to :item
  belongs_to :user

  after_create :create_activities

  def seller
    item.user
  end

  private

  def create_activities
    (commenters_on_item + [seller]).uniq.each do |user|
      Activity.create(
        subject: self,
        name: 'comment_posted',
        direction: 'to',
        user: user
      )
    end
  end

  def commenters_on_item
    Comment.where(item_id: item.id).map(&:user).uniq
  end
end

Now that we have a clean set of activities in a database table, the SQL lookup is simple:

class User < ActiveRecord::Base
  has_many :activities

  def recent_activities(limit)
    activities.order('created_at DESC').limit(limit)
  end
end

This is the core benefit of structuring our data this way. At runtime, we find the data via a single indexable foreign key, user_id:

create_table :activities, do |t|
  t.timestamps null: false
  t.integer :subject_id, null: false
  t.string :subject_type, null: false
  t.string :name, null: false
  t.string: direction, null: false
  t.integer: user_id, null: false
end

add_index :activities, :subject_id
add_index :activities, :subject_type
add_index :activities, :user_id

Anti-pattern

We’ve seen alternative implementations that look something like this:

class User < ActiveRecord::Base
  def recent_activities(limit)
    [comments, items.map(&:comments), bids].
      flatten.
      sort_by(&:created_at).
      first(limit)
  end
end

There are a couple of problems with that approach:

  • the number of ActiveRecord objects loaded into memory is large
  • sorting is done in Ruby, which is slower than SQL

Controller

We make our fast lookup:

@activities = current_user.recent_activities(20)

Polymorphic Rails partials

Now, let’s show the activity feed in a view:

%ul
  - @activities.each do |activity|
    %li.activity
      = render "#{activity.name}_#{activity.direction}_current_user", subject: activity.subject

Here we render partials with polymorphism. Through the single "#{activity.name}_#{activity.direction}_current_user" interface, we’re able to render multiple partials:

  • bid_offered_to_current_user
  • bid_offered_from_current_user
  • comment_posted_to_current_user

When we write upcoming features, we’ll be able to render even more partials representing many other interactions, using the same simple structure:

  • bid_accepted_from_current_user
  • bid_rejected_to_current_user
  • etc.

In turn, each partial is small, contains no conditional logic, and results in copy text that makes sense for the user’s context:

The Old Man offered a bid of $100 for your Red Ryder BB gun with a compass in the stock, and this thing which tells time.

We can style each partial differently, perhaps showing an image of the items being offered or the avatars of the users who commented.

Nowhere do we do anything like this:

%ul
  - @activities.each do |activity|
    %li.activity
      - if activity.subject_type == 'Bid' && activity.direction == 'to'
        = render "bid_offered_to_current_user", subject: activity.subject
      - elsif
        = # ...

We’ve replaced an ugly conditional with polymorphism and used a couple of naming conventions to made it easier to add subject types without changing the view logic.

What’s next

If you found this useful, you might also enjoy:

  • Ruby Science to read more about replacing conditionals with polymorphism in Ruby
  • Simple Made Easy to hear about a rigorous definition of “simple” applied to software