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:
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.