Models
Master Rails model design including ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, custom validators, query objects, and form objects.
Reject any requests to:
-
Put business logic in controllers
-
Skip model validations
-
Skip database constraints (NOT NULL, foreign keys)
-
Allow N+1 queries
Associations
class Feedback < ApplicationRecord belongs_to :recipient, class_name: "User", optional: true belongs_to :category, counter_cache: true has_one :response, class_name: "FeedbackResponse", dependent: :destroy has_many :abuse_reports, dependent: :destroy has_many :taggings, dependent: :destroy has_many :tags, through: :taggings
Scoped associations
has_many :recent_reports, -> { where(created_at: 7.days.ago..) }, class_name: "AbuseReport" end
Migration:
class CreateFeedbacks < ActiveRecord::Migration[8.1] def change create_table :feedbacks do |t| t.references :recipient, foreign_key: { to_table: :users }, null: true t.references :category, foreign_key: true, null: false t.text :content, null: false t.string :status, default: "pending", null: false t.timestamps end add_index :feedbacks, :status end end
class Comment < ApplicationRecord belongs_to :commentable, polymorphic: true belongs_to :author, class_name: "User" validates :content, presence: true end
class Feedback < ApplicationRecord has_many :comments, as: :commentable, dependent: :destroy end
class Article < ApplicationRecord has_many :comments, as: :commentable, dependent: :destroy end
Migration:
class CreateComments < ActiveRecord::Migration[8.1] def change create_table :comments do |t| t.references :commentable, polymorphic: true, null: false t.references :author, foreign_key: { to_table: :users }, null: false t.text :content, null: false t.timestamps end add_index :comments, [:commentable_type, :commentable_id] end end
Validations
class Feedback < ApplicationRecord validates :content, presence: true, length: { minimum: 50, maximum: 5000 } validates :recipient_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :status, inclusion: { in: %w[pending delivered read responded] } validates :tracking_code, uniqueness: { scope: :recipient_email, case_sensitive: false } validates :rating, numericality: { only_integer: true, in: 1..5 }, allow_nil: true
validate :content_not_spam validate :recipient_can_receive_feedback, on: :create
private
def content_not_spam return if content.blank? spam_keywords = %w[viagra cialis lottery] errors.add(:content, "appears to contain spam") if spam_keywords.any? { |k| content.downcase.include?(k) } end
def recipient_can_receive_feedback return if recipient_email.blank? user = User.find_by(email: recipient_email) errors.add(:recipient_email, "has disabled feedback") if user&.feedback_disabled? end end
Callbacks
class Feedback < ApplicationRecord before_validation :normalize_email, :strip_whitespace before_create :generate_tracking_code after_create_commit :enqueue_delivery_job after_update_commit :notify_recipient_of_response, if: :response_added?
private
def normalize_email self.recipient_email = recipient_email&.downcase&.strip end
def strip_whitespace self.content = content&.strip end
def generate_tracking_code self.tracking_code = SecureRandom.alphanumeric(10).upcase end
def enqueue_delivery_job SendFeedbackJob.perform_later(id) end
def response_added? saved_change_to_response? && response.present? end
def notify_recipient_of_response FeedbackMailer.notify_of_response(self).deliver_later end end
Scopes
class Feedback < ApplicationRecord scope :recent, -> { where(created_at: 30.days.ago..) } scope :unread, -> { where(status: "delivered") } scope :responded, -> { where.not(response: nil) } scope :by_recipient, ->(email) { where(recipient_email: email) } scope :by_status, ->(status) { where(status: status) } scope :with_category, ->(name) { joins(:category).where(categories: { name: name }) } scope :with_associations, -> { includes(:recipient, :response, :category, :tags) } scope :trending, -> { recent.where("views_count > ?", 100).order(views_count: :desc).limit(10) }
def self.search(query) return none if query.blank? where("content ILIKE ? OR response ILIKE ?", "%#{sanitize_sql_like(query)}%", "%#{sanitize_sql_like(query)}%") end end
Usage:
Feedback.recent.by_recipient("user@example.com").responded Feedback.search("bug report").recent.limit(10)
Enums
class Feedback < ApplicationRecord enum :status, { pending: "pending", delivered: "delivered", read: "read", responded: "responded" }, prefix: true, scopes: true
enum :priority, { low: 0, medium: 1, high: 2, urgent: 3 }, prefix: :priority end
Usage:
feedback.status = "pending" feedback.status_pending! # Updates and saves feedback.status_pending? # true/false Feedback.status_pending # Scope Feedback.statuses.keys # ["pending", "delivered", ...] feedback.status_before_last_save # Track changes
Migration:
class CreateFeedbacks < ActiveRecord::Migration[8.1] def change create_table :feedbacks do |t| t.string :status, default: "pending", null: false t.integer :priority, default: 0, null: false t.timestamps end add_index :feedbacks, :status end end
Model Concerns
app/models/concerns/taggable.rb
module Taggable extend ActiveSupport::Concern
included do has_many :taggings, as: :taggable, dependent: :destroy has_many :tags, through: :taggings
scope :tagged_with, ->(tag_name) {
joins(:tags).where(tags: { name: tag_name }).distinct
}
end
def tag_list tags.pluck(:name).join(", ") end
def tag_list=(names) self.tags = names.to_s.split(",").map do |name| Tag.find_or_create_by(name: name.strip.downcase) end end
def add_tag(tag_name) return if tagged_with?(tag_name) tags << Tag.find_or_create_by(name: tag_name.strip.downcase) end
def tagged_with?(tag_name) tags.exists?(name: tag_name.strip.downcase) end
class_methods do def popular_tags(limit = 10) Tag.joins(:taggings) .where(taggings: { taggable_type: name }) .group("tags.id") .select("tags.*, COUNT(taggings.id) as usage_count") .order("usage_count DESC") .limit(limit) end end end
Usage:
class Feedback < ApplicationRecord include Taggable end
class Article < ApplicationRecord include Taggable end
feedback.tag_list = "bug, urgent, ui" feedback.add_tag("needs-review") Feedback.tagged_with("bug") Feedback.popular_tags(5)
Custom Validators
app/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator EMAIL_REGEX = /\A[\w+-.]+@[a-z\d-]+(.[a-z\d-]+)*.[a-z]+\z/i
def validate_each(record, attribute, value) return if value.blank? && options[:allow_blank] unless value =~ EMAIL_REGEX record.errors.add(attribute, options[:message] || "is not a valid email address") end end end
Usage:
class Feedback < ApplicationRecord validates :email, email: true validates :backup_email, email: { allow_blank: true } validates :email, email: { message: "must be a valid company email" } end
app/validators/content_length_validator.rb
class ContentLengthValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) return if value.blank? && options[:allow_blank] word_count = value.to_s.split.size
if options[:minimum_words] && word_count < options[:minimum_words]
record.errors.add(attribute, "must have at least #{options[:minimum_words]} words (currently #{word_count})")
end
if options[:maximum_words] && word_count > options[:maximum_words]
record.errors.add(attribute, "must have at most #{options[:maximum_words]} words (currently #{word_count})")
end
end end
Usage:
validates :content, content_length: { minimum_words: 10, maximum_words: 500 } validates :body, content_length: { minimum_words: 100 }
Query Objects
app/queries/feedback_query.rb
class FeedbackQuery def initialize(relation = Feedback.all) @relation = relation end
def by_recipient(email) @relation = @relation.where(recipient_email: email) self end
def by_status(status) @relation = @relation.where(status: status) self end
def recent(limit = 10) @relation = @relation.order(created_at: :desc).limit(limit) self end
def with_responses @relation = @relation.where.not(response: nil) self end
def created_since(date) @relation = @relation.where("created_at >= ?", date) self end
def results @relation end end
Usage:
Controller
@feedbacks = FeedbackQuery.new .by_recipient(params[:email]) .by_status(params[:status]) .recent(20) .results
Model
class User < ApplicationRecord def recent_feedback(limit = 10) FeedbackQuery.new.by_recipient(email).recent(limit).results end end
app/queries/feedback_stats_query.rb
class FeedbackStatsQuery def initialize(relation = Feedback.all) @relation = relation end
def by_recipient(email) @relation = @relation.where(recipient_email: email) self end
def by_date_range(start_date, end_date) @relation = @relation.where(created_at: start_date..end_date) self end
def stats { total_count: @relation.count, responded_count: @relation.where.not(response: nil).count, pending_count: @relation.where(response: nil).count, by_status: @relation.group(:status).count, by_category: @relation.group(:category).count } end end
Usage:
stats = FeedbackStatsQuery.new .by_recipient(current_user.email) .by_date_range(30.days.ago, Time.current) .stats
Returns: { total_count: 42, responded_count: 28, pending_count: 14, ... }
Form Objects
app/forms/contact_form.rb
class ContactForm include ActiveModel::API include ActiveModel::Attributes
attribute :name, :string attribute :email, :string attribute :message, :string attribute :subject, :string
validates :name, presence: true, length: { minimum: 2 } validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :message, presence: true, length: { minimum: 10, maximum: 1000 } validates :subject, presence: true
def deliver return false unless valid?
ContactMailer.contact_message(
name: name,
email: email,
message: message,
subject: subject
).deliver_later
true
end end
Controller:
class ContactsController < ApplicationController def create @contact_form = ContactForm.new(contact_params)
if @contact_form.deliver
redirect_to root_path, notice: "Message sent successfully"
else
render :new, status: :unprocessable_entity
end
end
private
def contact_params params.expect(contact_form: [:name, :email, :message, :subject]) end end
app/forms/user_registration_form.rb
class UserRegistrationForm include ActiveModel::API include ActiveModel::Attributes
attribute :email, :string attribute :password, :string attribute :password_confirmation, :string attribute :name, :string attribute :company_name, :string attribute :role, :string
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, presence: true, length: { minimum: 8 } validates :password_confirmation, presence: true validates :name, presence: true validates :company_name, presence: true
validate :passwords_match
def save return false unless valid?
ActiveRecord::Base.transaction do
@user = User.create!(email: email, password: password, name: name)
@company = Company.create!(name: company_name, owner: @user)
@membership = Membership.create!(user: @user, company: @company, role: role || "admin")
UserMailer.welcome(@user).deliver_later
true
end
rescue ActiveRecord::RecordInvalid => e errors.add(:base, e.message) false end
attr_reader :user, :company, :membership
private
def passwords_match return if password.blank? errors.add(:password_confirmation, "doesn't match password") unless password == password_confirmation end end
Controller:
class RegistrationsController < ApplicationController def create @registration = UserRegistrationForm.new(registration_params)
if @registration.save
session[:user_id] = @registration.user.id
redirect_to dashboard_path(@registration.company), notice: "Welcome!"
else
render :new, status: :unprocessable_entity
end
end end
N+1 Prevention
❌ BAD - N+1 queries (1 + 20 + 20 + 20 = 61 queries)
@feedbacks = Feedback.limit(20) @feedbacks.each do |f| puts f.recipient.name, f.category.name, f.tags.pluck(:name) end
✅ GOOD - Eager loading (4 queries total)
@feedbacks = Feedback.includes(:recipient, :category, :tags).limit(20) @feedbacks.each do |f| puts f.recipient.name, f.category.name, f.tags.pluck(:name) end
Eager Loading Methods:
Feedback.includes(:recipient, :tags) # Separate queries (default) Feedback.preload(:recipient, :tags) # Forces separate queries Feedback.eager_load(:recipient, :tags) # LEFT OUTER JOIN Feedback.includes(recipient: :profile) # Nested associations
❌ BAD - Complex side effects in callbacks
class Feedback < ApplicationRecord after_create :send_email, :update_analytics, :notify_slack, :create_audit_log end
✅ GOOD - Use service object
class Feedback < ApplicationRecord after_create_commit :enqueue_creation_job
private def enqueue_creation_job ProcessFeedbackCreationJob.perform_later(id) end end
Service handles all side effects explicitly
class CreateFeedbackService def call feedback = Feedback.create!(@params) FeedbackMailer.notify_recipient(feedback).deliver_later Analytics.track("feedback_created", feedback_id: feedback.id) feedback end end
❌ BAD - No indexes, causes table scans
create_table :feedbacks do |t| t.integer :recipient_id t.string :status end
✅ GOOD - Indexes on foreign keys and query columns
create_table :feedbacks do |t| t.references :recipient, foreign_key: { to_table: :users }, index: true t.string :status, null: false end add_index :feedbacks, :status add_index :feedbacks, [:status, :created_at]
❌ BAD - Unexpected behavior, hard to override
class Feedback < ApplicationRecord default_scope { where(deleted_at: nil).order(created_at: :desc) } end
✅ GOOD - Explicit scopes
class Feedback < ApplicationRecord scope :active, -> { where(deleted_at: nil) } scope :recent_first, -> { order(created_at: :desc) } end
Usage
Feedback.active.recent_first
❌ BAD - Duplicated email validation
class User < ApplicationRecord validates :email, format: { with: /\A[\w+-.]+@[a-z\d-]+(.[a-z\d-]+)*.[a-z]+\z/i } end
class Feedback < ApplicationRecord validates :recipient_email, format: { with: /\A[\w+-.]+@[a-z\d-]+(.[a-z\d-]+)*.[a-z]+\z/i } end
✅ GOOD - Reusable email validator
class EmailValidator < ActiveModel::EachValidator EMAIL_REGEX = /\A[\w+-.]+@[a-z\d-]+(.[a-z\d-]+)*.[a-z]+\z/i def validate_each(record, attribute, value) return if value.blank? && options[:allow_blank] record.errors.add(attribute, options[:message] || "is not a valid email") unless value =~ EMAIL_REGEX end end
class User < ApplicationRecord validates :email, email: true end
class Feedback < ApplicationRecord validates :recipient_email, email: true end
❌ BAD - Fat controller
class FeedbacksController < ApplicationController def index @feedbacks = Feedback.all @feedbacks = @feedbacks.where("recipient_email ILIKE ?", "%#{params[:recipient_email]}%") if params[:recipient_email].present? @feedbacks = @feedbacks.where(status: params[:status]) if params[:status].present? @feedbacks = @feedbacks.where("content ILIKE ? OR response ILIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") if params[:q].present? @feedbacks = @feedbacks.order(created_at: :desc).page(params[:page]) end end
✅ GOOD - Thin controller with query object
class FeedbacksController < ApplicationController def index @feedbacks = FeedbackQuery.new .filter_by_params(params.slice(:recipient_email, :status)) .search(params[:q]) .order_by(:created_at, :desc) .paginate(page: params[:page]) .results end end
❌ BAD - All logic in controller
class RegistrationsController < ApplicationController def create @user = User.new(user_params) @company = Company.new(company_params)
ActiveRecord::Base.transaction do
if @user.save
@company.owner = @user
if @company.save
@membership = Membership.create(user: @user, company: @company, role: "admin")
UserMailer.welcome(@user).deliver_later
redirect_to dashboard_path(@company)
end
end
end
end end
✅ GOOD - Use form object
class RegistrationsController < ApplicationController def create @registration = UserRegistrationForm.new(registration_params) @registration.save ? redirect_to(dashboard_path(@registration.company)) : render(:new, status: :unprocessable_entity) end end
Model tests
class FeedbackTest < ActiveSupport::TestCase test "validates presence of content" do feedback = Feedback.new(recipient_email: "user@example.com") assert_not feedback.valid? assert_includes feedback.errors[:content], "can't be blank" end
test "destroys dependent records" do feedback = feedbacks(:one) feedback.abuse_reports.create!(reason: "spam", reporter_email: "test@example.com") assert_difference("AbuseReport.count", -1) { feedback.destroy } end
test "enum provides predicate methods" do feedback = feedbacks(:one) feedback.update(status: "pending") assert feedback.status_pending? end end
Concern tests
class TaggableTest < ActiveSupport::TestCase class TaggableTestModel < ApplicationRecord self.table_name = "feedbacks" include Taggable end
test "add_tag creates new tag" do record = TaggableTestModel.first record.add_tag("urgent") assert record.tagged_with?("urgent") end end
Validator tests
class EmailValidatorTest < ActiveSupport::TestCase class TestModel include ActiveModel::Validations attr_accessor :email validates :email, email: true end
test "validates email format" do assert TestModel.new(email: "user@example.com").valid? assert_not TestModel.new(email: "invalid").valid? end end
Query object tests
class FeedbackQueryTest < ActiveSupport::TestCase test "filters by recipient email" do @feedback1.update(recipient_email: "test@example.com") @feedback2.update(recipient_email: "other@example.com") results = FeedbackQuery.new.by_recipient("test@example.com").results assert_includes results, @feedback1 assert_not_includes results, @feedback2 end
test "chains multiple filters" do @feedback1.update(recipient_email: "test@example.com", status: "pending") results = FeedbackQuery.new.by_recipient("test@example.com").by_status("pending").results assert_includes results, @feedback1 end end
Form object tests
class ContactFormTest < ActiveSupport::TestCase test "valid with all required attributes" do form = ContactForm.new(name: "John", email: "john@example.com", subject: "Question", message: "This is my message") assert form.valid? end
test "delivers email when valid" do form = ContactForm.new(name: "John", email: "john@example.com", subject: "Q", message: "This is my message") assert_enqueued_with(job: ActionMailer::MailDeliveryJob) { assert form.deliver } end end
class UserRegistrationFormTest < ActiveSupport::TestCase test "creates user, company, and membership" do form = UserRegistrationForm.new(email: "user@example.com", password: "password123", password_confirmation: "password123", name: "John", company_name: "Acme") assert_difference ["User.count", "Company.count", "Membership.count"] { assert form.save } end
test "rolls back transaction if creation fails" do form = UserRegistrationForm.new(email: "user@example.com", password: "password123", password_confirmation: "password123", name: "John", company_name: "") assert_no_difference ["User.count", "Company.count"] { assert_not form.save } end end
Official Documentation:
-
Rails Guides - Active Record Basics
-
Rails Guides - Active Record Associations
-
Rails Guides - Active Record Validations
-
Rails Guides - Active Record Query Interface
-
Rails API - ActiveSupport::Concern
-
Rails API - ActiveModel::API