Active Record
ActiveRecord is Rails ORM: domain objects that map to database rows, with associations, scopes, validations, callbacks, and migrations. The win is high-leverage CRUD without writing SQL; the trap is hidden N+1 queries and over-friendly callbacks that blur business logic into database access.
Models, associations, scopes, joins, eager loading
EXAMPLE
# ===== Migration first — schema is the source of truth =====
# db/migrate/20260618_create_orders.rb
class CreateOrders < ActiveRecord::Migration[7.1]
def change
create_table :customers do |t|
t.string :name, null: false
t.string :email, null: false, index: { unique: true }
t.timestamps
end
create_table :orders do |t|
t.references :customer, foreign_key: true, null: false
t.string :status, null: false, default: 'new'
t.integer :total_cents, null: false
t.datetime :paid_at
t.timestamps
t.index %i[customer_id status]
end
create_table :line_items do |t|
t.references :order, foreign_key: true, null: false
t.string :sku, null: false
t.integer :qty, null: false, default: 1
t.integer :price_cents, null: false
end
end
end
# ===== Models — declare relationships + rules =====
class Customer < ApplicationRecord
has_many :orders, dependent: :restrict_with_exception
validates :email, presence: true, uniqueness: { case_sensitive: false }
before_save { self.email = email.downcase }
end
class Order < ApplicationRecord
belongs_to :customer
has_many :line_items, dependent: :destroy
enum status: { new: 'new', paid: 'paid', shipped: 'shipped', cancelled: 'cancelled' }
validates :total_cents, numericality: { greater_than_or_equal_to: 0 }
scope :recent, ->(d = 30.days.ago) { where('created_at > ?', d) }
scope :open, -> { where(status: %w[new paid]) }
def total_aud = (total_cents / 100.0).round(2)
end
class LineItem < ApplicationRecord
belongs_to :order
end
# ===== Queries — the chainable API =====
# Find a customer + their open orders, both in one round trip
customer = Customer
.includes(orders: :line_items)
.find_by!(email: 'alice@example.com')
# Aggregate: customers with > $500 lifetime
top = Customer
.joins(:orders)
.where(orders: { status: %w[paid shipped] })
.group('customers.id')
.having('SUM(orders.total_cents) > ?', 50_000)
.order('SUM(orders.total_cents) DESC')
.limit(10)
# Time-bucketed counts
counts = Order.recent.group('DATE(created_at)').count
# ===== Writes — atomically =====
ActiveRecord::Base.transaction do
o = Customer.find(42).orders.create!(status: 'new')
o.line_items.create!(sku: 'sku-1', qty: 2, price_cents: 1995)
o.update!(total_cents: o.line_items.sum('qty * price_cents'))
end
# ===== N+1 detection + fix =====
# BAD — fires 1 query for the customers + N queries for each set of orders
Customer.where(id: ids).each { |c| puts c.orders.size }
# GOOD — one extra query for the orders, joined in memory
Customer.includes(:orders).where(id: ids).each { |c| puts c.orders.size }
# Enable the bullet gem in dev to flag missing includes in tests + browser bar.
# ===== Callbacks — use carefully =====
# Rule of thumb: callbacks for DATA INTEGRITY only.
# Side effects (email, payment, audit) belong in service objects or jobs;
# callbacks make them implicit and untestable in isolation.
# Wrong:
class Order < ApplicationRecord
after_save :send_receipt # sends email even for tests, imports, rebuilds
end
# Better:
class PlaceOrder
def self.call(customer:, items:)
Order.transaction do
o = customer.orders.create!(total_cents: items.sum { |i| i[:price_cents] * i[:qty] })
items.each { |i| o.line_items.create!(i) }
OrderMailer.with(order: o).placed.deliver_later
o
end
end
end
Why it matters
`includes(:assoc)` is the single biggest performance lever in ActiveRecord. Forget it on a list page and you get N+1 queries the moment the view touches an association. Add the bullet gem in dev, fail tests when it flags an N+1, and N+1 stops sneaking into production.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
class User < ApplicationRecord
has_many :posts
validates :email, presence: true, uniqueness: true
end
User.where(active: true).order(:name)
Try it Yourself »
Discussion
Loading…