Use when designing Rails models - ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, query objects, form objects
Master Rails model design including ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, custom validators, query objects, and form objects.
<when-to-use> - Designing database models and associations - Writing validations and callbacks - Implementing business logic in models - Creating scopes and query methods - Extracting complex queries to query objects - Building form objects for multi-model operations - Organizing shared behavior with concerns - Creating custom validators - Preventing N+1 queries </when-to-use> <benefits> - **Convention Over Configuration** - Minimal setup for maximum functionality - **Single Responsibility** - Each pattern handles one concern - **Reusability** - Share behavior across models with concerns - **Testability** - Test models, concerns, validators in isolation - **Query Optimization** - Built-in N+1 prevention and eager loading - **Type Safety** - ActiveModel::Attributes provides type casting - **Database Agnostic** - Works with PostgreSQL, MySQL, SQLite </benefits> <team-rules-enforcement> **This skill enforces:** - ✅ **Rule #7:** Fat models, thin controllers (business logic in models) - ✅ **Rule #12:** Database constraints for data integrityReject any requests to:
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
</implementation>
<why>
Associations express relationships between models with minimal code. Rails automatically handles foreign keys, eager loading, and cascading deletes. Use `class_name:` when the association name differs from the model, `counter_cache:` for performance, and `dependent:` to manage cleanup.
</why>
</pattern>
<pattern name="polymorphic-associations">
<description>Flexible associations where a model belongs to multiple types</description>
<implementation>
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
</implementation>
<why>
Polymorphic associations allow a model to belong to multiple parent types through a single association. Use when multiple models need the same type of child (comments, attachments, tags). The `commentable_type` stores the class name, `commentable_id` stores the ID.
</why>
</pattern>
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
</implementation>
<why>
Validations enforce data integrity before persisting to the database. Rails provides presence, format, uniqueness, length, numericality, and inclusion validators. Custom `validate` methods handle complex business logic. Use `on: :create` or `on: :update` for lifecycle-specific validations.
</why>
</pattern>
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
</implementation>
<why>
Callbacks hook into the model lifecycle for simple data normalization and side effects. Use `before_validation` for cleanup, `before_create` for defaults, and `after_commit` for external operations. Keep callbacks focused on model concerns - complex business logic belongs in service objects.
</why>
</pattern>
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)
</implementation>
<why>
Scopes provide chainable query methods that keep controllers clean. Use scopes for simple filters, class methods for complex queries. Scopes are lazy-evaluated and composable. Use `includes()` in scopes to prevent N+1 queries.
</why>
</pattern>
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
</implementation>
<why>
Enums map human-readable states to database values with automatic predicates, scopes, and bang methods. Use string-backed enums for clarity in the database. The `prefix:` option prevents method name conflicts. Scopes make querying easy.
</why>
</pattern>
# 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)
</implementation>
<why>
Concerns extract shared behavior into reusable modules. Use `included do` for associations, validations, callbacks. Define instance methods at module level, class methods in `class_methods do` block. Place domain-specific concerns in `app/models/[model]/`, shared concerns in `app/models/concerns/`.
</why>
</pattern>
# 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
</implementation>
<why>
Custom validators encapsulate reusable validation logic. Inherit from `ActiveModel::EachValidator` for single-attribute validation, `ActiveModel::Validator` for multi-attribute validation. Support `:allow_blank` and `:message` options. Place in `app/validators/` for discoverability.
</why>
</pattern>
<pattern name="content-length-validator">
<description>Validate content by word count instead of character count</description>
<implementation>
# 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 }
</implementation>
<why>
Word count validation is more meaningful than character count for content fields. Custom validators make this reusable across models. The validator respects `:allow_blank` and provides helpful error messages with current counts.
</why>
</pattern>
# 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
</implementation>
<why>
Query objects encapsulate complex filtering, search, and aggregation logic. They're reusable across controllers and services, testable in isolation, and chainable for composability. Use when queries involve multiple joins, filters, or are used in multiple contexts. Return `self` for chaining, `results` to execute.
</why>
</pattern>
<pattern name="aggregation-query">
<description>Query object for aggregations and statistical calculations</description>
<implementation>
# 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, ... }
</implementation>
<why>
Query objects for aggregations centralize statistical calculations and reporting logic. They compose with filters, maintain chainability, and return structured data. Use for dashboards, reports, and analytics. Keep aggregation logic out of controllers and models.
</why>
</pattern>
# 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
</implementation>
<why>
Form objects handle non-database forms (contact, search) and complex multi-model operations. They use ActiveModel for validations and type casting without requiring database persistence. Return boolean from action methods, validate before executing logic.
</why>
</pattern>
<pattern name="multi-model-form">
<description>Form object that creates multiple related models in a transaction</description>
<implementation>
# 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
</implementation>
<why>
Form objects simplify multi-model operations by wrapping them in a transaction. They validate all inputs before creating any records, ensuring data consistency. Expose created records via attr_reader for controller access. Use for registration, checkout, wizards.
</why>
</pattern>
# ❌ 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
</implementation>
<why>
N+1 queries occur when loading a collection triggers additional queries for each item's associations. Use `includes()` to eager load associations in advance. Rails loads data in 2-3 queries instead of N+1. Always check for N+1 in views and use includes in scopes.
</why>
</pattern>
<antipatterns>
<antipattern>
<description>Using callbacks for complex business logic</description>
<bad-example>
# ❌ BAD - Complex side effects in callbacks
class Feedback < ApplicationRecord
after_create :send_email, :update_analytics, :notify_slack, :create_audit_log
end
</bad-example>
<good-example>
# ✅ 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
</good-example>
<why-bad>
Callbacks with complex side effects make models hard to test, introduce hidden dependencies, and create unpredictable behavior. Service objects make side effects explicit and testable. Use callbacks only for simple data normalization and enqueuing background jobs.
</why-bad>
</antipattern>
<antipattern>
<description>Missing database indexes on foreign keys and query columns</description>
<bad-example>
# ❌ BAD - No indexes, causes table scans
create_table :feedbacks do |t|
t.integer :recipient_id
t.string :status
end
</bad-example>
<good-example>
# ✅ GOOD - Indexes on foreign keys and query
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer