You know how to create, read, update, and delete records. You know associations and validations.
But Active Record has more to offer.
Let me show you advanced patterns that will make your code cleaner, faster, and more maintainable.
The Short Answer
Active Record is powerful. Most developers use only 20% of its features.
The other 80% can:
- Reduce database queries
- Simplify complex queries
- Make your code more readable
- Improve performance dramatically
Part 1: Scopes
Scopes are reusable query definitions. They keep your code DRY.
Basic Scopes
class Post < ApplicationRecord
scope :published, -> { where(status: "published") }
scope :draft, -> { where(status: "draft") }
scope :recent, -> { order(created_at: :desc) }
scope :featured, -> { where(featured: true) }
end
Chaining Scopes
# Clean and readable
@posts = Post.published.recent.featured
# With conditions
@posts = Post.published.where("created_at > ?", 1.week.ago)
Scopes with Parameters
class Post < ApplicationRecord
scope :published_by, ->(user) { published.where(user: user) }
scope :from_category, ->(category) { where(category: category) }
scope :older_than, ->(date) { where("created_at < ?", date) }
end
Scopes vs Class Methods
Scopes are great for simple queries. Class methods are better for complex logic.
class Post < ApplicationRecord
# Simple - use scope
scope :published, -> { where(status: "published") }
# Complex - use class method
def self.with_recent_comments(days: 7)
where("EXISTS (SELECT 1 FROM comments WHERE comments.post_id = posts.id AND comments.created_at > ?)", days.days.ago)
end
def self.search(query)
where("title ILIKE ? OR body ILIKE ?", "%#{query}%", "%#{query}%")
end
end
Part 2: Delegation
delegate lets you call methods on associated objects directly.
Basic Delegation
class Order < ApplicationRecord
belongs_to :user
delegate :email, :name, to: :user, prefix: true
end
order = Order.first
order.user_email # Delegates to order.user.email
order.user_name # Delegates to order.user.name
Delegation with Options
class Invoice < ApplicationRecord
belongs_to :order
belongs_to :user
delegate :total, to: :order, prefix: true
delegate :email, :name, to: :user, prefix: true, allow_nil: true
end
Many-to-Many Delegation
class Project < ApplicationRecord
has_many :tasks
has_many :assignments, through: :tasks
has_many :assignees, through: :assignments, source: :user
delegate :count, to: :tasks, prefix: true
end
Part 3: Includes for N+1 Prevention
includes solves the N+1 query problem.
Without Includes
# Bad - 1 + 100 queries
users = User.limit(100)
users.each do |user|
puts user.orders.count
end
With Includes
# Good - 2 queries total
users = User.includes(:orders).limit(100)
users.each do |user|
puts user.orders.count # Already loaded
end
Multiple Associations
# Multiple associations
User.includes(:orders, :profile).all
# Nested associations
User.includes(orders: :line_items).all
# Conditional includes
User.includes(:orders).where(orders: { status: "completed" })
Preload vs Eager Load
| Method | Behavior | Best For |
|---|---|---|
includes |
Lazy loading with options | Most cases |
preload |
Always separate queries | When you always need the association |
eager_load |
Single LEFT JOIN | When filtering on association |
joins |
No association loading | When you only need to filter |
# Always loads orders separately
User.preload(:orders).all
# LEFT JOIN (single query)
User.eager_load(:orders).all
# Only filter, don't load
User.joins(:orders).where(orders: { status: "completed" })
Part 4: Counter Caches
Counter caches eliminate count queries.
Without Counter Cache
# Queries database each time
user.orders.count
user.comments.count
With Counter Cache
class AddOrdersCountToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :orders_count, :integer, default: 0
# Backfill
User.find_each do |user|
User.reset_counters(user.id, :orders)
end
end
end
class Order < ApplicationRecord
belongs_to :user, counter_cache: true
end
# Now this is instant
user.orders_count
Custom Counter Caches
class Post < ApplicationRecord
has_many :comments
# Custom counter for a specific condition
has_many :published_comments, -> { published }, class_name: "Comment"
# Track count of published comments
attribute :published_comments_count, :integer, default: 0
end
Part 5: Enum
Enums make status fields human-readable.
Basic Enum
class Order < ApplicationRecord
enum status: {
pending: 0,
processing: 1,
shipped: 2,
delivered: 3,
cancelled: 4
}
end
Using Enums
# Create with enum
Order.create(status: :pending)
# Query with enum
Order.pending
Order.where(status: "processing")
# Check status
order.pending?
order.processing?
# Change status
order.shipped!
order.update(status: :delivered)
# Get all statuses
Order.statuses.keys
Order.statuses.values
Enum with Prefix/Suffix
class Order < ApplicationRecord
enum status: { pending: 0, processing: 1, delivered: 2 }, _prefix: :status
enum priority: { low: 0, high: 1 }, _suffix: :priority
end
order.status_pending? # instead of order.pending?
order.high_priority? # instead of order.high?
Enum with Validation
class Order < ApplicationRecord
enum status: { pending: 0, processing: 1, delivered: 2 }
validates :status, presence: true
end
Part 6: Touch
touch updates timestamps for associated records.
Basic Touch
class Comment < ApplicationRecord
belongs_to :post
# When a comment is updated, update the post's updated_at
belongs_to :post, touch: true
end
Touch with Custom Column
class Comment < ApplicationRecord
belongs_to :post, touch: :comments_updated_at
end
Manual Touch
# Update specific timestamp
post.touch
post.touch(:published_at)
post.touch(:last_activity_at, time: Time.current)
Part 7: Transactions
Transactions ensure multiple operations succeed or fail together.
Basic Transaction
class Order < ApplicationRecord
def process!
transaction do
update!(status: "processing")
charge_customer!
update!(status: "completed")
end
rescue => e
update!(status: "failed")
raise e
end
end
Transactions with Multiple Records
def transfer_funds(from_account, to_account, amount)
Account.transaction do
from_account.balance -= amount
from_account.save!
to_account.balance += amount
to_account.save!
Transfer.create!(from: from_account, to: to_account, amount: amount)
end
end
Nested Transactions
def process_order(order)
Order.transaction do
order.update!(status: "processing")
# This is nested - it doesn't commit until the outer transaction does
Payment.transaction do
payment = Payment.create!(order: order, amount: order.total)
payment.charge!
end
order.update!(status: "completed")
end
end
Part 8: Custom Query Methods
Sometimes you need custom SQL for complex queries.
find_by_sql
def with_recent_comments
find_by_sql(<<-SQL)
SELECT posts.*, COUNT(comments.id) as comment_count
FROM posts
LEFT JOIN comments ON comments.post_id = posts.id
WHERE comments.created_at > '#{7.days.ago}'
GROUP BY posts.id
HAVING COUNT(comments.id) > 0
SQL
end
Custom Select
def with_stats
select("posts.*, COUNT(comments.id) as comment_count")
.joins("LEFT JOIN comments ON comments.post_id = posts.id")
.group("posts.id")
end
Raw SQL with Parameters
def by_complex_filter(params)
where("(created_at >= ? OR featured = ?) AND (status IN (?) OR category = ?)",
params[:since], true, params[:statuses], params[:category])
end
Part 9: Callbacks
Callbacks are hooks that run at specific times in the record lifecycle.
Common Callbacks
class Post < ApplicationRecord
# Before
before_validation :normalize_title
before_save :set_slug
before_create :generate_uuid
before_update :track_changes
# After
after_create :schedule_welcome_email
after_update :invalidate_cache
after_destroy :clean_up_files
# Around
around_save :measure_performance
private
def normalize_title
self.title = title.strip.capitalize if title.present?
end
def set_slug
self.slug = title.parameterize if slug.blank?
end
def measure_performance
start = Time.current
yield
Rails.logger.info("Save took #{Time.current - start}s")
end
end
Conditional Callbacks
class Order < ApplicationRecord
before_save :calculate_total, if: :items_changed?
before_validation :validate_items, unless: :skip_validation?
end
Skipping Callbacks
# Skip all callbacks
post.update_column(:status, "published")
# Skip a single callback (using Active Record's skip_callback)
Post.skip_callback(:create, :after, :schedule_welcome_email)
Part 10: Optimistic Locking
Optimistic locking prevents concurrent updates from overwriting each other.
Enable Locking
class Order < ApplicationRecord
attribute :lock_version, :integer, default: 0
end
# Migration
class AddLockVersionToOrders < ActiveRecord::Migration[7.0]
def change
add_column :orders, :lock_version, :integer, default: 0
end
end
Using Locking
order = Order.find(1)
order.update(total: 100)
# If someone else updates the order in between:
# ActiveRecord::StaleObjectError is raised
Pessimistic Locking
# Lock row for update
order = Order.lock.find(1)
order.update(total: 100)
The Advanced Active Record Checklist
When to Use Each Pattern
| Pattern | When to Use |
|---|---|
| Scopes | Reusable query conditions |
| Delegation | Cleaner method access |
| Includes | Prevent N+1 queries |
| Counter Cache | Frequent count queries |
| Enum | Status or type fields |
| Touch | Cache invalidation |
| Transactions | Multiple dependent operations |
| Custom Query | Complex SQL |
| Callbacks | Lifecycle hooks |
| Locking | Concurrent updates |
Summary
Active Record is deeper than most developers think.
| Pattern | Benefit |
|---|---|
| Scopes | DRY queries |
| Delegation | Cleaner code |
| Includes | Faster performance |
| Counter Cache | Instant counts |
| Enum | Human-readable status |
| Touch | Automatic timestamps |
| Transactions | Data integrity |
| Callbacks | Lifecycle hooks |
| Locking | Concurrency control |
Quick Reference
# Scopes
scope :active, -> { where(active: true) }
# Delegation
delegate :email, to: :user
# Includes
User.includes(:orders).all
# Counter Cache
belongs_to :user, counter_cache: true
# Enum
enum status: { pending: 0, active: 1 }
# Touch
belongs_to :post, touch: true
# Transaction
ActiveRecord::Base.transaction do
# multiple operations
end
Leave a comment