Skip to content

Instantly share code, notes, and snippets.

@prfraser
Created January 8, 2026 05:45
Show Gist options
  • Select an option

  • Save prfraser/cbfb375d3489128133e3dc890fd38556 to your computer and use it in GitHub Desktop.

Select an option

Save prfraser/cbfb375d3489128133e3dc890fd38556 to your computer and use it in GitHub Desktop.

Polymorphic Voting System - Senior Task

The Task

"We want users to be able to upvote and downvote both books and reviews. How would you approach this?"


What They Need to Build

  • Single votes table with polymorphic association
  • Vote model with votable_type and votable_id
  • Votable concern to DRY up shared logic
  • Single controller handling both votable types
  • Reusable view partial with polymorphic_path

Good Solution

1. Migration

# db/migrate/xxx_create_votes.rb
class CreateVotes < ActiveRecord::Migration[7.1]
  def change
    create_table :votes do |t|
      t.references :user, null: false, foreign_key: true
      t.references :votable, polymorphic: true, null: false
      t.integer :value, null: false
      t.timestamps
    end

    add_index :votes, [:user_id, :votable_type, :votable_id], unique: true
    add_index :votes, [:votable_type, :votable_id]
  end
end

Look for:

  • polymorphic: true on references
  • Unique composite index includes votable_type
  • Second index for querying votes by votable

2. Models

# app/models/vote.rb
class Vote < ApplicationRecord
  belongs_to :user
  belongs_to :votable, polymorphic: true

  validates :value, inclusion: { in: [1, -1] }
  validates :votable_id, uniqueness: { scope: [:user_id, :votable_type] }

  scope :upvotes, -> { where(value: 1) }
  scope :downvotes, -> { where(value: -1) }
end
# app/models/concerns/votable.rb
module Votable
  extend ActiveSupport::Concern

  included do
    has_many :votes, as: :votable, dependent: :destroy
  end

  def score
    votes.sum(:value)
  end

  def vote_from(user)
    return nil unless user
    votes.find_by(user: user)
  end
end
# app/models/review.rb (add)
include Votable
# app/models/book.rb (add)
include Votable
# app/models/user.rb (add)
has_many :votes, dependent: :destroy

Look for:

  • Concern extraction (not duplicating methods in both models)
  • Polymorphic belongs_to and has_many :as
  • Uniqueness scoped to both user_id AND votable_type

3. Routes

# config/routes.rb (add)
concern :votable do
  resource :vote, only: [:create, :destroy]
end

resources :books, concerns: :votable
resources :reviews, concerns: :votable

Generates:

  • POST /books/:book_id/vote
  • DELETE /books/:book_id/vote
  • POST /reviews/:review_id/vote
  • DELETE /reviews/:review_id/vote

Alternative (also acceptable):

resources :books do
  resource :vote, only: [:create, :destroy]
end

resources :reviews do
  resource :vote, only: [:create, :destroy]
end

Look for: Singular resource :vote, routes for both models.


4. Controller

# app/controllers/votes_controller.rb
class VotesController < ApplicationController
  before_action :require_authenticated_user
  before_action :set_votable

  def create
    value = params[:value].to_i

    unless [1, -1].include?(value)
      return redirect_back fallback_location: @votable, alert: "Invalid vote"
    end

    vote = current_user.votes.find_or_initialize_by(votable: @votable)
    vote.value = value

    if vote.save
      redirect_back fallback_location: @votable
    else
      redirect_back fallback_location: @votable, alert: "Could not save vote"
    end
  end

  def destroy
    vote = current_user.votes.find_by(votable: @votable)
    vote&.destroy
    redirect_back fallback_location: @votable
  end

  private

  VOTABLE_TYPES = {
    'book_id' => Book,
    'review_id' => Review
  }.freeze

  def set_votable
    VOTABLE_TYPES.each do |param, klass|
      if params[param]
        @votable = klass.find(params[param])
        return
      end
    end
    raise ActiveRecord::RecordNotFound
  end
end

Look for:

  • Single controller for all votable types
  • Clean votable lookup pattern
  • find_or_initialize_by for upsert behavior
  • Value validation (allowlist)

Alternative set_votable (also acceptable):

def set_votable
  if params[:book_id]
    @votable = Book.find(params[:book_id])
  elsif params[:review_id]
    @votable = Review.find(params[:review_id])
  else
    raise ActiveRecord::RecordNotFound
  end
end

5. View

<%# app/views/votes/_controls.html.erb %>
<%# locals: (votable:) %>

<div class="vote-controls">
  <span class="score"><%= votable.score %></span>

  <% if current_user %>
    <% current_vote = votable.vote_from(current_user) %>
    <% path = polymorphic_path([votable, :vote]) %>

    <%= button_to path,
        params: { value: 1 },
        method: :post,
        class: current_vote&.value == 1 ? "btn-voted" : "btn-vote" do %><% end %>

    <%= button_to path,
        params: { value: -1 },
        method: :post,
        class: current_vote&.value == -1 ? "btn-voted" : "btn-vote" do %><% end %>

    <% if current_vote %>
      <%= button_to "Clear", path, method: :delete, class: "btn-clear" %>
    <% end %>
  <% end %>
</div>

Usage:

<%# books/show.html.erb %>
<%= render "votes/controls", votable: @book %>

<%# reviews/_review.html.erb %>
<%= render "votes/controls", votable: review %>

Look for:

  • polymorphic_path([votable, :vote]) - generates correct URL for any votable
  • Single reusable partial
  • Vote state indication

Hints to Give If Stuck

If stuck on... Hint
Polymorphic syntax "Rails has a polymorphic: true option on references"
Single controller for both "What if the controller didn't care which type it was voting on?"
Finding the votable "How do you know if it's a book or review from the params?"
Reusable view partial "Rails has polymorphic_path for generating URLs"
Concern extraction "Both models need the same methods - where could those live?"

File Summary

When complete, they should have created/modified:

app/
├── controllers/
│   └── votes_controller.rb (new)
├── models/
│   ├── concerns/
│   │   └── votable.rb (new)
│   ├── vote.rb (new)
│   ├── book.rb (add: include Votable)
│   └── review.rb (add: include Votable)
└── views/
    └── votes/
        └── _controls.html.erb (new)

config/
└── routes.rb (modified)

db/migrate/
└── xxx_create_votes.rb (new)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment