Advanced Active Record: Beyond the Basics

There are many more advanced patterns in Active Record than just the basics. Let's explore them

Advanced Active Record: Beyond the Basics

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

RUBY
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

RUBY
# Clean and readable
@posts = Post.published.recent.featured

# With conditions
@posts = Post.published.where("created_at > ?", 1.week.ago)

Scopes with Parameters

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

RUBY
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

RUBY
class Order < ApplicationRecord
  belongs_to :user

  delegate :email, :name, to: :user, prefix: true
end
RUBY
order = Order.first
order.user_email  # Delegates to order.user.email
order.user_name   # Delegates to order.user.name

Delegation with Options

RUBY
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

RUBY
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

RUBY
# Bad - 1 + 100 queries
users = User.limit(100)

users.each do |user|
  puts user.orders.count
end

With Includes

RUBY
# Good - 2 queries total
users = User.includes(:orders).limit(100)

users.each do |user|
  puts user.orders.count  # Already loaded
end

Multiple Associations

RUBY
# 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
RUBY
# 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

RUBY
# Queries database each time
user.orders.count
user.comments.count

With Counter Cache

RUBY
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
RUBY
class Order < ApplicationRecord
  belongs_to :user, counter_cache: true
end
RUBY
# Now this is instant
user.orders_count

Custom Counter Caches

RUBY
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

RUBY
class Order < ApplicationRecord
  enum status: {
    pending: 0,
    processing: 1,
    shipped: 2,
    delivered: 3,
    cancelled: 4
  }
end

Using Enums

RUBY
# 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

RUBY
class Order < ApplicationRecord
  enum status: { pending: 0, processing: 1, delivered: 2 }, _prefix: :status
  enum priority: { low: 0, high: 1 }, _suffix: :priority
end
RUBY
order.status_pending? # instead of order.pending?
order.high_priority?  # instead of order.high?

Enum with Validation

RUBY
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

RUBY
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

RUBY
class Comment < ApplicationRecord
  belongs_to :post, touch: :comments_updated_at
end

Manual Touch

RUBY
# 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

RUBY
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

RUBY
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

RUBY
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

RUBY
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

RUBY
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

RUBY
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

RUBY
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

RUBY
class Order < ApplicationRecord
  before_save :calculate_total, if: :items_changed?
  before_validation :validate_items, unless: :skip_validation?
end

Skipping Callbacks

RUBY
# 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

RUBY
class Order < ApplicationRecord
  attribute :lock_version, :integer, default: 0
end
RUBY
# Migration
class AddLockVersionToOrders < ActiveRecord::Migration[7.0]
  def change
    add_column :orders, :lock_version, :integer, default: 0
  end
end

Using Locking

RUBY
order = Order.find(1)
order.update(total: 100)

# If someone else updates the order in between:
# ActiveRecord::StaleObjectError is raised

Pessimistic Locking

RUBY
# 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

RUBY
# 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
Derrick Orare

Derrick Orare

Senior Software Engineer with 11+ years of experience building scalable backend systems, APIs and fintech platforms

Discussion

0 Comments

Questions, feedback or corrections are welcome.

💬

No discussion yet.

Be the first to share your thoughts.

Leave a comment