Let Rails Help You

Matheus Richard in Brazil

I’ve recently built a favoriting system for an app. While the code isn’t novel, it reminded me how clean, flexible, and fun Rails can be when you lean into the framework’s conventions. Here’s how I did it - I hope you’ll learn something along the way.

The requirements

My team wanted users to mark records as favorite in the system. This would make those records handy for quick access. We needed to support several existing models—and likely more in the future—so it had to be flexible and reusable. Also, multiple users should be able to favorite the same record (some records are “public”, but we’ll skip how that works for this post).

The solution

So we need a way to mark a record as favorite. Instead of a boolean field on each favoritable model, I created a separate model to represent the relationship between a user and a favoritable. That way, any record with an associated FavoriteRecord is considered favorited. While a bit more complex, this lets us make any model favoritable without writing new migrations. We’ll use a polymorphic association to allow any model to be favoritable.

The models

The database structure will look like this:

A diagram showing the database structure for a favoriting system. It shows:
A favorite_records table with a owner_id, favoritable_id and favoritable_type columns;
A user table with an id column;
A "favoritable_model" table with an id column;
Both the user and favoritable_model tables have a has_many relationship with the favorite_records table.

And the migration looks like this:

class CreateFavoriteRecords < ActiveRecord::Migration[7.2]
  def change
    create_table :favorite_records, id: :uuid do |t|
      t.references :owner, foreign_key: { to_table: :users }, null: false, type: :uuid
      t.references :favoritable, polymorphic: true, null: false, type: :uuid

      t.timestamps
    end

    add_index :favorite_records, [:owner_id, :favoritable_type, :favoritable_id], unique: true, name: 'index_favorite_records_uniqueness'
  end
end

And here’s our humble FavoriteRecord model:

class FavoriteRecord < ApplicationRecord
  belongs_to :owner, class_name: 'User'
  belongs_to :favoritable, polymorphic: true

  # a user can only favorite a record once
  validates :owner, uniqueness: { scope: [:favoritable_type, :favoritable_id] }
end

Let’s work on the other side of the association now. First, we make users have favorite records:

class User < ApplicationRecord
  has_many :favorite_records, foreign_key: :owner_id, dependent: :destroy
end

Then, let’s make a model favoritable. For this example, I’ll use a Note model. Instead of writing this directly in Note, I’ll create a Favoritable concern so we can share this behavior with other models.

module Favoritable
  extend ActiveSupport::Concern

  included do
    has_many :favorite_records, as: :favoritable, dependent: :destroy

    scope :favorite, -> { favorited_by(Current.user) }
    scope :not_favorite, -> { where.missing(:favorite_records) }
    scope :favorited_by, ->(owner) {
      joins(:favorite_records)
        .where(favorite_records: { owner: })
    }
  end

  def favorite!
    favorite_records.create!(owner: Current.user)
  end

  def unfavorite!
    favorite_record_for(Current.user)&.destroy
  end

  def favoritable_sgid
    to_sgid(for: :favoritable, expires_in: nil)
  end

  private

  def favorite_record_for(user)
    favorite_records.find { it.owner_id == user.id }
  end
end

So now Note just includes that concern and gets all that functionality.

The controller

We can mark notes as favorite now and we have a few helpful scopes. Let’s work on the next level of abstraction: the controller.

We could create a FavoriteNotesController, but since we want to stay model-agnostic, we’ll go with a generic FavoriteRecordsController. Similarly, instead of using model_id and model_type params, we’ll use a favoritable_sgid Signed Global ID. This prevents users from messing with the params and trying to favorite records that they shouldn’t be able to.

Global IDs are a cool Rails feature that allows you to have a unique identifier for any record in your system. Exactly what we want. We’re using a signed version, which is a more secure version that prevents tampering with the ID.

class FavoriteRecordsController < ApplicationController
  before_action :set_favoritable

  def create
    @favoritable.favorite!

    redirect_back_or_to @favoritable
  end

  def destroy
    @favoritable.unfavorite!

    redirect_back_or_to @favoritable
  end

  private

  def set_favoritable
    @favoritable = GlobalID::Locator.locate_signed(
      params[:favoritable_sgid],
      for: :favoritable
    )
  end
end

Signed IDs help us make this controller look just like any other Rails controller. Our helpers in Favoritable take care of the rest. Pretty neat!

And the route will look like this:

Rails.application.routes.draw do
  resource :favorite_records,
    param: :favoritable_sgid,
    only: %i[create destroy]
end

The view

When rendering each record in an index view, we can add a button to favorite/unfavorite it. Let’s create a partial for this, so we can reuse it in several models:

<%# app/views/application/_favorite_record_button.html.erb %>
<% if favoritable.favorited_by? Current.user %>
  <%= button_to 'Unfavorite',
    favorite_records_path(favoritable_sgid: favoritable.favoritable_sgid),
    method: :delete %>
<% else %>
  <%= button_to 'Favorite',
    favorite_records_path(favoritable_sgid: favoritable.favoritable_sgid),
    method: :post %>
<% end %>

So in app/views/notes/_note.html.erb, we can render this partial:

<%= render 'favorite_record_button', favoritable: note %>

The Rails-way makes custom features feel native

I hope this highlighted how Rails (and Ruby!) helps you build flexible code when you lean into its conventions. We modeled our task into something that looks like it came out of a Rails generator, so it fits smoothly into the framework.

When you’re designing a new feature, think about how you can use Rails conventions to make it easier to build and maintain. Modeling work into the MVC pattern makes it easier to build it in Rails, because the framework is optimized for that.