Ruby & Rails · Field Guide · 104 Topics

Ruby & Rails Pro Playbook

The patterns a veteran Rails engineer reaches for daily — each with real code, real output, a plain-English analogy, and why the pattern wins. Copy, run, adapt.

Part I — Ruby · The Language

The Mental Shift

01Everything is an object — no primitives, ever

Scenario: you're coming from another language and keep asking "is this a primitive or an object?" In Ruby the answer is always the same: it's an object, and you talk to it by sending messages (calling methods). Numbers, strings, nil, even classes themselves.

🧠
Analogy: a city where absolutely everyone speaks the same language — even the lampposts (numbers) and empty lots (nil) answer when spoken to. You never ask "can I call a method on this?" You always can; the only question is whether it understands that particular message.
Code — try in irb
3.times { puts "hello" }   # an integer has methods
-42.abs                    # => 42
nil.class                  # even nil is an object
"ruby".class.ancestors.first(3)

# every expression RETURNS a value — even if/else
status = if Time.now.hour < 12 then "AM" else "PM" end
Output
hello
hello
hello
=> 42
=> NilClass
=> [String, Comparable, Object]
=> "PM"

✅ Do — good use cases

  • Chain methods fearlessly: "a,b,c".split(",").map(&:upcase)
  • Explore live in irb: x.class, x.methods.sort, x.respond_to?(:save)
  • Use "everything returns a value": assign straight from if/case

❌ Don't — anti-patterns

  • C-style for i in 0..n loops — Rubyists use each/map iterators
  • if x == true — just if x; comparing to true is a smell
Pattern & benefit: one mental model covers the whole language — objects receiving messages. There are no special cases to memorize, no boxing/unboxing, no primitive wrappers. This is why Ruby code chains so naturally and why Rails can feel like English.
Gotcha — why it goes wrong: because nil is a real object, errors read differently: nil.upcase raises NoMethodError: undefined method 'upcase' for nil — not a null-pointer crash. Train yourself to read that as "somewhere upstream, this variable became nil." It's the #1 error you'll debug in Rails (see topic 3).
02Symbols vs strings — :name everywhere★ used daily

Scenario: Rails code is littered with :email, :name, :destroy. When do you write :email (symbol) and when "email" (string)?

🧠
Analogy: a symbol is an employee badge number — exactly one exists per concept, compared instantly by identity, never edited. A string is a sticky note with a name scribbled on it — a fresh note every time you write one, editable, and compared letter-by-letter.
Code
:email.object_id == :email.object_id   # same badge
"email".object_id == "email".object_id # two sticky notes

user = { name: "Asha", role: :admin }  # symbols as keys + enum-ish value
user[:name]

:email.to_s    # cross the border when needed
"email".to_sym
Output
=> true
=> false
=> "Asha"
=> "email"
=> :email

✅ Do — good use cases

  • Symbols for identifiers: hash keys, method names, statuses (:pending), Rails options (dependent: :destroy)
  • Strings for data: anything typed by users, stored in DB, or manipulated

❌ Don't — anti-patterns

  • Mixing key types in one hash — {"name" => 1, email: 2} is a debugging tax
  • Symbolizing everything defensively — if you'll upcase/gsub it, it's data → string
Pattern & benefit: symbol comparison is a pointer check — instant — and each symbol exists once in memory. But the real win is communication: :status says "this is a name of a thing" while "shipped" says "this is a value." Idiomatic Ruby reads correctly because of this split.
Gotcha — why it goes wrong: h = JSON.parse('{"name":"Asha"}') gives string keys, so h[:name] is nil — silently. Same trap the other way with your own symbol-keyed hashes. Fix: JSON.parse(json, symbolize_names: true). Rails params dodge this via HashWithIndifferentAccess (both work) — plain Ruby hashes do not.
03nil, truthiness & safe navigation &.★ used daily

Scenario: user.address.city blows up with NoMethodError for nil in production at 2am. Ruby's truthiness rules and the &. operator are your armor.

🧠
Analogy: Ruby's if is a bouncer with a two-name blacklist: only nil and false are turned away. 0, "", and [] all walk right in — unlike the clubs in JavaScript or Python. And &. is a polite knock: "anyone home? If not, I'll quietly leave a nil instead of kicking the door down."
Code
puts "0 is truthy!" if 0        # would NOT print in JS/C
puts "empty is truthy!" if ""   # would NOT print in Python

user = nil
user&.name                      # nil — no crash

name = params_name || "guest"   # default when nil/false
@rates ||= expensive_fetch      # memoize: compute once, reuse
Output
0 is truthy!
empty is truthy!
=> nil
=> "guest"

✅ Do — good use cases

  • &. for genuinely-optional chains: current_user&.email
  • x ||= default for memoization and lazy defaults
  • In Rails: params[:q].presence || "all" — treats "" as missing too

❌ Don't — anti-patterns

  • a&.b&.c&.d everywhere — you're hiding a design problem, and bugs surface far from their cause
  • if x == nil — use x.nil? or just truthiness
Pattern & benefit: two falsy values means no coercion surprises — you always know what an if will do. ||= is the standard Rails memoization idiom: @user ||= User.find(session[:user_id]) runs the query once per request.
Gotcha — why it goes wrong: || replaces false too, not just nil. enabled = opts[:enabled] || true can never be false! For booleans use opts.fetch(:enabled, true). Second trap: "" is truthy, so params[:q] || "all" happily keeps an empty search box — that's why Rails added .presence.
04Strings — interpolation & the everyday toolkit

Scenario: building messages, cleaning user input, generating slugs — the string methods you'll reach for every single day.

🧠
Analogy: interpolation #{...} is a mail-merge template — the blanks are filled live, with any Ruby expression. A squiggly heredoc <<~ is the full-page version of the same letter, with the indentation politely trimmed off.
Code
name, price = "Asha", 499.5
"Hi #{name}, total: ₹#{'%.2f' % price}"   # any expression inside #{}

" Ruby On Rails  ".strip.downcase.gsub(/\s+/, "-")  # slug

email = "ASHA@Corp.COM "
email.strip.downcase.end_with?("corp.com")

sql = <<~SQL
  SELECT id, email
  FROM users
SQL
Output
=> "Hi Asha, total: ₹499.50"
=> "ruby-on-rails"
=> true
=> "SELECT id, email\nFROM users\n"

✅ Do — good use cases

  • "#{}" over + concatenation — handles to_s for you
  • <<~SQL squiggly heredocs for multi-line SQL/text (indentation-stripped)
  • Chain the cleaners: .strip.downcase on any external input

❌ Don't — anti-patterns

  • str += piece inside big loops — allocates a new string each pass; collect in an array and join
  • Interpolating user input into SQL strings — that's an injection; use placeholders (topic 98)
Pattern & benefit: Ruby strings are batteries-included: squeeze, tr, center, chars, scan… before writing string-mangling code, check if a method already exists — it usually does. Rails adds more (titleize, parameterize, truncate).
Gotcha — why it goes wrong: single quotes do not interpolate — 'Hi #{name}' prints the literal characters #{name}. It's the classic first-week bug. Convention: double quotes when interpolating; either is fine otherwise. Also know your gsub arg: gsub(".", "-") replaces literal dots, gsub(/./, "-") replaces every character.
05Methods — keyword args, defaults & splat

Scenario: a method call like charge(500, true, false, nil) is unreadable. Keyword arguments make call sites self-documenting — Rails APIs are built on them.

🧠
Analogy: positional args are an assembly line — everything must arrive in exact order. Keyword args are labeled parcels — order irrelevant, the label is what counts. The splat * is the catch-all bag that scoops up whatever's left over.
Code
def charge(amount:, currency: "INR", capture: true)
  "charging #{amount} #{currency} (capture=#{capture})"
end

charge(currency: "USD", amount: 500)   # any order, reads like English

def tag(name, *classes, **attrs)       # splat + double-splat
  "#{name}: #{classes.join(' ')} #{attrs}"
end
tag("div", "btn", "lg", id: "save")

# naming conventions: ? returns boolean, ! is the dangerous twin
"".empty?      # => true
[3,1].sort!    # sorts in place (mutates)
Output
=> "charging 500 USD (capture=true)"
=> "div: btn lg {id: \"save\"}"
=> true
=> [1, 3]

✅ Do — good use cases

  • Keyword args whenever a method takes 2+ params — future-you reads the call site
  • amount: with no default = required keyword; Ruby enforces it
  • End predicates with ? (valid?, admin?) — never is_valid

❌ Don't — anti-patterns

  • The legacy def foo(options = {}) hash pattern — no required-arg checking, silent typos
  • Assuming ! means "mutates" — it means "more dangerous than my non-! twin" (Rails: save returns false, save! raises)
Pattern & benefit: the last expression is the return value — no return keyword needed (use it only for early exits). Required keywords give you argument validation for free: charge()ArgumentError: missing keyword: :amount at the call site, not a nil bug three files later.
Gotcha — why it goes wrong: parentheses are optional, which bites when ambiguous: puts charge amount: 500 can parse surprisingly. Rule of thumb — write parens everywhere except DSL-style calls (has_many :orders, validates :email). That's exactly the style you'll see in every Rails codebase.

Blocks & Enumerable — Ruby's Superpower

06Blocks & yield — code you hand to a method★ used daily

Scenario: blocks are THE Ruby feature. Every each, every map, every Rails transaction do ... end is a block — a chunk of code you pass into a method, which the method runs whenever it likes.

🧠
Analogy: a block is a recipe card you hand the chef. The chef (the method) controls the kitchen — when to cook, how many times, with which ingredients (yield-ed arguments) — but the recipe is yours. File.open do ... end is the chef also promising to wash up (close the file) after your recipe finishes, even if it burns.
Code
# consuming blocks: yield calls the block you were given
def with_timing
  start = Time.now
  result = yield              # run the caller's recipe
  puts "took #{Time.now - start}s"
  result
end

with_timing { (1..1_000_000).sum }

# two syntaxes, one rule: {} for one line, do...end for many
[1, 2, 3].each { |n| puts n * 10 }

File.open("log.txt", "w") do |f|   # file auto-closed after block
  f.puts "hello"
end
Output
took 0.018s
=> 500000500000
10
20
30

✅ Do — good use cases

  • Setup/teardown wrappers: timing, transactions, file handles, temp config
  • block_given? to make the block optional
  • { } single-line, do…end multi-line — the universal style rule

❌ Don't — anti-patterns

  • Manual open/close pairs when a block form exists — you will leak on exceptions
  • Deeply nested blocks 4+ levels — extract a method
Pattern & benefit: blocks invert control safely: the method guarantees the "around" behavior (cleanup, retry, transaction commit/rollback), the caller supplies only the interesting middle. This is why ActiveRecord::Base.transaction do ... end can promise atomicity — your code runs inside its guarantee.
Gotcha — why it goes wrong: return inside a block returns from the enclosing method, not just the block — a silent early exit that skips the rest of the method. Use next value to "return" from just the block iteration. This one bites everyone exactly once, usually inside a .map.
07Procs vs lambdas — blocks in a variable

Scenario: you need to store a block, pass it around, or keep a library of small behaviors — Rails scopes (-> { where(active: true) }) are exactly this.

🧠
Analogy: a lambda is a strict vending machine — exact coin count (arguments) or it refuses, and pressing "return" only ejects your snack. A proc is a friendly street vendor — brings extra or too few coins, he shrugs and adapts; but if he says "return", he walks out of the whole market (the enclosing method!) taking you with him.
Code
double = ->(x) { x * 2 }      # lambda, modern arrow syntax
double.call(5)
double.(5)                    # shorthand call

adder  = proc { |a, b| a.to_i + b.to_i }
adder.call(1)                 # proc shrugs at missing args

# the & bridge: symbol → block (used constantly)
%w[ruby rails].map(&:upcase)

# lambdas as stored behavior — this is how Rails scopes work
discounts = { vip: ->(t) { t * 0.8 }, none: ->(t) { t } }
discounts[:vip].call(1000)
Output
=> 10
=> 10
=> 1
=> ["RUBY", "RAILS"]
=> 800.0

✅ Do — good use cases

  • Default to -> lambdas — strict args and sane return
  • map(&:name) instead of map { |x| x.name }
  • Hash-of-lambdas to replace long if/elsif dispatch chains

❌ Don't — anti-patterns

  • Proc.new/proc unless you specifically want loose arity
  • Storing big multi-step logic in lambdas — that's a class or method's job
Pattern & benefit: lambdas make behavior a value — storable in hashes, passable as arguments, composable. Rails leans on this everywhere: scope :active, -> { where(active: true) } is a stored lambda evaluated fresh per call (which is why it must be a lambda, not a plain where — see topic 70).
Gotcha — why it goes wrong: return inside a proc returns from the enclosing method (like blocks); inside a lambda it returns only from the lambda. And procs silently pad/discard arguments while lambdas raise ArgumentError. When behavior seems to "skip code randomly," check whether someone used proc where a lambda belonged.
08map / select / reject / reduce — stop writing loops★ used daily

Scenario: transform a list, keep some items, boil a list down to one value. This quartet replaces 90% of the for loops you'd write in other languages — and it's the same API you'll use on ActiveRecord results.

🧠
Analogy: a factory conveyor belt. map is the paint station — every item goes in, a transformed item comes out, count unchanged. select is quality control — only passing items continue. reject is QC's grumpy twin. reduce is the compactor at the end — the whole belt crushed into one value.
Code
orders = [
  { id: 1, total: 500,  paid: true  },
  { id: 2, total: 1200, paid: false },
  { id: 3, total: 800,  paid: true  },
]

orders.map    { |o| o[:total] }            # transform
orders.select { |o| o[:paid] }.size        # keep matching
orders.reject { |o| o[:paid] }.map { |o| o[:id] }
orders.sum    { |o| o[:total] }            # reduce, humanized

# chain them — reads top-to-bottom like a pipeline
orders.select { |o| o[:paid] }
      .map    { |o| o[:total] }
      .sum
Output
=> [500, 1200, 800]
=> 2
=> [2]
=> 2500
=> 1300

✅ Do — good use cases

  • Any transform → map; any filter → select/reject; any total → sum/reduce
  • Chain small steps instead of one clever loop — each line testable by eye
  • filter_map when you'd otherwise map then compact

❌ Don't — anti-patterns

  • each + manually pushing into an accumulator array — that IS map/select, hand-rolled worse
  • Loading Order.all into Ruby to filter/sum what SQL could do — push it to the DB (topic 64)
Pattern & benefit: intent over mechanics — map tells the reader "same length, transformed" before they read the block. No index bookkeeping, no off-by-one, no mutation. These methods come from Enumerable, so the identical vocabulary works on arrays, hashes, ranges, files, and ActiveRecord relations.
Gotcha — why it goes wrong: each returns the original array, not your block results — result = list.each { ... } silently gives back the unchanged list. If you want the transformed values, you want map. Rule: each for side effects only (printing, saving), map for values.
09Enumerable power tools — group_by, tally, partition

Scenario: "group these orders by status", "count occurrences", "split into passed/failed" — you'd write GROUP BY in SQL; Ruby has one-word answers for in-memory data.

🧠
Analogy: a mail room. group_by sorts letters into labeled pigeonholes (a hash of lists). tally just counts letters per sender without keeping them. partition is the two-bin split: urgent / everything else. each_with_object is a clerk walking the pile carrying one basket he fills as he goes.
Code
orders = [
  { id: 1, status: "paid",    total: 500 },
  { id: 2, status: "pending", total: 1200 },
  { id: 3, status: "paid",    total: 800 },
]

orders.group_by { |o| o[:status] }.transform_values(&:size)

%w[rb js rb py rb js].tally

passed, failed = [70, 45, 90, 30].partition { |s| s >= 50 }
[passed, failed]

orders.each_with_object(Hash.new(0)) { |o, h| h[o[:status]] += o[:total] }

orders.min_by { |o| o[:total] }[:id]
orders.each_slice(2).to_a.size    # batches of 2
Output
=> {"paid" => 2, "pending" => 1}
=> {"rb" => 3, "js" => 2, "py" => 1}
=> [[70, 90], [45, 30]]
=> {"paid" => 1300, "pending" => 1200}
=> 1
=> 2

✅ Do — good use cases

  • group_by for report buckets, tally for counters, min_by/max_by/sort_by for "by some attribute"
  • each_slice(500) to batch API calls or inserts
  • zip, flat_map, uniq { } — skim the Enumerable docs once; it pays forever

❌ Don't — anti-patterns

  • Hand-rolling h[k] ||= []; h[k] << v loops — that's group_by
  • Doing this on millions of DB rows in Ruby — Order.group(:status).sum(:total) does it in Postgres (topic 64)
Pattern & benefit: these are SQL's GROUP BY / COUNT / ORDER BY for in-memory collections — one word each, chainable, no accumulator bugs. Interviews love them; code reviews love them more.
Gotcha — why it goes wrong: sort_by { rand } to shuffle is both slower and subtly biased — use shuffle. And Hash.new(0) (default value) is what makes the counting idiom work; forget it and h[k] += 1 explodes on the first missing key with NoMethodError for nil.
10Hashes — the workhorse data structure★ used daily

Scenario: options, params, JSON payloads, lookup tables — in Ruby, everything flows through hashes. Master five methods and most glue code writes itself.

🧠
Analogy: a hash is a hotel key rack — one hook (key) per room, grab the key instantly by room number, no walking the corridor. fetch is the strict concierge who refuses to pretend a room exists; dig is the bellhop who checks floor → wing → room and quietly returns empty-handed if any level is missing.
Code
user = { name: "Asha", roles: [:admin], meta: { city: "Pune" } }

user[:name]
user[:missing]                     # nil — silent!
user.fetch(:missing, "n/a")        # explicit default
user.dig(:meta, :city)             # nil-safe deep access

user.merge(plan: :pro)             # returns NEW hash
prices = { s: 10, m: 20 }
prices.transform_values { |v| v * 1.18 }
prices.select { |k, v| v > 15 }    # hashes are Enumerable too
Output
=> "Asha"
=> nil
=> "n/a"
=> "Pune"
=> {name: "Asha", roles: [:admin], meta: {city: "Pune"}, plan: :pro}
=> {s: 11.8, m: 23.6}
=> {m: 20}

✅ Do — good use cases

  • fetch when the key must exist — fail loud, fail early
  • dig for nested API/JSON payloads instead of &. chains
  • Lookup-table dispatch: HANDLERS[event] || DEFAULT beats if/elsif ladders

❌ Don't — anti-patterns

  • Passing one giant hash through five methods — promote it to a class/Struct (topic 22)
  • Mutating a hash you received as an argument — callers get spooky action at a distance
Pattern & benefit: O(1) lookup, ordered iteration (insertion order — guaranteed), and the full Enumerable vocabulary on top. merge, transform_values, slice, except cover most JSON-wrangling without loops. Rails' params, options hashes, and where clauses are all just hashes.
Gotcha — why it goes wrong: h[:missing] returning nil is quiet data corruption waiting to happen — the nil travels three method calls before exploding. When a key is required, fetch raises KeyError: key not found: :missing at the source. Also remember topic 2: string keys and symbol keys are different keys.
11Ranges & case/when — the === secret

Scenario: grading scores, bucketing ages, matching by class or regex — case/when looks like a switch statement but is far more powerful, because it matches with ===.

🧠
Analogy: a switch statement checks exact nametags. Ruby's case hires club bouncers: each when is a bouncer with his own rule — "are you in this age range?", "does your name match this pattern?", "are you of this family (class)?" First bouncer to nod wins.
Code
def grade(score)
  case score
  when 90..100 then "A"      # Range === score
  when 75...90 then "B"      # ... excludes the end
  when Integer then "F"      # Class === score
  else "invalid"
  end
end

[95, 80, 40, "x"].map { |s| grade(s) }

case "support@corp.com"
when /admin@/   then "boss"
when /@corp\./  then "employee"   # Regexp === string
end

(1..5).to_a          # ranges are Enumerable
("a".."e").include?("c")
Output
=> ["A", "B", "F", "invalid"]
=> "employee"
=> [1, 2, 3, 4, 5]
=> true

✅ Do — good use cases

  • Bucketing with ranges: grades, price tiers, HTTP status families (when 200..299)
  • Matching on class or regex in one clean ladder
  • case returns a value — assign it directly

❌ Don't — anti-patterns

  • if score >= 90 && score <= 100 chains — ranges say it better
  • Huge case on your own types — that's polymorphism begging to happen (topic 21)
Pattern & benefit: when X secretly calls X === value — and Range, Class, Regexp, Proc each define === their own way (cover?, is_a?, match?, call). That's one syntax for four kinds of matching. You can even when ->(n) { n.even? }.
Gotcha — why it goes wrong: two dots vs three: (1..5) includes 5, (1...5) stops at 4. Off-by-one bugs love the wrong dot count. And it's X === value, not value === X — order matters; the pattern goes on the left (which is why when puts it there for you).

Collections, Deeper

12Arrays — the full toolkit★ used daily

Scenario: beyond map/select — the array methods that turn five-line manipulations into one word.

🧠
Analogy: a deck of cards in your hands: first/last peek at the ends, take/drop deal from the top, flatten shuffles nested piles into one, compact throws out the jokers (nils), sample draws blind, rotate cuts the deck.
Code
a = [3, nil, [1, [4, 1]], nil, 5]

a.compact                    # nils out
a.flatten                    # nesting gone
a.flatten.compact.sort.uniq  # chain the cleanup

[1, 2, 3, 4, 5].take(2)      # first n
[1, 2, 3, 4, 5].drop(2)      # all but first n
["a","b","c"].rotate(1)
[1, 2] | [2, 3]              # set ops: union
[1, 2, 3] - [2]              # difference
[5, 1, 9].minmax
Array.new(3) { |i| i * 10 }  # build by formula
Output
=> [3, [1, [4, 1]], 5]
=> [3, nil, 1, 4, 1, nil, 5]
=> [1, 3, 4, 5]
=> [1, 2]
=> [3, 4, 5]
=> ["b", "c", "a"]
=> [1, 2, 3]
=> [1, 3]
=> [1, 9]
=> [0, 10, 20]

✅ Do — good use cases

  • compact after maps that may produce nil (or filter_map in one step)
  • Set operators | & - for tags, permissions, id lists
  • first(3)/last(3) with arguments — they return arrays

❌ Don't — anti-patterns

  • Index-juggling (a[a.length - 1]) — that's a.last
  • flatten on deliberately structured pairs — [[k, v], ...] often wants to_h, not flattening
Pattern & benefit: arrays are Ruby's universal carrier — query results, CSV rows, JSON lists all land here, so every method in this card compounds across your whole codebase. flatten(1) takes a depth argument; uniq takes a block (uniq { |u| u.email }).
Gotcha — why it goes wrong: Array.new(3, []) gives three references to the same array — push to one, all three change. Use the block form Array.new(3) { [] }. Same trap as Hash.new([]) — default-value objects are shared, block forms create fresh ones.
13Iteration patterns — with_index, each_cons, each_slice

Scenario: "number these rows", "compare each element with the next", "process in batches of 500" — the iteration shapes beyond plain each.

🧠
Analogy: ways of walking a queue of people: with_index pins a numbered badge on each person as you pass; each_cons(2) looks at every adjacent pair through a sliding window; each_slice(3) chops the queue into vans of three for transport.
Code
%w[a b c].each_with_index { |x, i| puts "#{i}: #{x}" }

# with_index chains onto ANY enumerator — and can start at 1:
%w[a b c].map.with_index(1) { |x, i| "#{i}. #{x}" }

[3, 7, 12, 20].each_cons(2).map { |a, b| b - a }   # deltas!

(1..7).each_slice(3).to_a                # batches

%w[x y].cycle.first(5)                   # repeat forever, take 5

e = [10, 20].each                        # enumerators are objects
e.next
e.next
Output
0: a
1: b
2: c
=> ["1. a", "2. b", "3. c"]
=> [4, 5, 8]
=> [[1, 2, 3], [4, 5, 6], [7]]
=> ["x", "y", "x", "y", "x"]
=> 10
=> 20

✅ Do — good use cases

  • each_cons(2) for deltas, gaps, "is this sorted?" checks
  • each_slice(n) for batched API calls / bulk inserts (pairs with topic 72)
  • map.with_index(1) for human-numbered lists — no manual counter variable

❌ Don't — anti-patterns

  • i = 0 ... i += 1 counters alongside each — that's with_index's whole job
  • for loops with manual ranges to fake windows — each_cons/each_slice exist
Pattern & benefit: calling an iterator without a block returns an Enumerator — a pausable, chainable iteration object. That's the plumbing behind map.with_index, lazy evaluation (topic 14), and external iteration with next. One concept, many superpowers.
Gotcha — why it goes wrong: each_with_index always starts at 0 and can't be offset — the 1-based version is each.with_index(1) (note the dot). Displaying i + 1 everywhere works until someone forgets one spot; the offset form removes the class of bug.
14Lazy enumerators — infinite sequences, finite work

Scenario: "the first 5 valid codes from a million-line file" — normal chains process everything then take 5. Lazy chains process just enough to produce 5, then stop.

🧠
Analogy: a juice bar with a conveyor of oranges. The eager approach juices the entire truckload, then pours 5 glasses and bins the rest. The lazy bar juices one orange at a time, pouring as it goes — and stops the moment glass five is full. The truck can even be infinite; nobody cares.
Code
# infinite sequence — impossible without lazy:
(1..Float::INFINITY).lazy
  .map    { |n| n * n }
  .select { |n| n % 7 == 0 }
  .first(3)

# huge file: reads only enough lines to find 5 matches
File.foreach("server.log").lazy
    .grep(/ERROR/)
    .map { |l| l.split(" ").first }
    .first(5)

# eager vs lazy — count the work:
(1..1000).map { |n| n * 2 }.first(2)        # 1000 multiplications
(1..1000).lazy.map { |n| n * 2 }.first(2)   # 2 multiplications
Output
=> [49, 196, 441]
=> ["2026-07-11T09:14:02", "2026-07-11T09:15:47", ...]
=> [2, 4]
=> [2, 4]     # same answer, 500× less work

✅ Do — good use cases

  • Early-exit pipelines over big/streamed input: logs, CSVs, API pagination
  • Generators: Enumerator.new { |y| ... y << value ... } + .lazy
  • Anything with "first N matching" over expensive transforms

❌ Don't — anti-patterns

  • .lazy on small arrays — the laziness bookkeeping costs more than it saves
  • Forgetting the terminator — a lazy chain without first/take/force computes nothing at all
Pattern & benefit: memory stays flat (one element in flight, not a million-element intermediate array per step) and time is bounded by the output size, not the input size. The same mental model as topic 64's lazy relations — build the pipeline, pay only when consuming.
Gotcha — why it goes wrong: a lazy chain returns an Enumerator::Lazy, not an array — pass it to code expecting an array and things break oddly. Terminate explicitly: first(n) returns an array; force is "give me everything" (careful with infinity!). And size on a lazy chain is often nil — it genuinely can't know.
15Set — membership at O(1)

Scenario: "is this user id in the allowed list?" checked 10,000 times against an array is 10,000 linear scans. A Set answers each in constant time.

🧠
Analogy: an array is a guest list on paper — the bouncer runs his finger down every line, per guest. A Set is the stamp on your hand: one glance, in or out. And like a stamp, you can't have it twice — duplicates simply don't exist.
Code
require "set"           # stdlib

allowed = Set[4, 8, 15, 16, 23, 42]

allowed.include?(15)     # O(1) — hash lookup
allowed << 99
allowed.add?(99)         # nil if already there — dedupe signal

admins  = Set[1, 4, 8]
allowed & admins         # intersection
allowed - admins         # difference
admins.subset?(allowed)

# the classic use — seen-tracking in a loop:
seen = Set.new
events.each { |e| next unless seen.add?(e.id); process(e) }
Output
=> true
=> #<Set: {4, 8, 15, 16, 23, 42, 99}>
=> nil
=> #<Set: {4, 8}>
=> #<Set: {15, 16, 23, 42, 99}>
=> true

✅ Do — good use cases

  • Hot include? checks inside loops — convert the array once: ids.to_set
  • Seen/visited tracking, permissions, tag math with & | -
  • Deduping while keeping your own logic (add? tells you if it was new)

❌ Don't — anti-patterns

  • Sets when order matters — arrays state that intent
  • Sets of mutable objects you then mutate — their hash changes and the set quietly corrupts
Pattern & benefit: array.include? inside a loop is the accidental O(n²) behind "fast in dev, minutes in prod"; to_set is the one-line fix. Sets also read as intent: "unique membership collection" instead of "array I promise not to duplicate."
Gotcha — why it goes wrong: membership uses hash/eql? — two "equal-looking" custom objects that don't define those (topic 25) count as different members. Strings/symbols/numbers are safe; custom classes need value equality (or use a Data object, topic 22, which gets it free).
16Regexp — match?, captures & scan★ used daily

Scenario: validate formats, extract order numbers from emails, mass-rewrite strings — regular expressions are unavoidable; Ruby's API makes them pleasant.

🧠
Analogy: a regex is a metal detector tuned to a shape: sweep it over text and it beeps on matches. Named captures are detectors with labeled collection bags — the year lands in the bag marked "year". scan sweeps the whole beach and brings back every find.
Code
order = "Order #A-4412 shipped on 2026-07-11 to Pune"

order.match?(/#[A-Z]-\d+/)          # boolean, fast, no globals

m = order.match(/(?<yy>\d{4})-(?<mm>\d{2})-(?<dd>\d{2})/)
m[:yy]                              # named capture
m[:mm]

order.scan(/\d+/)                   # ALL matches
order[/#\w-\d+/]                    # string[] with regex — quick extract

order.gsub(/Pune|Mumbai/, "Pune" => "PNQ", "Mumbai" => "BOM")
"a,b;c d".split(/[,; ]/)

# case/when and grep understand regexes (===):
%w[dev staging prod].grep(/^p/)
Output
=> true
=> "2026"
=> "07"
=> ["4412", "2026", "07", "11"]
=> "#A-4412"
=> "Order #A-4412 shipped on 2026-07-11 to PNQ"
=> ["a", "b", "c", "d"]
=> ["prod"]

✅ Do — good use cases

  • match? for yes/no checks — no MatchData allocation, no side effects
  • Named captures (?<name>...) — self-documenting extractions
  • str[regex] for quick single grabs; scan for all of them

❌ Don't — anti-patterns

  • Parsing HTML/JSON with regexes — real parsers exist (topic 50); regexes for fragments, parsers for structure
  • Heroic 200-char regexes — split into named pieces with /x extended mode or plain Ruby steps
Pattern & benefit: regexes integrate everywhere — split, gsub, scan, grep, case/when, String#[] — so one skill multiplies across the standard library. gsub with a hash or block handles complex rewrites without capture-group arithmetic.
Gotcha — why it goes wrong: ^ and $ match line boundaries in Ruby, not string ends — validating input with /^\w+$/ lets "valid\nrm -rf /" pass (the first line matched!). For whole-string anchors always use \A and \z. Rails' format validators expect this and warn — listen to them.
17Multiple assignment & destructuring

Scenario: a method returns a pair and you want both halves named; a nested array needs unpacking; a hash's keys should become locals — all without temp-variable ceremony.

🧠
Analogy: unpacking a bento box in one motion — each compartment lands on its own labeled plate: rice, fish, pickle = box. The splat is the big plate that takes all the leftovers, and nested parens unpack the compartment inside a compartment.
Code
a, b = 1, 2               # parallel assignment
a, b = b, a               # the classic swap

first, *rest = [1, 2, 3, 4]
*init, last = [1, 2, 3, 4]

(x, y), r = [[10, 20], 99]      # nested unpack
x + y + r

# methods returning multiple values (really: an array)
def min_max(list) = [list.min, list.max]
lo, hi = min_max([5, 2, 9])

# block params destructure too — hash iteration is this!
{ a: 1, b: 2 }.map { |key, val| "#{key}=#{val}" }

# hashes → locals via pattern matching (topic 34):
{ name: "Asha", city: "Pune" } => { name:, city: }
name
Output
=> [2, 1]
first=1  rest=[2, 3, 4]
init=[1, 2, 3]  last=4
=> 129
=> [2, 9]      # lo, hi
=> ["a=1", "b=2"]
=> "Asha"

✅ Do — good use cases

  • Named halves of pair-returns: ok, errors = validate(x)
  • Block-param destructuring for hashes and each_with_object pairs
  • first, *rest for head/tail processing

❌ Don't — anti-patterns

  • Returning 4+ positional values — nobody remembers the order; return a Data/Struct (topic 22)
  • Deep nested destructuring puzzles — two levels max, then use pattern matching for clarity
Pattern & benefit: destructuring pushes names to the earliest possible moment — instead of result[0]/result[1] haunting the next ten lines, the pieces are named on arrival. Block destructuring is why hash iteration reads so cleanly in idiomatic Ruby.
Gotcha — why it goes wrong: extra values silently vanish and missing ones become nil: a, b = [1, 2, 3] quietly drops the 3; a, b = [1] makes b nil — no warning, no error. When shape mismatches must be loud, use pattern matching's => (raises on mismatch) instead of trusting assignment.

Object-Oriented Ruby

18Classes — initialize, attr_accessor & instance vars★ used daily

Scenario: the anatomy of every Ruby class you'll write or read — including every Rails model. Getters/setters in one line, state in @ivars.

🧠
Analogy: a class is a cookie cutter, objects are the cookies. @instance_variables are ingredients baked inside each cookie — invisible from outside. attr_reader installs a window to see an ingredient; attr_accessor installs a window plus a hatch to swap it. No window, no peeking.
Code
class Invoice
  attr_reader :number            # getter only
  attr_accessor :status          # getter + setter

  def initialize(number:, amount:)
    @number = number             # @ivars: private state per object
    @amount = amount
    @status = :draft
  end

  def send!                      # expose behavior, not raw data
    @status = :sent
    self                         # return self → enables chaining
  end

  def to_s = "##{@number} (#{@status}) ₹#{@amount}"   # endless method
end

inv = Invoice.new(number: 101, amount: 4999)
puts inv.send!.to_s
inv.number
Output
#101 (sent) ₹4999
=> 101

✅ Do — good use cases

  • attr_reader by default; attr_accessor only when outsiders truly must write
  • Keyword args in initializeInvoice.new(number: 101, ...) self-documents
  • Methods that change state return self for chaining

❌ Don't — anti-patterns

  • attr_accessor for everything — you've built a struct with extra steps and zero encapsulation
  • Reaching into another object's ivars via instance_variable_get — talk via methods
Pattern & benefit: attr_accessor :status literally generates def status and def status=(v) — a first taste of Ruby classes being executable code (topic 37). Unread ivars are just nil (no declaration ceremony), and private below a line hides helper methods.
Gotcha — why it goes wrong: inside a method, status = :sent creates a local variable — it does NOT call your setter. You must write self.status = :sent or @status = :sent. This exact bug is legendary in Rails models: total = compute inside a method silently fails to update the attribute; self.total = compute works.
19Modules & mixins — include vs extend

Scenario: three models all need "archivable" behavior. No multiple inheritance in Ruby — instead you mix in modules. Rails concerns are exactly this.

🧠
Analogy: a module is a toolbelt. include Archivable hands the toolbelt to every cookie the cutter makes (instance methods); extend hands it to the cutter itself (class methods). A class inherits from one parent, but can strap on any number of toolbelts.
Code
module Archivable
  def archive!
    @archived_at = Time.now
    "archived at #{@archived_at.strftime('%H:%M')}"
  end

  def archived? = !@archived_at.nil?
end

class Post
  include Archivable       # instances get the methods
end

class Report
  include Archivable       # reuse without inheritance
end

post = Post.new
post.archive!
post.archived?
Post.ancestors.first(3)    # module sits in the lookup chain
Output
=> "archived at 14:02"
=> true
=> [Post, Archivable, Object]

✅ Do — good use cases

  • Cross-cutting behavior: Archivable, Taggable, Searchable across unrelated models
  • Namespacing: module Billing; class InvoiceBilling::Invoice
  • In Rails: ActiveSupport::Concern for the include-plus-class-methods pattern

❌ Don't — anti-patterns

  • Junk-drawer modules (Utils, Helpers) collecting unrelated methods
  • Mixing in 10 concerns to dodge a real design — 2000-line "concern soup" models
Pattern & benefit: the module slots into the method-lookup chain between the class and its parent (see ancestors) — so mixed-in methods can even be overridden by the class and reached with super. Comparable and Enumerable (topics 23, 8) are the standard library's proof this scales.
Gotcha — why it goes wrong: include gives instance methods, extend gives class methods — swap them and you get NoMethodError despite "the method being right there." When a module needs both, that's the included(base) hook / ActiveSupport::Concern pattern you'll see all over Rails codebases.
20Inheritance & super — extend, don't replace

Scenario: class Admin < User — the child specializes the parent. super lets you add to inherited behavior instead of rewriting it. Every Rails class you touch (< ApplicationRecord, < ApplicationController) works this way.

🧠
Analogy: super is calling your parent for the family recipe, then adding your own spice on top. Bare super passes along the same ingredients you were given (all your args, automatically); super() with empty parens deliberately calls the parent with nothing.
Code
class User
  def initialize(name)
    @name = name
  end

  def greeting = "Hi, #{@name}"
end

class Admin < User
  def initialize(name, level:)
    super(name)                 # parent sets @name
    @level = level              # child adds its own
  end

  def greeting = super + " [admin L#{@level}]"   # extend, not replace
end

Admin.new("Asha", level: 2).greeting
Admin.ancestors.first(3)
Output
=> "Hi, Asha [admin L2]"
=> [Admin, User, Object]

✅ Do — good use cases

  • True is-a relationships: Admin is-a User; CsvReport is-a Report
  • super in initialize whenever the parent sets up state
  • Template pattern: parent defines the skeleton, children override one step

❌ Don't — anti-patterns

  • Inheriting for code reuse without is-a (Invoice < DatabaseUtils) — use modules or composition
  • Hierarchies 4+ levels deep — every level is a place for behavior to hide
Pattern & benefit: Ruby is single-inheritance, which keeps the chain honest: one parent, plus modules for shared traits (topic 19). Method lookup walks ancestors in order — the same chain super climbs — so behavior is always findable, never a diamond-problem mystery.
Gotcha — why it goes wrong: forgetting super in a child's initialize means parent ivars are never set — everything downstream is mysteriously nil. And the super vs super() distinction is real: bare super forwards your exact arguments; if the parent's signature differs, you'll get an ArgumentError that looks like it's blaming the wrong method.
21Duck typing — "if it quacks, it's a duck"

Scenario: your notifier must handle Email, SMS, and Slack targets. No interfaces, no type checks — if the object responds to deliver, it qualifies. This is Ruby's core design philosophy.

🧠
Analogy: hiring a drummer by audition, not diploma. You don't check where they studied (their class) — you ask them to play (respond_to?(:drum)). Anyone who can play, plays. A drum machine gets the gig too.
Code
class EmailChannel
  def deliver(msg) = "email: #{msg}"
end

class SmsChannel
  def deliver(msg) = "sms: #{msg}"
end

class SlackChannel
  def deliver(msg) = "slack: #{msg}"
end

def notify_all(channels, msg)
  channels.map { |ch| ch.deliver(msg) }   # no type checks — just quack
end

notify_all([EmailChannel.new, SmsChannel.new, SlackChannel.new], "deploy done")

# probing the duck
SmsChannel.new.respond_to?(:deliver)
Output
=> ["email: deploy done", "sms: deploy done", "slack: deploy done"]
=> true

✅ Do — good use cases

  • Pluggable strategies: payment gateways, storage backends, notification channels
  • Test doubles — a fake that responds to deliver IS a valid channel; no mocking framework gymnastics
  • Accept "anything with each" / "anything with to_s" instead of demanding Array/String

❌ Don't — anti-patterns

  • if ch.is_a?(EmailChannel) ... elsif ch.is_a?(SmsChannel) ladders — that's fighting the language
  • Ducks with same method name, different meaning or arity — quacking in different dialects
Pattern & benefit: adding WhatsAppChannel tomorrow requires zero changes to notify_all — open for extension, closed for modification, no interface files, no casts. Rails uses this everywhere: anything responding to call can be middleware; anything with to_partial_path can be rendered.
Gotcha — why it goes wrong: the flip side of no compile-time checks: pass a non-duck and it explodes at call time, possibly deep in production. Mitigate at the boundary: validate inputs where they enter the system, write a shared test both real-and-fake ducks must pass, and let Ruby duck-type freely inside.
22Struct & Data — value objects in one line

Scenario: you're passing {lat:, lng:} hashes around and typos in keys keep biting. You want a tiny typed-ish object — without writing a 20-line class.

🧠
Analogy: a hash is a loose ziplock bag — anything goes in, typos included. A Data object is a molded case, like a camera case with a die-cut foam slot per item: exactly these fields, right shape guaranteed, and the case is sealed (immutable).
Code
# Data (Ruby 3.2+): immutable value object
Coord = Data.define(:lat, :lng) do
  def to_s = "(#{lat}, #{lng})"
end

pune = Coord.new(lat: 18.52, lng: 73.86)
pune.lat
pune.to_s
pune == Coord.new(lat: 18.52, lng: 73.86)   # value equality!
delhi = pune.with(lat: 28.61)               # copy-with-changes

# Struct: the mutable elder sibling
Money = Struct.new(:amount, :currency, keyword_init: true)
Money.new(amount: 500, currency: "INR").amount
Output
=> 18.52
=> "(18.52, 73.86)"
=> true
=> #<data Coord lat=28.61, lng=73.86>
=> 500

✅ Do — good use cases

  • Domain values: Money, Coord, DateRange, SearchResult — anything defined by its fields
  • Return values richer than a hash: Result = Data.define(:ok, :errors)
  • pune.with(lat: ...) for modified copies — immutability without ceremony

❌ Don't — anti-patterns

  • OpenStruct in hot paths — flexible but drastically slower and typo-silent
  • Data/Struct as a dodge for a real class once behavior grows beyond a method or two
Pattern & benefit: compared by value not identity (two Coords with equal fields are == — perfect hash keys and test expectations), fields are typo-proof (pune.lotNoMethodError instantly), and Data's immutability makes objects safely shareable across threads and cache entries.
Gotcha — why it goes wrong: plain Struct.new(:a, :b) takes positional args — Money.new("INR", 500) silently swaps amount and currency! Use keyword_init: true, or just prefer Data.define (keyword-friendly by default). Reach for Struct only when you truly need mutability.
23Comparable & the spaceship <=>

Scenario: you want sort, min, max, between? and all six comparison operators on your own class — say, semantic versions — by writing exactly one method.

🧠
Analogy: <=> is a referee who only ever raises one of three cards: −1 (left loses), 0 (draw), 1 (left wins). Teach the referee your game once, include Comparable, and the whole league infrastructure — rankings (sort), champions (max), qualification windows (between?) — runs itself.
Code
class Version
  include Comparable
  attr_reader :parts

  def initialize(str) = @parts = str.split(".").map(&:to_i)

  def <=>(other) = parts <=> other.parts   # arrays compare element-wise!

  def to_s = parts.join(".")
end

a, b = Version.new("2.10.0"), Version.new("2.9.4")

a > b                      # true — 10 beats 9 numerically
[b, a, Version.new("1.0.0")].sort.map(&:to_s)
a.between?(Version.new("2.0.0"), Version.new("3.0.0"))
a.clamp(b, Version.new("2.9.9")).to_s
Output
=> true
=> ["1.0.0", "2.9.4", "2.10.0"]
=> true
=> "2.9.9"

✅ Do — good use cases

  • Naturally ordered domain objects: versions, money, priorities, date ranges
  • Delegate to arrays: [major, minor, patch] <=> other.triple gives tie-breaking for free
  • sort_by { |x| x.something } when you need one-off orderings instead

❌ Don't — anti-patterns

  • Defining all six operators by hand — that's exactly what Comparable exists to prevent
  • A <=> that's inconsistent (a>b and b>a) — sort output becomes nondeterministic garbage
Pattern & benefit: one method, and the mixin derives < <= == >= > between? clamp — plus every Enumerable method that needs ordering just works on your objects. This is the mixin pattern (topic 19) at its finest: tiny contract in, rich behavior out. Note "2.10.0" correctly beats "2.9.4" — string comparison would get that wrong.
Gotcha — why it goes wrong: <=> against an incomparable object should return nil, not raise — but then sort raises ArgumentError: comparison failed, confusing everyone. Guard it: return nil unless other.is_a?(Version). Also sort is not guaranteed stable in Ruby; if stability matters, sort_by with a tiebreaker.

Objects, Deeper

24self — who is talking right now?

Scenario: half of Ruby's confusing moments dissolve once you can answer one question at any line of code: what is self here?

🧠
Analogy: self is the word "I" in a conversation — same word, different person depending on who's speaking. Inside an instance method, "I" is the object; inside def self.x or the class body, "I" is the class itself; at the top level, "I" is main. Every unqualified method call (save, puts) is really self.save — a sentence with an invisible "I".
Code
class Order
  puts "class body: #{self}"          # → Order (the class object!)

  def summary
    "instance method: #{self.class}"  # self = this order
  end

  def self.stats
    "class method: #{self}"           # self = Order
  end

  def cancel!
    self.status = "cancelled"   # self required for SETTERS (topic 18)
    total       # same as self.total — implicit receiver
  end
end

Order.new.summary
Order.stats
Output
class body: Order
=> "instance method: Order"
=> "class method: Order"

✅ Do — good use cases

  • When debugging "undefined method" — first ask "what is self here?" (p self works anywhere)
  • self.attr = value for setters inside instance methods — mandatory
  • Return self from mutating methods to allow chaining

❌ Don't — anti-patterns

  • Writing self.method_name for ordinary calls — implicit is idiomatic; explicit is only for setters and disambiguation
  • Assuming self inside a block equals the block's location — instance_eval-style APIs swap it (topic 48)
Pattern & benefit: one rule explains classes, class methods, and DSLs: code always executes with a current self. has_many :orders works because the class body's self is the class, and has_many is just a method call on it — the entire Rails DSL is this one insight (topic 37).
Gotcha — why it goes wrong: the classic: status = "done" inside a method creates a local variable instead of calling the status= setter — silently. Only self.status = "done" hits the setter. If a model "won't update a field," this is the first suspect — it never stops catching people.
25Equality — == vs equal? vs eql? vs ===

Scenario: four "equals" that mean different things — and a custom class that behaves wrongly in hashes until you teach it equality.

🧠
Analogy: comparing two ₹500 notes. == asks "same value?" (yes — both buy the same lunch). equal? asks "the very same physical note?" (same serial number — object identity). eql? is the picky cashier: same value and same kind (a ₹500 note ≠ a ₹500 voucher — like 1 eql? 1.0 is false). === isn't equality at all — it's the bouncer's "do you belong to my group?" (topic 11).
Code
a = "mug"; b = "mug"
a == b               # value equality
a.equal?(b)          # identity — two different objects

1 == 1.0             # true  — numeric value
1.eql?(1.0)          # false — type matters to eql?

# custom class: teach it equality or hashes misbehave
class Sku
  attr_reader :code
  def initialize(code) = @code = code
  def ==(other)  = other.is_a?(Sku) && code == other.code
  alias eql? ==
  def hash = code.hash          # equal objects MUST share a hash
end

Sku.new("A1") == Sku.new("A1")
{ Sku.new("A1") => 5 }[Sku.new("A1")]   # works because of eql?/hash
Output
=> true
=> false
=> true
=> false
=> true
=> 5

✅ Do — good use cases

  • Define == (+ alias eql?, + hash) on domain values used in hashes/sets/uniq
  • Or skip the boilerplate: Data.define / Struct give value equality free (topic 22)
  • equal? only when identity truly matters (caching, cycle detection)

❌ Don't — anti-patterns

  • Overriding == without hash — Hash/Set/uniq quietly treat equal objects as different
  • Using === as "extra-equal" — it's case-matching, nothing else
Pattern & benefit: value equality is what makes tests assert cleanly (assert_equal expected_sku, actual), uniq dedupe correctly, and hash lookups work. ActiveRecord defines == as "same class, same id" — two in-memory copies of user 42 are == even with different loaded attributes.
Gotcha — why it goes wrong: the "works in specs, breaks in Sets" bug: == defined, hash forgotten — Set[sku1, sku2] keeps both "equal" objects because buckets are chosen by hash first. Rule: ==, eql?, hash travel as a trio, always.
26Access control — private, protected, public

Scenario: your class has 3 methods callers should use and 7 internal helpers. Mark the boundary — or every helper becomes someone's load-bearing dependency.

🧠
Analogy: a restaurant: public is the dining room — anyone walks in. Private is the kitchen — staff only, and even regulars can't order "directly from the stove." Protected is the staff canteen: other chefs (same class family) may enter each other's — that's how two accounts compare balances without publishing them to the world.
Code
class Account
  def initialize(balance) = @balance = balance

  def richer_than?(other)
    balance > other.balance     # other.balance OK: protected allows peers
  end

  def withdraw!(amount)
    check_limits!(amount)       # private helper — implicit self
    @balance -= amount
  end

  protected

  def balance = @balance        # peers yes, outsiders no

  private

  def check_limits!(amount)
    raise "over limit" if amount > 50_000
  end
end

a, b = Account.new(900), Account.new(400)
a.richer_than?(b)
a.balance rescue "NoMethodError: protected method 'balance' called"
Output
=> true
=> "NoMethodError: protected method 'balance' called"

✅ Do — good use cases

  • Default new helper methods to private — promote to public only on demand
  • protected for peer-comparisons (richer_than?, overlaps?)
  • The public method set IS your class's API — keep it small and intentional

❌ Don't — anti-patterns

  • Everything public "for testability" — test through the public API; private methods are implementation
  • send to bypass private in app code — it works (topic 37), which is exactly the problem
Pattern & benefit: private isn't security — it's a contract: "I may rename or delete this freely; nobody outside depends on it." Small public surfaces are what make refactoring safe. In Rails controllers, everything below private also stops being routable as an action — a real safety line.
Gotcha — why it goes wrong: Ruby's private differs from Java's: it means "no explicit receiver" — historically even self.check_limits! was illegal (allowed since Ruby 2.7 for setters' sake... and note private applies to methods defined after it in the file — a method accidentally pasted above the private line is silently public. Also: private before def self.x does nothing! Class methods need private_class_method or class << self (topic 27).
27Class methods — def self.x & class << self

Scenario: behavior that belongs to the concept, not one instance — finders, factories, registries. Where class methods live and how Rails uses them everywhere.

🧠
Analogy: instance methods are things an employee can do; class methods are services of the head office: hiring (User.create), looking someone up in the directory (User.find), company-wide stats (User.count). You don't ask an individual employee to hire someone — you ask the office.
Code
class Report
  def self.generate_all           # the common style — one-off class method
    formats.map { |f| new(f) }    # `new` here = Report.new (self is Report)
  end

  class << self                   # a block of class methods
    def formats = %w[pdf csv]

    private                       # THIS private works for class methods

    def internal_registry = @registry ||= {}
  end

  def initialize(format) = @format = format
end

Report.generate_all.size
Report.formats
# factory idiom you'll meet constantly:
# def self.from_json(json) = new(**JSON.parse(json, symbolize_names: true))
Output
=> 2
=> ["pdf", "csv"]

✅ Do — good use cases

  • Factories (from_json, build_default), finders, aggregate queries
  • def self.x for one or two; class << self when there's a group (or private class methods)
  • In Rails models: class methods that return relations compose like scopes (topic 70)

❌ Don't — anti-patterns

  • Classes that are ALL class methods holding state — that's a singleton object wanting to be an instance
  • Class-method utility grab-bags (Helpers.do_thing) — cohesion gone
Pattern & benefit: a class is an object (instance of Class), so "class methods" are just methods on that object — one consistent model, no static/instance divide to memorize. scope :active literally generates a class method; has_many, validates — all class methods you now recognize on sight.
Gotcha — why it goes wrong: inside def self.generate_all, calling another class method needs no prefix (self is the class) — but calling an instance method there fails with NoMethodError, confusing beginners daily. The two worlds only meet through an instance: new(...).something. And remember from topic 26: a bare private in the class body does NOT privatize def self.x.
28Class-level state — the @@ trap & class instance variables

Scenario: you want a counter or registry shared across all instances. Ruby offers @@class_variables — and every style guide says don't. Here's why, and what to use.

🧠
Analogy: a @@variable is a joint bank account shared with your entire extended family — parents, children, grandchildren all read AND write the same balance. One subclass "withdraws" and the parent's balance changed too. A class instance variable (@var at class level) is your own account — children may open look-alike accounts, but the money never mixes.
Code — the trap, then the fix
class Vehicle
  @@count = 0                    # ❌ shared across the WHOLE hierarchy
  def self.count = @@count
end
class Car < Vehicle; @@count = 100; end   # oops:
Vehicle.count                    # parent's value CHANGED

class Service                    # ✅ class instance variable
  @registry = []                 # lives on Service itself (topic 24: self = class)
  class << self
    attr_reader :registry
    def register(name) = registry << name
  end
end
class MailService < Service
  @registry = []                 # its OWN registry — no bleed
end
Service.register("a"); MailService.register("b")
[Service.registry, MailService.registry]
Output
=> 100      # Car's assignment overwrote Vehicle's @@count!
=> [["a"], ["b"]]     # clean separation

✅ Do — good use cases

  • Class instance variables (@x at class level) for per-class config/registries
  • In Rails: class_attribute :setting — inheritable AND overridable per subclass, done right
  • Constants for immutable class-level values (topic 41)

❌ Don't — anti-patterns

  • @@anything — the hierarchy-wide sharing is almost never what you meant (RuboCop agrees)
  • Mutable class-level state as hidden global — in threaded servers (Puma!) that's a race condition (topic 51)
Pattern & benefit: class instance variables follow the same rule as everything else — @var belongs to whoever self is, and at class level self is the class (topic 24). One rule, no special cases. Rails' class_attribute adds the inheritance-with-override behavior you usually actually want.
Gotcha — why it goes wrong: @@vars look scoped-to-class but are scoped-to-hierarchy — a subclass three files away mutates your value and the bug points at the wrong class entirely. Second trap: class ivars are not inherited — MailService.registry is nil unless the subclass initializes its own (or you use class_attribute / an inherited hook).
29Method lookup — the ancestors chain

Scenario: you call user.save — but save isn't defined in your model. Where does Ruby find it? Understanding the lookup path turns "Rails magic" into a readable map.

🧠
Analogy: a chain of command: the question ("can you handle save?") goes to the object's class first; if it shrugs, up the chain — mixed-in modules, then the superclass, its modules, and so on to BasicObject. First one to say "that's mine" handles it. Nobody claims it? The complaint department: method_missing (topic 47).
Code
class User < ApplicationRecord
  include Searchable
end

User.ancestors.first(6)
# the exact order Ruby searches:

u = User.new
u.method(:save).owner          # who actually defines save?
u.method(:save).source_location # file:line — jump straight there!

# modules insert BELOW the class — so the class wins:
# prepend inserts ABOVE — module wins (wrappers/instrumentation)
module Audited
  def save = (puts "auditing!"; super)
end
User.prepend(Audited)
User.ancestors.first(3)
Output
=> [User, Searchable, ApplicationRecord,
    ActiveRecord::Base, ..., Object]
=> ActiveRecord::Persistence
=> [".../active_record/persistence.rb", 393]
=> [Audited, User, Searchable]

✅ Do — good use cases

  • method(:name).owner / .source_location — the two best debugging tools in Ruby
  • prepend for wrapping existing methods with logging/timing (then super)
  • ancestors when include/extend behavior surprises you

❌ Don't — anti-patterns

  • Monkey-patching core classes (reopening String etc.) in app code — every lookup on Earth now runs through your patch
  • Guessing where a method comes from — asking takes one console line
Pattern & benefit: ONE algorithm explains inheritance, mixins, prepend, and super: walk ancestors left to right; super means "continue from where I am in this list." With owner and source_location you can trace any Rails method to its exact source line — the day Rails stops being magic.
Gotcha — why it goes wrong: including two modules that define the same method — the last included wins (it sits closest in the chain), silently shadowing the first. When behavior "randomly" changed after adding a concern, diff ancestors. Same story with gems that prepend patches — source_location reveals the impostor.
30Operator overloading — +, <<, [] on your classes

Scenario: total = price_a + price_b on Money objects, cart << item, config[:key] — operators are just methods in Ruby, so your domain objects can speak arithmetic.

🧠
Analogy: operators are nicknamesa + b is formal-name a.+(b). Teach your class what its nickname means and it joins the language natives: Money adds like numbers, Playlists concatenate like arrays. But nicknames carry expectations — if your + secretly deletes files, you've named a crocodile "Fluffy".
Code
class Money
  include Comparable
  attr_reader :cents, :currency

  def initialize(cents, currency = "INR")
    @cents, @currency = cents, currency
  end

  def +(other)
    raise ArgumentError, "currency mismatch" unless currency == other.currency
    Money.new(cents + other.cents, currency)   # return NEW object
  end

  def *(n)    = Money.new(cents * n, currency)
  def <=>(o)  = cents <=> o.cents
  def to_s    = format("₹%.2f", cents / 100.0)
end

total = Money.new(29900) + Money.new(4900)
puts total
puts total * 2
Money.new(100) < Money.new(200)
Output
₹348.00
₹696.00
=> true

✅ Do — good use cases

  • Value objects with real arithmetic: Money, Duration, Vector, Quantity
  • << for append-ish collections, []/[]= for lookup-ish objects
  • Return new objects from +/-/* — immutable math, like Ruby's own numerics

❌ Don't — anti-patterns

  • Surprising semantics — + that mutates, == that isn't symmetric: violated expectations are worse than no operator
  • Overloading for cleverness on non-mathy objects (user + role?) — a named method says more
Pattern & benefit: almost everything is overloadable — + - * / == <=> << [] []= ! unary- — because they're method calls in disguise. This is why hash[:key], array << x, and time + 3600 all feel uniform: same mechanism, different classes. Combine with Comparable (topic 23) and your value object is a full citizen.
Gotcha — why it goes wrong: a += b is sugar for a = a + b — it calls your + and rebinds the variable; it never mutates in place. And you can't overload &&, ||, != (derived from ==), or = — assignment is language-level. If + can fail (currency mismatch), raise loudly; silent coercion is how money bugs are born.
31Custom Enumerable — define each, inherit 50 methods

Scenario: your Playlist class wraps an array of tracks. Give it each, include Enumerable — and suddenly map, select, sort_by, sum, group_by all work on playlists.

🧠
Analogy: Enumerable is a franchise kit: prove you can do ONE thing — hand items over one at a time (each) — and the franchise ships you the entire store: search, sorting, grouping, statistics, all fifty departments prebuilt. Comparable (topic 23) is the same deal for ordering; this is Ruby's grand pattern.
Code
class Playlist
  include Enumerable

  def initialize(*tracks) = @tracks = tracks

  def each(&block)              # the ONLY requirement
    @tracks.each(&block)
    self
  end
end

Track = Data.define(:title, :secs, :artist)
pl = Playlist.new(
  Track.new(title: "Kesariya", secs: 268, artist: "Arijit"),
  Track.new(title: "Naatu",    secs: 212, artist: "Rahul"),
  Track.new(title: "Tum Hi Ho", secs: 262, artist: "Arijit")
)

pl.sum(&:secs)                       # all of Enumerable, free:
pl.group_by(&:artist).transform_values(&:size)
pl.max_by(&:secs).title
pl.sort_by(&:secs).map(&:title)
Output
=> 742
=> {"Arijit" => 2, "Rahul" => 1}
=> "Kesariya"
=> ["Naatu", "Tum Hi Ho", "Kesariya"]

✅ Do — good use cases

  • Domain collections: Playlist, Basket, ResultSet, Timeline — anything list-like with rules
  • Paginated API wrappers: each fetches page after page; callers just map
  • Add Comparable to the elements and sorting works end to end

❌ Don't — anti-patterns

  • Exposing the inner array (attr_reader :tracks) instead — callers mutate your internals
  • Subclassing Array for domain collections — you inherit 100 mutators you didn't want; composition + Enumerable is cleaner
Pattern & benefit: one method in, ~50 out — and your class now duck-types as a collection (topic 21): any code written for "something enumerable" accepts a Playlist. Add def to_a (free via Enumerable) and splatting works too. This is the highest leverage-per-line pattern in Ruby.
Gotcha — why it goes wrong: Enumerable's methods return arrays, not your class — pl.select { ... } is an Array of tracks, not a Playlist. If you need Playlist-in, Playlist-out, override the few you care about (def select(&b) = Playlist.new(*super)). Forgetting this, code calls .play on an Array and dies.
32Delegation — Forwardable & the Rails delegate

Scenario: an Order is asked for customer_name, customer_email... you could write five one-line pass-through methods, or declare the forwarding and move on.

🧠
Analogy: a good executive assistant: you ask the assistant for "the Q3 numbers" and they fetch them from finance — you never walk to finance yourself. delegate :name, to: :customer hires that assistant. The alternative — order.customer.name everywhere — is every caller personally walking through your org chart (and tripping when a desk is empty).
Code
# plain Ruby — stdlib Forwardable:
require "forwardable"
class Playlist
  extend Forwardable
  def_delegators :@tracks, :size, :first, :empty?
end

# Rails — richer and used constantly:
class Order < ApplicationRecord
  belongs_to :customer

  delegate :name, :email, to: :customer, prefix: true, allow_nil: true
  # generates: customer_name, customer_email — nil-safe
end

order.customer_name        # instead of order.customer.name
order.customer_email

# Law of Demeter — the design smell this fixes:
# order.customer.address.city  ❌ 3-hop train wreck
# order.shipping_city          ✅ delegate or wrap it
Output
=> "Acme Corp"
=> "buyer@acme.com"
# with allow_nil: true and no customer:
=> nil        # instead of NoMethodError on nil

✅ Do — good use cases

  • prefix: true so the origin stays visible (customer_name)
  • allow_nil: true when the target association is optional
  • Wrapping/decorating objects: presenters delegate the boring 90%, override the interesting 10%

❌ Don't — anti-patterns

  • Delegating 15 methods to one target — the "wrapper" is a facade for bad boundaries; maybe callers should just use the target
  • Chains in views (order.customer.address.city) — one nil anywhere and the page 500s
Pattern & benefit: delegation keeps the knowledge of structure in one place — callers ask the order, and only the Order knows the answer lives on customer. Restructure tomorrow (denormalize the column, rename the association) and one delegate line changes instead of forty call sites.
Gotcha — why it goes wrong: delegation in loops hides N+1s beautifully — orders.each { |o| o.customer_name } looks flat but hits the customers table per order (topic 67; includes(:customer) fixes it). And without allow_nil, a missing target raises the same old NoMethodError for nil — delegation moved the train wreck, it didn't abolish nil (topic 3).
33Memoization — ||= and its traps★ used daily

Scenario: current_user, exchange rates, a parsed config — computed once per object/request, then reused. Rails codebases memoize constantly; the idiom has two sharp edges.

🧠
Analogy: a sticky note on your monitor: first time someone asks for the wifi password you look it up and write it down; every ask after that, you read the note. ||= is exactly that — with one flaw: if the answer you wrote was "no wifi here" (nil/false), the note looks blank and you look it up again... every single time.
Code
class Checkout
  def shipping_rates
    @shipping_rates ||= RateApi.fetch(cart)   # 1 API call, not 5
  end

  # ❌ TRAP: result can legitimately be nil/false
  def coupon
    @coupon ||= Coupon.find_by(code: code)    # nil → refetches EVERY call!
  end

  # ✅ nil-safe memoization:
  def coupon
    return @coupon if defined?(@coupon)
    @coupon = Coupon.find_by(code: code)
  end
end

# per-REQUEST memoization — the Rails classic:
def current_user
  @current_user ||= User.find_by(id: session[:user_id])
end
Output — log proof
# first call:  Coupon Load (1.2ms) SELECT ... WHERE code = 'X'
# later calls: (nothing — cached in @coupon)
# with the ||= trap and nil result:
# Coupon Load ... Coupon Load ... Coupon Load ...   ← every call, forever

✅ Do — good use cases

  • Expensive, stable-within-the-object's-life values: API lookups, parsed files, DB singletons
  • defined?(@var) pattern when nil/false are valid answers
  • Remember the lifespan: controller ivars live one request; model ivars, one object

❌ Don't — anti-patterns

  • Memoizing values that change underneath (then wondering why updates don't show) — memoization is caching, with cache-invalidation problems (topic 96)
  • Memoizing in class-level/global state to "share across requests" — thread-safety and staleness bugs (topics 28, 51)
Pattern & benefit: the cheapest cache there is — no store, no keys, no expiry: the object's lifetime IS the expiry. In web apps that boundary (one request) is usually exactly the freshness you want, which is why @current_user ||= appears in effectively every Rails app ever written.
Gotcha — why it goes wrong: both edges cut in production: (1) the nil-refetch trap above silently multiplies API/DB calls; (2) memoized-then-stale — you memoize user.orders.count, create an order, and the count is wrong for the rest of the request. Memoize lookups, be wary memoizing things you mutate.

Ruby Pro Moves

34Pattern matching — case/in (Ruby 3+)

Scenario: a webhook payload arrives as nested JSON. Instead of five lines of dig + nil checks + manual destructuring, you describe the shape you expect and Ruby extracts the pieces.

🧠
Analogy: a cookie-cutter over rolled dough: you press the shape you want onto the data — if the dough matches the outline, the named cutouts (=> variables) pop out already filled. Wrong shape? The cutter simply doesn't fit and the next in clause tries.
Code
payload = { event: "payment", data: { amount: 4999, currency: "INR",
            customer: { id: 42, vip: true } } }

case payload
in { event: "refund", data: { amount: } }
  "refund of #{amount}"
in { event: "payment", data: { amount:, customer: { vip: true } } }
  "VIP payment: #{amount}"          # matched + destructured in one step
in { event: String => ev }
  "unhandled: #{ev}"
end

# arrays too — with splat
case [1, [2, 3]]
in [Integer => a, [b, *]] then "a=#{a} b=#{b}"
end

# one-shot check
config = { db: { host: "localhost" } }
config => { db: { host: } }          # rightward assignment
host
Output
=> "VIP payment: 4999"
=> "a=1 b=2"
=> "localhost"

✅ Do — good use cases

  • Webhooks, API responses, job payloads — anywhere nested hashes have known shapes
  • Combine type + shape + value checks in one clause: in { amount: Integer => amt } if amt > 0
  • in { status: "active" | "trial" } — alternatives with |

❌ Don't — anti-patterns

  • Pattern matching flat, simple conditions — plain if/case/when reads better
  • Forgetting the fall-through: no else + no match = NoMatchingPatternError raised
Pattern & benefit: match, validate, and destructure in a single expression — the hash keys act as the guard and the extracted variables are ready on the matching line. Compared to dig chains, intent is visible: the code literally shows the shape of the data it accepts.
Gotcha — why it goes wrong: unlike case/when, an unmatched case/in raises (NoMatchingPatternKeyError) rather than returning nil — great for correctness, surprising in production if you forgot else. Also note: in { event: } matches symbol keys only — string-keyed JSON needs symbolize_names: true first (topic 2 strikes again).
35Exceptions — rescue, ensure, retry, custom errors

Scenario: a flaky payment API times out occasionally. You want to retry twice, always release the lock, and raise a domain-specific error your app can catch precisely.

🧠
Analogy: a trapeze act. begin is the jump, rescue is the safety net (sized for specific acrobats — don't catch the whole circus), retry sends the acrobat back up the ladder, and ensure is the stage crew that sweeps up no matter how the act ended — applause or faceplant.
Code
class PaymentError < StandardError; end    # your app's error family

def charge!(attempts = 0)
  acquire_lock
  api_call                                  # may raise Timeout::Error
rescue Timeout::Error => e
  attempts += 1
  retry if attempts < 3                     # jump back to begin
  raise PaymentError, "gave up after 3 tries: #{e.message}"
ensure
  release_lock                              # ALWAYS runs
end

charge!
Output
lock acquired
api timeout... retrying (1)
api timeout... retrying (2)
lock released
PaymentError: gave up after 3 tries: execution expired

✅ Do — good use cases

  • Define one app-level base error (class AppError < StandardError), subclass per domain
  • ensure for cleanup: locks, files, temp state
  • Rescue the most specific class that you can actually handle

❌ Don't — anti-patterns

  • rescue Exception — catches Ctrl-C, memory errors, SystemExit; you've made the process unkillable
  • rescue => e; nil — swallowing errors turns a loud crash into silent data corruption
  • Exceptions as flow control for expected cases (user not found is a nil, not an exception)
Pattern & benefit: bare rescue catches StandardError and below — by design, that excludes the catastrophic stuff. Method definitions are implicit begin blocks, so def / rescue / ensure / end needs no extra nesting. Rails maps common errors for you: ActiveRecord::RecordNotFound → 404.
Gotcha — why it goes wrong: retry without a counter is an infinite loop wearing a safety vest — always bound it. And rescue order matters: Ruby checks top-to-bottom, so rescue StandardError listed first shadows every specific rescue below it (they become dead code, no warning).
36Mutability — freeze, dup & frozen_string_literal

Scenario: a constant array of statuses gets <<-ed somewhere deep in the app, and now every request sees the polluted list. Ruby objects are mutable by default — freezing is your seatbelt.

🧠
Analogy: freeze is laminating a document — everyone can read it, nobody can scribble on it; attempts bounce off loudly. dup is the photocopier: hand colleagues a copy, keep the original clean. But note — laminating a folder (freeze on an array) doesn't laminate the pages inside (its elements).
Code
# frozen_string_literal: true         <- first line of every modern file

STATUSES = %w[draft sent paid].freeze

STATUSES << "oops"                    # blows up immediately — good!

s = "hello"                           # frozen via the magic comment
s << " world"                         # also blows up

copy = STATUSES.dup                   # unfrozen shallow copy
copy << "custom"                      # fine — original untouched

# the shallow trap:
LABELS = ["a", "b"].freeze
LABELS[0].upcase! rescue "inner string wasn't frozen (unless magic comment)"
Output
FrozenError: can't modify frozen Array
FrozenError: can't modify frozen String
=> ["draft", "sent", "paid", "custom"]

✅ Do — good use cases

  • # frozen_string_literal: true at the top of every file (Rails generates it)
  • .freeze every constant Array/Hash/String — constants in Ruby aren't actually constant!
  • dup before mutating anything you received as an argument

❌ Don't — anti-patterns

  • Mutating arguments in place (list.map!, str <<) — callers didn't sign up for that
  • Relying on freeze for deep structures without freezing the elements too
Pattern & benefit: a FrozenError at the mutation site is infinitely better than corrupted shared state discovered three requests later. Frozen strings also dedupe in memory — one "pending" instead of thousands. Immutability is why Data objects (topic 22) are thread-safe by construction.
Gotcha — why it goes wrong: Ruby only warns when you reassign a constant (STATUSES = [...] again) and stays silent when you mutate one — that's the trap freeze closes. Also dup is shallow: the copied array shares its inner objects with the original. Deep-copy escape hatch: Marshal.load(Marshal.dump(x)) — or restructure so you don't need it.
37Metaprogramming — send, define_method & how Rails works

Scenario: ever wondered how has_many :orders conjures an orders method out of thin air? Ruby classes are executable code — code can write methods. This topic is less "do this daily" and more "understand the magic you use daily."

🧠
Analogy: most languages build classes with blueprints — fixed at print time. Ruby builds them with a 3D printer that's still connected: while the program runs, code can print new methods onto any class. has_many :orders is a print job; send is slipping a note under an object's door instead of knocking on the official front door.
Code
class Report
  # define methods in a loop — at class-load time
  [:pdf, :csv, :xlsx].each do |fmt|
    define_method("to_#{fmt}") { "report as #{fmt.upcase}" }
  end
end

r = Report.new
r.to_pdf
r.to_csv

# send: call a method whose name you hold as data
column = :to_xlsx
r.send(column)

# this is (simplified!) how attr_accessor and has_many exist:
# def self.attr_accessor(*names)
#   names.each { |n| define_method(n) { instance_variable_get("@#{n}") } }
# end
Output
=> "report as PDF"
=> "report as CSV"
=> "report as XLSX"

✅ Do — good use cases

  • Killing hard duplication: N near-identical methods → one define_method loop
  • public_send when the method name arrives as data (respects private!)
  • Reading it fluently — Rails source is full of this; now it's legible

❌ Don't — anti-patterns

  • method_missing without respond_to_missing? — breaks respond_to?, confuses everyone
  • send(params[:action]) — letting users invoke arbitrary methods is a security hole
  • Metaprogramming to look clever — every layer of magic is a layer of debugging
Pattern & benefit: this is the engine under Rails' hood: attr_accessor, has_many, validates, scope are all ordinary class-level method calls that generate code as the class loads. Once you see that, Rails stops being magic and becomes a library you can read.
Gotcha — why it goes wrong: dynamically defined methods don't appear in grep — a teammate hunting for def to_pdf finds nothing. Keep generated names discoverable (document the loop, use source_location in console to trace). If you're debugging "where does this method even come from?": r.method(:to_pdf).source_location is your friend.
38Gems & Bundler — Gemfile, lockfile, bundle exec

Scenario: every Ruby project declares dependencies in a Gemfile; Bundler installs and pins them. "Works on my machine" bugs die here.

🧠
Analogy: the Gemfile is your grocery list ("some good basmati, brand ~flexible"); Gemfile.lock is the printed receipt — exact brands and batch numbers actually bought. Teammates and servers shop from the receipt, so everyone cooks with identical ingredients. bundle exec means "cook using only what's on the receipt."
Gemfile
source "https://rubygems.org"
ruby "3.3.5"

gem "rails", "~> 8.0"        # ~> : 8.x yes, 9.0 no ("pessimistic")
gem "pg"
gem "sidekiq"

group :development, :test do
  gem "rspec-rails"
  gem "factory_bot_rails"
end
Shell
$ bundle install          # resolve + install, writes Gemfile.lock
$ bundle add stripe       # add a gem the right way
$ bundle exec rspec       # run using EXACT locked versions
$ bundle outdated         # what could be upgraded

✅ Do — good use cases

  • Commit Gemfile.lock always — it IS the reproducibility guarantee
  • ~> 8.0 pessimistic pins for frameworks; loose for small utilities
  • Put test/dev-only gems in groups — production installs skip them

❌ Don't — anti-patterns

  • gem install whatever globally for project code — invisible to teammates and CI
  • Editing Gemfile.lock by hand — it's generated; change the Gemfile instead
  • Adding a gem for 10 lines of logic you could own — every gem is a dependency you now babysit
Pattern & benefit: Bundler solves the whole dependency graph at once and freezes the answer, so dev, CI, and production run byte-identical gem versions. Rails binstubs (bin/rails, bin/rspec) wrap bundle exec for you — prefer them.
Gotcha — why it goes wrong: running plain rspec when your shell has a newer global version = the classic "passes locally, fails in CI." If you see Gem::LoadError: can't activate, you forgot bundle exec/binstub. Also bundle update (no args) upgrades everything — usually you want bundle update somegem --conservative.
39Ruby idioms — write code that looks like Ruby★ used daily

Scenario: your code works but a Rubyist squints at it. These small idioms are the difference between "translated from Java" and native Ruby — and they're what reviewers comment on.

🧠
Analogy: grammar vs fluency in a spoken language. if !user.nil? is technically correct — like saying "the meeting is at the hour of three post-midday." if user is how locals actually talk. Idioms are the native accent.
Code — before → after
# guard clause: handle the edge, then get on with it
def ship(order)
  return unless order.paid?          # not: if order.paid? ... 20 lines ... end
  # happy path at zero indentation
end

do_backup   if night?                # trailing modifiers for one-liners
alert!  unless healthy?              # unless = if-not (never with else!)

total  = price * qty  if valid?      # nil if not valid — beware (see gotcha)
name ||= "guest"                     # default only when nil/false
a, b = b, a                          # swap, no temp var
first, *rest = [1, 2, 3]             # destructuring

user = User.new.tap { |u| u.role = :admin }   # configure then return it
%w[a b c]  %i[x y z]                 # word/symbol array literals
puts "#{qty} item#{'s' if qty != 1}"
Output
=> [1, [2, 3]]     # first, rest
3 items

✅ Do — good use cases

  • Guard clauses at method top — flat happy path, edge cases dispatched early
  • unless for simple negative conditions: raise unless valid?
  • tap to configure-and-return; then to chain a value into a block

❌ Don't — anti-patterns

  • unless x.nil? && !y.empty? — compound negatives melt brains; use if
  • unless ... else — legal, universally hated
  • Clever one-liners that need a comment to explain — clarity beats brevity
Pattern & benefit: idiomatic code is predictable code — reviewers read it at full speed because it matches the patterns in every gem and Rails source file. RuboCop encodes most of these; run it and treat the first hundred offenses as a free Ruby course.
Gotcha — why it goes wrong: trailing if on an assignment still leaves the variable defined-but-nil when the condition fails (total = x if valid?total is nil, not undefined) — fine if expected, a NilClass error two screens later if not. And go easy on &. + || + trailing modifiers in one line; expressiveness compounds into obfuscation fast.

Standard Library & Beyond

40Variable scope — local, instance, class, global

Scenario: why can't a method see the variable defined above it? Why can a block? Scope rules are invisible until they bite — learn the walls once.

🧠
Analogy: rooms in a building. A local variable lives in one room — def builds a new room with no windows (can't see outside locals). A block is a glass extension of the current room — it sees everything inside. @ivars are the resident's belongings, visible in any room the same resident (self) occupies. $globals are announcements over the building PA — everyone hears; that's the problem.
Code
multiplier = 3

[1, 2].map { |n| n * multiplier }   # ✅ block SEES outer locals (closure)

def triple(n)
  # n * multiplier                  # ❌ NameError — def is a fresh scope
  n * 3
end

class Cart
  def add(item)  = (@items ||= []) << item   # @ivar: follows self
  def size       = (@items || []).size        # visible across methods
end

counter = 0
[1, 2, 3].each { counter += 1 }     # blocks can MUTATE outer locals
counter

x = 10 if false                     # declared but unassigned...
x                                   # nil, not NameError!
Output
=> [3, 6]
=> 3
=> nil

✅ Do — good use cases

  • Rely on closures — blocks capturing locals is what makes callbacks/lambdas useful (topic 7)
  • Pass data into methods via arguments — the "windowless room" is a feature, not a bug
  • @ivars for object state only — not as sneaky parameter-passing between methods

❌ Don't — anti-patterns

  • $globals in app code — invisible coupling, thread-unsafe, untestable (loggers are the rare exception)
  • Long methods where block-mutated locals accumulate — extract methods with explicit args
Pattern & benefit: the sigil IS the scope documentation: bare = this method, @ = this object, $ = everywhere (beware), CONSTANT = namespaced (topic 41). You can read a variable's lifetime off its first character — one of Ruby's best readability features.
Gotcha — why it goes wrong: two classics: (1) an unassigned-branch local is nil not undefined (x = 10 if false) — the nil surfaces far away; (2) a block parameter shadows an outer variable of the same name (|user| when user exists outside) — you mutate the wrong one and Ruby only warns with -W. Name block params distinctly.
41Constants & :: — namespacing without tears

Scenario: magic numbers scattered through the code, two gems both defining Client, Rails autoloading yelling about file names — constants and namespaces tie it together.

🧠
Analogy: :: is a postal address system: Billing::Invoice is "Invoice, who lives in the Billing district." Two people named Invoice can coexist in different districts. A constant is a brass nameplate — bolted on and expected never to change... though Ruby, oddly, only frowns (warning) if you pry it off, which is why you also freeze what's behind it (topic 36).
Code
module Billing
  TAX_RATE = 0.18                  # namespaced constant

  class Invoice
    MAX_ITEMS = 50                 # class-level constant

    def total(subtotal)
      subtotal * (1 + TAX_RATE)    # found by lexical scope — no prefix
    end
  end
end

Billing::TAX_RATE
Billing::Invoice::MAX_ITEMS
Billing::Invoice.new.total(1000)

# Rails autoloading (Zeitwerk) maps names ↔ paths — exactly:
# Billing::Invoice ↔ app/models/billing/invoice.rb
# get this wrong → NameError / "expected file to define constant"
Output
=> 0.18
=> 50
=> 1180.0

✅ Do — good use cases

  • Name every magic value: MAX_RETRIES = 3, STATUSES = %w[...].freeze
  • Modules as namespaces per domain area: Billing::, Search:: — mirrors folders in Rails
  • freeze constant arrays/hashes/strings — the nameplate doesn't protect the contents

❌ Don't — anti-patterns

  • Cross-district reach-ins everywhere (A::B::C::D chains in unrelated code) — high coupling in plain sight
  • Defining constants dynamically at runtime — Zeitwerk and grep both lose track
Pattern & benefit: constant lookup is lexical-first (the enclosing module walls, inside-out), then up the ancestors — which is why TAX_RATE works bare inside Billing but needs Billing::TAX_RATE outside. In Rails, understanding the name↔path contract solves 95% of autoloading errors before they start.
Gotcha — why it goes wrong: class Billing::Invoice (compact style) has different lexical scope than nested module Billing; class Invoice — the compact form can't see TAX_RATE bare! This one confuses even seniors. Prefer the nested form. And a top-level ::Client forces "the global one," escaping your current namespace when two Clients collide.
42Conversions — to_i vs Integer(), Array(), implicit vs explicit

Scenario: params arrive as strings, APIs send who-knows-what — converting safely (or loudly) at the boundary decides whether bad data dies at the door or three layers deep.

🧠
Analogy: to_i is the easygoing customs officer — waves anything through, stamping garbage as 0 ("abc"? That's a zero, welcome!). Integer() is the strict officer — invalid papers, loud rejection at the border (ArgumentError). Strict at boundaries, easygoing only when garbage-to-zero is genuinely fine.
Code
"42".to_i        # 42
"42abc".to_i     # 42   — parses the prefix, silently
"abc".to_i       # 0    — !!! garbage becomes zero
nil.to_i         # 0

Integer("42")    # 42
Integer("abc") rescue "ArgumentError!"   # loud — good at boundaries
Integer("2abc") rescue "ArgumentError!"
Integer("ff", 16)                        # base support

Array(nil)       # []          — the nil-neutralizer
Array([1, 2])    # [1, 2]      — already array? untouched
Array(1)         # [1]         — scalar? wrapped
# perfect for "accept one or many":
def notify(recipients) = Array(recipients).each { |r| ... }
Output
=> 42
=> 42
=> 0
=> 0
=> 42
=> "ArgumentError!"
=> "ArgumentError!"
=> 255
=> []
=> [1, 2]
=> [1]

✅ Do — good use cases

  • Integer(params[:page]) at boundaries — fail fast on garbage (rescue to a 400)
  • Array(maybe_one_or_many) — kills the is_a?(Array) branching
  • to_s in string interpolation contexts — it's called for you; rely on it

❌ Don't — anti-patterns

  • params[:amount].to_i for money paths — "abc" → ₹0 orders are real incidents
  • Implementing to_str/to_ary casually — those are implicit conversions Ruby calls behind your back; only true string/array-like classes qualify
Pattern & benefit: the convention pair: short names (to_i, to_s) = explicit, lenient, everywhere; capitalized methods (Integer(), Float()) = strict validators. Knowing which you're holding turns "parse user input" from a bug factory into one deliberate line per field. (Rails adds params.expect typing and model type-casting on top — topic 55.)
Gotcha — why it goes wrong: the silent zero: "10 items".to_i == 10 but "items: 10".to_i == 0 — position matters, garbage doesn't raise. Similar: "3.99".to_i is 3 (truncation, not rounding). Every "why is this quantity zero?!" bug traces back to a lenient conversion where a strict one belonged.
43Numbers — Float traps & BigDecimal for money

Scenario: 0.1 + 0.2 != 0.3 — and your invoice totals are off by a paisa. Floating point is fine for physics, dangerous for finance.

🧠
Analogy: a Float is a measuring tape marked in binary — some everyday decimal lengths (0.1!) fall between its marks, so it reads the nearest mark and shrugs. BigDecimal is a surveyor's chain built for decimals — exact, slower, and the only tool you should bring when the measurement is money. Integers-as-paise is even better: count coins, never measure them.
Code
0.1 + 0.2                     # the famous lie
0.1 + 0.2 == 0.3

require "bigdecimal/util"
"0.1".to_d + "0.2".to_d       # exact decimal math
("19.99".to_d * 3).to_s("F")

# the pro pattern — integer paise/cents:
price_cents = 1999
(price_cents * 3).fdiv(100)   # display-time division only

10 / 3                        # integer division!
10.0 / 3
10.fdiv(3)                    # explicit float division
10 % 3
2**64                         # integers never overflow in Ruby
1_000_000                     # underscores for readability
Output
=> 0.30000000000000004
=> false
=> 0.3e0            # exact
=> "59.97"
=> 59.97
=> 3                # surprise if you expected 3.33
=> 3.3333333333333335
=> 3.3333333333333335
=> 1
=> 18446744073709551616

✅ Do — good use cases

  • Money: integer cents/paise columns (Rails: t.integer :price_cents) or t.decimal — never t.float
  • BigDecimal from strings: "0.1".to_d — building it from a Float imports the error
  • Float comparisons within a tolerance: (a - b).abs < 1e-9

❌ Don't — anti-patterns

  • float == float — as shown above, it lies
  • Accumulating floats in loops (running totals drift) — sum integers or decimals
Pattern & benefit: Ruby integers are arbitrary precision — no overflow, ever — which makes the integer-cents pattern completely safe at any scale. Postgres numeric maps to BigDecimal in ActiveRecord automatically, so a decimal column round-trips exactly: your DB instinct (never float for money) carries straight over.
Gotcha — why it goes wrong: integer division silently floors: total / count with two integers drops the fraction — average ratings all come out 3 instead of 3.7. One operand must be a float/rational/decimal (fdiv, to_f, quo). And round is banker's-rounding-free (rounds half up) but Float#round on an already-imprecise float can still surprise — round decimals, not floats.
44Date & Time — parsing, formatting, arithmetic★ used daily

Scenario: "due in 3 days", "format for the invoice", "parse this API timestamp" — daily work. (Time zones get their own Rails topic — 63 — because they deserve it.)

🧠
Analogy: Time is a stopwatch reading — an exact instant, to the nanosecond. Date is a wall calendar square — a whole day, no clock attached. strftime is a rubber-stamp kit: arrange the letter dies (%d %b %Y) and stamp the instant into any shape of string.
Code
require "date"

t = Time.now
d = Date.today

t.strftime("%d %b %Y, %H:%M")     # format
d.strftime("%A")                   # day name
Date.parse("2026-08-15")           # ISO parsing built in
Time.parse("2026-07-11 14:30")     # (require "time")

d + 30                             # Date + days
d.next_month
(Date.new(2026, 12, 31) - d).to_i  # days between

t + 3600                           # Time + SECONDS
d.saturday?

# with Rails/ActiveSupport, the good stuff:
3.days.from_now
2.weeks.ago
d.end_of_month
Date.tomorrow.beginning_of_day..Date.tomorrow.end_of_day   # range for queries!
Output
=> "11 Jul 2026, 14:22"
=> "Saturday"
=> #<Date: 2026-08-15>
=> 2026-07-11 14:30:00 +0530
=> #<Date: 2026-08-10>
=> #<Date: 2026-08-11>
=> 173
=> true
=> 2026-07-14 14:22:07 +0530
=> 2026-06-27 14:22:07 +0530

✅ Do — good use cases

  • ISO 8601 (iso8601) for storage/APIs; strftime only at display time
  • Date ranges for whole-day queries: created_at: date.all_day (Rails)
  • ActiveSupport durations (3.days) in Rails code — they handle month-length weirdness

❌ Don't — anti-patterns

  • Chronic string-mangling ("#{y}-#{m}-#{d}" concatenation) — Date objects compose; strings don't
  • Date.parse on user-entered dates without a format — "01/02/2026" is ambiguous (parsed day-first!); use Date.strptime(s, "%d/%m/%Y")
Pattern & benefit: date arithmetic that knows about calendars — next_month from Jan 31 gives Feb 28/29, leap years just work, step iterates business-day-style ranges. Memorize five strftime dies (%Y %m %d %H %M) and derive the rest as needed.
Gotcha — why it goes wrong: unit mismatch: Date + n adds days but Time + n adds secondsTime.now + 30 is half a minute, not a month. And comparing a Date to a Time works via coercion, but mixed-type ranges in queries are subtle bugs — normalize to one type at the boundary. The truly nasty zone traps: topic 63.
45Method references — &:sym, method(:name) & composition

Scenario: map(&:upcase) is everywhere in Ruby code — what IS that ampersand? And how do you pass an existing method around like a lambda?

🧠
Analogy: & is a universal adapter plug — it converts whatever you give it into block-shape. Feed it a symbol and the symbol quietly becomes "call this method on each thing." method(:parse) is a business card for a method — hand it to someone and they can call you later. >> staples business cards into an assembly line.
Code
%w[ruby rails].map(&:upcase)      # &:sym → { |x| x.upcase }
[1, -2, 3].select(&:positive?)
[[1, 2], [3, 4]].map(&:sum)

# method(:name) — a callable reference to a REAL method:
def slugify(s) = s.strip.downcase.gsub(/\s+/, "-")
["  New Delhi ", "Navi Mumbai"].map(&method(:slugify))

# composition — small functions, snapped together:
clean  = :strip.to_proc >> :downcase.to_proc
slug   = clean >> ->(s) { s.gsub(/\s+/, "-") }
slug.call("  Ruby On Rails  ")

# a method reference remembers its receiver:
logger = method(:puts)
logger.call("hello")
Output
=> ["RUBY", "RAILS"]
=> [1, 3]
=> [3, 7]
=> ["new-delhi", "navi-mumbai"]
=> "ruby-on-rails"
hello

✅ Do — good use cases

  • &:method whenever the block just calls one method — universal idiom
  • &method(:helper) to reuse a named method as a block — testable, greppable
  • >>/<< composition for transformation pipelines (parsers, sanitizers)

❌ Don't — anti-patterns

  • &:sym when arguments are needed — map(&:round) can't pass 2; write the block
  • Composition chains so long nobody can name the intermediate steps — name them
Pattern & benefit: these turn methods into values — storable, passable, composable — without wrapping everything in lambdas by hand. & works because Symbol implements to_proc; understanding that one line demystifies the most common idiom in modern Ruby.
Gotcha — why it goes wrong: method(:x) captures the method at that moment — if the method is redefined later (or you meant an instance method of another object), your reference is stale or wrong. And & only works in argument position — f = &:upcase is a syntax error; you want :upcase.to_proc.
46Argument forwarding — splat, double-splat & ...

Scenario: wrappers, decorators, and "call the real method with whatever I got" — forwarding arguments without listing them is how middleware and instrumentation stay generic.

🧠
Analogy: a courier who relabels parcels without opening them: *args scoops the boxes, **opts the envelopes, &blk the fragile tube — and ... is the entire trolley, pushed through as-is. Your wrapper signs for everything, adds its sticker (logging, timing), and forwards untouched.
Code
# the full manual trio:
def with_logging(*args, **opts, &blk)
  puts "calling with #{args.inspect} #{opts.inspect}"
  target(*args, **opts, &blk)          # re-splat to forward
end

# Ruby 3: forward EVERYTHING with ...
def instrumented(...)
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  result = real_work(...)
  puts "took #{(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).round(3)}s"
  result
end

def real_work(a, b:, &blk) = blk.call(a + b)
instrumented(1, b: 2) { |x| x * 10 }

# collecting variadic input:
def tag(*names, **attrs) = "#{names.join('.')} #{attrs}"
tag(:div, :span, id: "x")
Output
took 0.0s
=> 30
=> "div.span {id: \"x\"}"

✅ Do — good use cases

  • ... for pure pass-through wrappers — decorators, retries, instrumentation
  • *args/**opts when the wrapper must inspect or tweak before forwarding
  • Keep real signatures (named params) on the methods people actually call

❌ Don't — anti-patterns

  • def do_thing(*args) as your public API — callers get zero signature documentation; splats are for forwarding, not laziness
  • Forgetting **opts when forwarding — keyword args silently become a trailing hash in old styles; forward all three shapes
Pattern & benefit: forwarding is what lets one 5-line wrapper serve every method shape in the codebase — the wrapper needs zero knowledge of signatures. This is the mechanism under define_method-based instrumentation, Rails' delegate (topic 32), and every middleware pattern you'll meet.
Gotcha — why it goes wrong: forgetting the splats on the inner call — target(args, opts) passes an Array and a Hash as two literal arguments instead of re-spreading them. Symptom: ArgumentError: wrong number of arguments (given 2, expected 0+) from a method that "obviously" matches. The splats must appear on both sides.
47method_missing — ghost methods, responsibly

Scenario: an API client where client.get_users, client.get_orders, client.get_anything should all work without defining each — the dynamic catch-all, and the discipline it demands.

🧠
Analogy: method_missing is the hotel's "any other requests" concierge — every request no department claims (topic 29's chain exhausted) lands on his desk, and he improvises. Brilliant — but if the front desk directory (respond_to?) doesn't list what he can do, guests are told "we don't do that" while he stands there doing it. Keep the directory in sync: respond_to_missing?.
Code
class ApiClient
  ENDPOINTS = %i[users orders products].freeze

  def method_missing(name, *args, &blk)
    if name.to_s =~ /\Aget_(\w+)\z/ && ENDPOINTS.include?($1.to_sym)
      fetch("/#{$1}", *args)                # improvise the request
    else
      super                                  # ALWAYS super — real NoMethodError
    end
  end

  def respond_to_missing?(name, include_private = false)
    name.to_s =~ /\Aget_(\w+)\z/ && ENDPOINTS.include?($1.to_sym) || super
  end

  private def fetch(path, params = {}) = "GET #{path} #{params}"
end

c = ApiClient.new
c.get_users(limit: 5)
c.respond_to?(:get_orders)    # true — directory in sync!
c.get_nonsense rescue "NoMethodError, as it should be"
Output
=> "GET /users {limit: 5}"
=> true
=> "NoMethodError, as it should be"

✅ Do — good use cases

  • Proxies/adapters over dynamic surfaces: API clients, RPC stubs, config objects
  • ALWAYS the pair: method_missing + respond_to_missing?, both calling super
  • Prefer define_method when the name set is known (topic 37) — real methods are faster and greppable

❌ Don't — anti-patterns

  • Swallowing everything without super — typos now return nonsense instead of NoMethodError; debugging becomes archaeology
  • Heavy logic in the hot path — method_missing fires only after the FULL lookup chain fails; it's the slowest possible dispatch
Pattern & benefit: for genuinely open-ended surfaces, ghost methods keep the client tiny — new API endpoints work without a client release. This is how OpenStruct works, and how old-Rails find_by_name_and_email dynamic finders worked (since replaced by real methods — a lesson in itself).
Gotcha — why it goes wrong: without respond_to_missing?, everything that checks before calling breaks silently: respond_to? says false, method(:get_users) raises, &method refs fail, mocks misbehave. The improvising concierge exists but is invisible to every directory in the building — the bug reports make no sense until you know this rule.
48instance_eval & class_eval — how DSLs are built

Scenario: ever wondered how RSpec.describe, routes.draw, or a Gemfile work — blocks where bare words like get and gem mean something? That's instance_eval swapping self.

🧠
Analogy: instance_eval is a guest chef badge: you step into someone else's kitchen and for the duration of the visit, "the oven" means their oven — every bare word resolves against the host. DSLs hand your block a badge to their configuration kitchen; that's why get "/users" works bare inside routes.draw.
Code — build a tiny DSL yourself
class PipelineDSL
  def initialize    = @steps = []
  def step(name)    = @steps << name          # the DSL vocabulary
  def parallel(n)   = @steps << "parallel(#{n})"
  def build         = @steps
end

def pipeline(&blk)
  dsl = PipelineDSL.new
  dsl.instance_eval(&blk)     # run the block with self = dsl
  dsl.build
end

pipeline do
  step :fetch          # bare words — they're dsl.step(:fetch)!
  step :transform
  parallel 4
  step :load
end

# class_eval: same trick on a CLASS — add methods from outside:
String.class_eval do
  def shout = upcase + "!"
end
"hello".shout
Output
=> [:fetch, :transform, "parallel(4)", :load]
=> "HELLO!"

✅ Do — good use cases

  • Configuration DSLs where bare-word vocabulary genuinely reads better
  • class_eval with a block (not a string) when metaprogramming methods onto classes
  • Offer a yield-style alternative (do |config|) — explicit receiver, no self-swapping surprises

❌ Don't — anti-patterns

  • DSLs for the sake of cleverness — every swapped self is a place readers get lost; plain methods often win
  • String eval (class_eval "def ...") with interpolated input — injection + un-debuggable; use block form
Pattern & benefit: now you can read the magic: a Gemfile is instance_eval on a Bundler DSL object (gem is a method); RSpec blocks run against example-group objects; routes.draw against a mapper. One mechanism — "run this block with a different self" — explains half the DSLs in the ecosystem (the other half is topic 24's class-body-as-code).
Gotcha — why it goes wrong: inside instance_eval, your own helper methods and ivars are gone — self is the DSL object now, so @my_var is its ivar (nil) and my_helper raises NoMethodError. Locals still work (closures! topic 40) — which is why DSL blocks capture locals fine but mysteriously can't see instance state. When a DSL block behaves weirdly: p self first.
49File I/O & Pathname — reading, writing, walking

Scenario: read a config, stream a huge log, write a report, glob a directory — the file operations every script and Rake task needs.

🧠
Analogy: File.read is photocopying the whole book — fine for a pamphlet, ruinous for an encyclopedia. File.foreach is reading it line by line at the library desk — one line in your head at a time, any size book. The block form of open is the librarian who always collects the book back, even if you faint mid-chapter (exception safety).
Code
File.write("report.txt", "total: 42\n")     # one-shot write
File.read("report.txt")                     # one-shot read (small files!)

File.open("report.txt", "a") { |f| f.puts "appended" }   # auto-close

# streaming — constant memory on any size:
File.foreach("huge.log") do |line|
  process(line) if line.include?("ERROR")
end

require "pathname"
root = Pathname("/var/app")
cfg  = root.join("config", "app.yml")       # portable path math
cfg.exist?
cfg.extname

Dir.glob("app/models/**/*.rb").size         # walk trees
File.basename("/x/y/report.txt", ".*")
Output
=> 10
=> "total: 42\n"
=> true
=> ".yml"
=> 48
=> "report"

✅ Do — good use cases

  • Block form File.open { } always — guaranteed close (topic 6's whole point)
  • File.foreach / each_line for anything possibly large — pairs with .lazy (topic 14)
  • Pathname/File.join over string concatenation — no "dir" + "/" + "file" slash bugs

❌ Don't — anti-patterns

  • File.read on user uploads/logs of unknown size — one 4GB file and the process dies
  • Building paths from user input without validation — ../../etc/passwd traversal is alive and well
Pattern & benefit: file handles are Enumerable — grep, lazy, each_slice all work directly on them, so log-crunching one-liners compose from parts you already know. In Rails, Rails.root.join("config", "x.yml") is the Pathname idiom you'll type weekly.
Gotcha — why it goes wrong: reads and writes buffer — data may not hit disk until close/flush, so a crashed script leaves a half-written file. For must-be-complete writes, write to a tempfile and File.rename into place (atomic on POSIX). And mind encodings: files read as UTF-8 by default; a Latin-1 log throws invalid byte sequence — pass encoding: explicitly when sources are dirty.
50JSON, CSV & YAML — data in, data out★ used daily

Scenario: parse an API response, import a spreadsheet export, read a config file — the three formats that carry 95% of the data you'll touch.

🧠
Analogy: three couriers for the same cargo (hashes and arrays): JSON is the international courier — machines everywhere accept it; CSV is the pallet truck — flat rows, loved by spreadsheets, no nesting; YAML is the handwritten note — human-friendly, indentation-sensitive, best kept for configs humans actually edit.
Code
require "json"
payload = '{"user":{"name":"Asha","tags":["pro","admin"]}}'
data = JSON.parse(payload, symbolize_names: true)
data.dig(:user, :tags)
{ ok: true, at: Time.now }.to_json

require "csv"
csv = "name,price\nMug,299\nPen,49\n"
rows = CSV.parse(csv, headers: true)
rows.map { |r| [r["name"], r["price"].to_i] }
CSV.generate { |c| c << %w[sku qty]; c << ["MUG-01", 3] }

require "yaml"
cfg = YAML.safe_load(File.read("app.yml"))   # safe_ — always
cfg["retries"]
Output
=> ["pro", "admin"]
=> "{\"ok\":true,\"at\":\"2026-07-11 14:22:07 +0530\"}"
=> [["Mug", 299], ["Pen", 49]]
=> "sku,qty\nMUG-01,3\n"
=> 3

✅ Do — good use cases

  • symbolize_names: true on JSON you control; string keys for wild data (topic 2!)
  • CSV with headers: true — rows become name-addressable, order-proof
  • YAML.safe_load / load_file — plain YAML.load can instantiate arbitrary objects (a real RCE vector historically)

❌ Don't — anti-patterns

  • Hand-rolling parsers with split(",") — quoted commas ("Pune, MH") break it on row one; CSV handles quoting
  • YAML as a data interchange format between services — it's for configs; JSON for wire
Pattern & benefit: all three round-trip through plain hashes/arrays, so your Enumerable skills (topics 8–10) ARE your data-wrangling skills: parse → transform with map/group_by → serialize. In Rails, render json: and jsonb columns (topic 79) extend the same mental model.
Gotcha — why it goes wrong: JSON has no symbols, dates, or times — to_json stringifies them, and the round trip gives you back strings: {at: Time.now} → parse → data[:at].class == String. Every "why is my timestamp a string?" bug is this. Parse dates explicitly at the boundary (topic 44), and note CSV gives you strings too — "299" needs to_i (topic 42's strictness rules apply).
51Threads & thread safety — enough to be dangerous-proof

Scenario: fetch five APIs concurrently; and — more importantly — understand why your Puma/Sidekiq code must be thread-safe even if you never spawn a thread yourself.

🧠
Analogy: threads are several cooks in one kitchen. For waiting-heavy work (watching five pots — I/O) more cooks help enormously. Shared state is the single cutting board: two cooks chopping on it simultaneously ruins both dishes (race condition). A Mutex is the "one cook at the board" rule. Best kitchen: every cook brings their own board (no shared mutable state).
Code
# concurrent I/O — the legitimate everyday use:
urls = %w[/users /orders /stats]
results = urls.map { |u| Thread.new { [u, http_get(u)] } }
              .map(&:value)               # join + collect results

# the race — looks fine, loses updates:
count = 0
10.times.map { Thread.new { 1000.times { count += 1 } } }.each(&:join)
count                       # rarely 10_000! read-modify-write races

# the fix:
mutex, safe = Mutex.new, 0
10.times.map { Thread.new { 1000.times { mutex.synchronize { safe += 1 } } } }
        .each(&:join)
safe
Output
=> [["/users", "..."], ["/orders", "..."], ["/stats", "..."]]  # ~parallel
=> 7418        # ❌ nondeterministic — lost updates
=> 10000       # ✅ with mutex

✅ Do — good use cases

  • Threads for concurrent I/O (HTTP fan-out, slow queries) — the GVL releases during I/O
  • Immutable/frozen shared data (topics 22, 36) — can't race what can't change
  • In Rails: reach for ActiveJob concurrency (topic 94) before hand-rolled threads

❌ Don't — anti-patterns

  • Mutable class-level/global state in web apps — Puma runs your code in threads; that "harmless" @@cache is a race (topic 28)
  • Threads for CPU-bound speedups — the GVL serializes Ruby bytecode; use processes/Ractors for that
Pattern & benefit: the practical takeaway isn't "write threaded code" — it's that your code already runs threaded under Puma and Sidekiq. Keep state in locals, ivars on per-request objects, and the DB (whose transactions — topic 71 — are the real concurrency tool), and thread safety mostly falls out for free.
Gotcha — why it goes wrong: count += 1 is three operations (read, add, write) wearing one operator's trenchcoat — two threads interleave and an update vanishes. Worse: races are probabilistic — quiet in dev, corrupting under production load. Also: unhandled exceptions in threads die silently unless you Thread.report_on_exception = true (default since 2.5) or join and check.
52Benchmarking — measure before you optimize

Scenario: two ways to write the same transformation — which is faster? Stop guessing: Ruby ships the measuring tools, and benchmark-ips makes them honest.

🧠
Analogy: optimizing without measuring is dieting without a scale — you'll confidently optimize the wrong thing (everyone's instincts about "slow" code are famously bad). Benchmark.ips is the lab-grade scale: it warms up first (JIT, caches), runs long enough to be statistical, and reports the margin of error.
Code
require "benchmark/ips"    # gem install benchmark-ips

HAY = ("a".."zz").to_a
NEEDLES = HAY.sample(500)

Benchmark.ips do |x|
  x.report("array include?") { NEEDLES.each { |n| HAY.include?(n) } }
  x.report("set include?")   { s = HAY.to_set; NEEDLES.each { |n| s.include?(n) } }
  x.compare!
end

# quick one-off timing (stdlib):
require "benchmark"
Benchmark.realtime { 10_000.times { "x" * 100 } }.round(4)
Output
Warming up --------------------------------------
     array include?    212.000 i/100ms
       set include?    2.681k  i/100ms
Comparison:
       set include?:  26810.3 i/s
     array include?:   2124.4 i/s - 12.62x slower

=> 0.0041

✅ Do — good use cases

  • ips + compare! for micro choices; measure with realistic data sizes
  • In Rails: read the log's ms figures, .explain(:analyze) on slow relations (topic 99) — you know EXPLAIN better than most
  • Optimize the measured hot 5%, then STOP

❌ Don't — anti-patterns

  • Micro-optimizing readable code that runs 4 times a day — clarity compounds, nanoseconds don't
  • Benchmarking with toy data (10-element arrays) then extrapolating to millions — complexity classes flip winners
Pattern & benefit: ips (iterations per second) with warm-up and error margins beats naive timing loops — it's the difference between anecdote and evidence. Most real Rails speedups come from the database side anyway (N+1s — topic 67, missing indexes, over-fetching) — measurement tells you which side to fix.
Gotcha — why it goes wrong: benchmarks lie in tiny ways: dead-code elimination (a block whose result is unused may be optimized away), shared setup mutating between reports, GC pauses landing in one candidate's lap. Keep blocks self-contained, return the computed value, and re-run — if two runs disagree wildly, the benchmark (not Ruby) is the bug.
Part II — Rails · The Framework

Rails Foundations

53MVC & convention over configuration

Scenario: rails new shop generates 60+ files. Where does code go? Rails' answer: it has already decided — and every Rails app on Earth uses the same layout.

🧠
Analogy: a restaurant. The customer's order (HTTP request) goes to a waiter (controller) — he never cooks. He asks the kitchen (model) which owns ingredients and recipes (data + business rules), then delivers the dish on nice plating (view). Convention over configuration is the franchise manual: every branch keeps knives in the same drawer, so any chef can walk into any branch and start cooking.
The naming contract — learn this and you know every Rails app
Model      app/models/product.rb          class Product      (singular)
Table      products                                          (plural, snake_case)
Controller app/controllers/products_controller.rb            (plural)
Views      app/views/products/index.html.erb                 (action name)
Route      GET /products  →  ProductsController#index  →  renders index.html.erb
Shell — a full CRUD slice in two commands
$ rails new shop --database=postgresql
$ bin/rails generate scaffold Product name:string price:decimal
$ bin/rails db:create db:migrate
$ bin/rails server     # http://localhost:3000/products — it just works

✅ Do — good use cases

  • Follow the conventions exactly at first — fight the framework later, if ever
  • Fat models / service objects for business logic; skinny controllers that only orchestrate
  • Read a generated scaffold once, top to bottom — it's the best Rails tutorial there is

❌ Don't — anti-patterns

  • Business logic in controllers or views — untestable, unreusable
  • Renaming/reshuffling the folder layout — you're deleting the shared map in every teammate's head
Pattern & benefit: because Product implies table products, controller ProductsController, and view folder products/, Rails wires everything with zero config files. That's the productivity trick: decisions you don't make are bugs you can't create — and any Rails dev is productive in your codebase on day one.
Gotcha — why it goes wrong: the magic runs on exact naming. Class Person maps to table people (Rails knows English plurals!), but get creative — table named product_info, file not matching class name — and you'll hit NameError: uninitialized constant with no obvious cause. When lost: Product.table_name in console shows what Rails expects.
54Routes — resources & path helpers

Scenario: config/routes.rb is your app's front door — it maps every URL to a controller action. One word, resources, generates the whole RESTful set.

🧠
Analogy: a hotel switchboard operator: every incoming call (URL + verb) gets connected to exactly one room (controller#action). resources :products installs the standard 7-line speed-dial card. Path helpers (product_path(id)) are asking the operator "ring room 12" by name — if rooms get renumbered (URLs change), your calls still connect.
config/routes.rb
Rails.application.routes.draw do
  root "products#index"

  resources :products          # all 7 RESTful routes in one line

  resources :orders, only: [:index, :show, :create] do
    member     { post :cancel }      # POST /orders/:id/cancel
    collection { get  :export }      # GET  /orders/export
  end

  namespace :admin do
    resources :users             # /admin/users → Admin::UsersController
  end
end
The 7 routes `resources :products` creates
GET    /products          index    all products      products_path
GET    /products/new      new      blank form        new_product_path
POST   /products          create   insert            products_path
GET    /products/:id      show     one product       product_path(p)
GET    /products/:id/edit edit     edit form         edit_product_path(p)
PATCH  /products/:id      update   modify            product_path(p)
DELETE /products/:id      destroy  delete            product_path(p)

✅ Do — good use cases

  • resources with only:/except: — expose only what exists
  • Path helpers everywhere: product_path(@product), never "/products/#{@product.id}"
  • bin/rails routes -g product when you forget a helper name (you will)

❌ Don't — anti-patterns

  • 20 custom get "..." routes for what's really CRUD on a hidden resource — "cancel an order" is create on a cancellation
  • Deeply nested resources 3+ levels — /users/1/orders/2/items/3/reviews is pain; nest one level max
Pattern & benefit: REST-by-default forces a resource-oriented design that stays uniform as the app grows. Helpers keep URLs in exactly one file: change resources :products to a different path and every link in the app updates itself.
Gotcha — why it goes wrong: routes match top-to-bottom — a greedy get "*path" or a custom get "products/featured" placed below resources :products never fires, because /products/featured already matched show with id="featured". Order specific routes above general ones, and check with bin/rails routes.
55Controllers — params & strong parameters★ used daily

Scenario: a form posts user data. Between "raw request" and "database write" stands the controller — and strong parameters, the allowlist that stops attackers from setting admin: true on themselves.

🧠
Analogy: airport security for form data. Everything in the request lands on the belt (params), but only items on the approved list (permit) board the plane into your model. The passenger who slipped role=admin into his bag watches it get quietly binned.
app/controllers/products_controller.rb
class ProductsController < ApplicationController
  before_action :set_product, only: [:show, :edit, :update, :destroy]

  def create
    @product = Product.new(product_params)      # only permitted fields
    if @product.save
      redirect_to @product, notice: "Created!"
    else
      render :new, status: :unprocessable_entity  # re-show form + errors
    end
  end

  private

  def set_product
    @product = Product.find(params[:id])   # raises 404 if missing — good
  end

  def product_params
    params.expect(product: [:name, :price, :category_id])  # Rails 8
    # Rails ≤7: params.require(:product).permit(:name, :price, :category_id)
  end
end
What an attacker sends vs what gets through
POST params: { product: { name: "Mug", price: 99, admin: true, role: "root" } }
product_params  =>  { "name" => "Mug", "price" => "99" }
# admin/role silently filtered — mass-assignment attack defused

✅ Do — good use cases

  • One private *_params method per resource — the single place field access is granted
  • before_action for repeated lookups/auth — but keep the list short and boring
  • Controllers do: authenticate → authorize → load → act → respond. Nothing else

❌ Don't — anti-patterns

  • Product.new(params[:product]) raw — Rails raises ForbiddenAttributesError, and it's right to
  • permit! (allow-everything) — you've disabled airport security entirely
  • Business logic in actions — extract to models/service objects once an action passes ~10 lines
Pattern & benefit: mass assignment is how GitHub got famously hacked in 2012 — strong params make the allowlist explicit and mandatory. params[:id] vs params.expect(...): reading single values is fine; it's writing to models that must pass the checkpoint.
Gotcha — why it goes wrong: adding a DB column doesn't add it to permit — the form field submits, gets filtered, and "the save silently ignores my new field" costs an hour of confusion (check logs: Unpermitted parameter: :new_field). Also: find raises RecordNotFound (→ 404) while find_by(id:) returns nil — pick per context (topic 57).
56Migrations — evolving the schema safely

Scenario: you need a new column. In Rails you never touch the DB by hand — you write a migration: versioned, ordered, runnable-anywhere schema changes. (Coming from Postgres, this is DDL under version control.)

🧠
Analogy: migrations are Git commits for your database schema. Each file is one diff; the schema_migrations table records which diffs are applied; any fresh database can replay history to reach the exact current state. Nobody "just edits the production schema" for the same reason nobody edits files on the server directly.
Shell + generated migration
# $ bin/rails g migration AddStatusToOrders status:string:index

class AddStatusToOrders < ActiveRecord::Migration[8.0]
  def change
    add_column :orders, :status, :string, default: "pending", null: false
    add_index  :orders, :status
  end
end

# richer example — new table
create_table :reviews do |t|
  t.references :product, null: false, foreign_key: true   # FK + index
  t.integer :rating, null: false
  t.text :body
  t.timestamps                       # created_at / updated_at
end
Shell
$ bin/rails db:migrate
== AddStatusToOrders: migrating ======================
-- add_column(:orders, :status, :string, ...)  -> 0.0087s
-- add_index(:orders, :status)                 -> 0.0042s
$ bin/rails db:rollback     # undo last — change method auto-reverses

✅ Do — good use cases

  • null: false + default: + DB-level constraints — let Postgres defend the data (you know why!)
  • t.references :product, foreign_key: true — real FKs, free index
  • Separate deploys for big changes: add column → backfill in batches → add constraint

❌ Don't — anti-patterns

  • Editing an already-run migration — teammates' DBs silently diverge; write a new one
  • Data manipulation inside schema migrations referencing model classes — models change, old migrations break
  • add_index on a huge production table without algorithm: :concurrently — table locked, site down
Pattern & benefit: db/schema.rb is the always-current snapshot (new machines run db:schema:load, not 500 old migrations). The change method knows how to reverse most operations, so rollbacks are free. Schema history in code review means DBAs-by-accident catch string where decimal belonged.
Gotcha — why it goes wrong: for a Postgres person the trap is trusting model validations alone — validates :email, uniqueness: true races under concurrency (two requests both pass the SELECT check, both INSERT). The fix is yours already: add_index :users, :email, unique: true. Validations are UX; constraints are truth (topic 68).
57ActiveRecord CRUD — create, find, update, destroy★ used daily

Scenario: the four operations you'll run a thousand times a day — and the crucial split between the quiet versions (save → false) and the loud ones (save! → raise).

🧠
Analogy: an ActiveRecord model is a translator standing between Ruby and Postgres — you speak objects, it speaks SQL. The bang/no-bang pair is how it reports failure: save is a polite clerk who returns your form with a shrug (false + errors list); save! pulls the fire alarm (exception). Forms want the clerk; background jobs want the alarm.
Code — with the SQL it generates
p = Product.create!(name: "Mug", price: 299)
# INSERT INTO "products" ("name","price",...) VALUES ($1,$2,...) RETURNING "id"

Product.find(42)          # SELECT ... WHERE id = 42 LIMIT 1  (raises if missing)
Product.find_by(name: "Mug")   # WHERE name = 'Mug' LIMIT 1   (nil if missing)
Product.find_or_create_by!(sku: "MUG-01") { |x| x.price = 299 }

p.update!(price: 349)     # UPDATE "products" SET "price" = 349 WHERE id = 1
p.price = 399; p.changed? # dirty tracking
p.save!

p.destroy                 # DELETE + runs callbacks/associations
p.persisted?
Output
=> #<Product id: 1, name: "Mug", price: 299>
=> #<Product id: 42, ...>
=> #<Product id: 1, name: "Mug", ...>
=> true          # changed?
=> #<Product id: 1, price: 399>
=> false         # persisted? after destroy

✅ Do — good use cases

  • Bang versions (create!/save!/update!) in jobs, seeds, services — fail loud
  • Plain versions in controllers where you branch on false and re-render the form
  • find for URLs/ids (missing = legit 404); find_by when absence is normal

❌ Don't — anti-patterns

  • Ignoring save's return value — the silent-false bug: nothing saved, nobody told you
  • update_attribute/update_column to "get around" validations — you're injecting invalid data past your own guards
Pattern & benefit: dirty tracking (changed?, price_was, saved_change_to_price?) tells you exactly what's modified — Rails writes only changed columns. find_or_create_by collapses the lookup-or-insert dance (though for true concurrency safety you'll still want your old friend, a unique index + create_or_find_by).
Gotcha — why it goes wrong: delete vs destroy is a classic: delete fires bare SQL DELETE — no callbacks, no dependent: :destroy cascade — leaving orphaned children. Same trap: update_column skips validations and updated_at. Rule: default to the callback-running versions; use the raw ones only deliberately, in bulk operations you fully understand.

App Structure & Configuration

58Generators — scaffold less, generate right

Scenario: rails generate can produce anything from a single migration to a full CRUD slice — knowing which generator (and which flags) keeps your app free of dead files.

🧠
Analogy: generators are cookie-cutter templates at a print shop: business card (migration), letterhead (model), or the full wedding-stationery box (scaffold). Ordering the full box for every occasion buries you in matching envelopes you'll never send — most days you want exactly one card.
Shell — the ones you'll actually use
$ rails g model Product name:string price:decimal category:references
      # → model + migration (+ tests). The workhorse.

$ rails g migration AddStatusToOrders status:string:index
      # → just a migration — most common of all

$ rails g controller Dashboards show
      # → controller + view + route helpers

$ rails g scaffold Post title:string body:text
      # → EVERYTHING (model/controller/7 views/routes/tests)

$ rails destroy model Product     # generated wrong? undo it all
$ rails g model --help            # every generator documents itself
Output
invoke  active_record
create    db/migrate/20260711091412_create_products.rb
create    app/models/product.rb
invoke    test_unit
create      test/models/product_test.rb

✅ Do — good use cases

  • g migration / g model daily; g scaffold once while learning, then rarely
  • references type in generators — FK column + index + belongs_to wired
  • rails destroy to cleanly reverse a generation

❌ Don't — anti-patterns

  • Scaffolding every resource — you'll delete half the files or worse, ship them unreviewed
  • Hand-creating migration files — the timestamp prefix matters; let the generator name them
Pattern & benefit: generators encode the naming conventions (topic 53) so you physically can't misname a migration class or model file. Config in config/application.rb can switch test framework, skip helpers/assets per generate — tune once, benefit every time.
Gotcha — why it goes wrong: generated code is yours — scaffold controllers with formats you don't serve, jbuilder files, empty helpers all rot unless deleted. Review every generated file like you wrote it (you did). And generating a model does NOT run the migration — the "table doesn't exist" error right after generating is just a missing db:migrate.
59Environments & credentials — dev, test, prod, secrets

Scenario: code that emails real customers from your laptop, or shows stack traces to the public — environment config is what keeps each world behaving like itself. Plus: where API keys live.

🧠
Analogy: the same play performed in rehearsal, dress rehearsal, and opening night — same script (code), different stage rules: rehearsal stops mid-scene to debug (dev error pages), dress rehearsal uses props (test doubles), opening night is silent-failures-and-understudies (prod error pages, monitoring). The credentials file is the theater safe: one master key opens it, and the key never travels with the script.
Code
Rails.env                        # "development" | "test" | "production"
Rails.env.production?

# config/environments/production.rb — per-stage rules:
config.consider_all_requests_local = false   # no stack traces to users
config.force_ssl = true

# encrypted secrets — committable, master.key is NOT:
# $ bin/rails credentials:edit
# aws:
#   access_key_id: AKIA...
# stripe_secret: sk_live_...

Rails.application.credentials.stripe_secret
Rails.application.credentials.dig(:aws, :access_key_id)

ENV.fetch("DATABASE_URL")        # 12-factor alternative — also standard
ENV.fetch("QUEUE", "default")    # fetch with default (topic 10 habits!)
Output
=> "production"
=> true
=> "sk_live_..."
=> "AKIA..."

✅ Do — good use cases

  • Behavior differences live in config/environments/*, not if Rails.env.production? sprinkled through app code
  • Credentials for static secrets; ENV for per-deploy infrastructure (URLs, pool sizes)
  • ENV.fetch (raises when missing) over ENV[] (silent nil) for required vars

❌ Don't — anti-patterns

  • Committing master.key or real .env files — the one unforgivable git sin
  • Testing against development behavior — "works locally" with caching off, eager loading off means prod is a different app; that's what staging is for
Pattern & benefit: encrypted credentials solve secret-sharing elegantly — the ciphertext lives in git (diffable, reviewable), only RAILS_MASTER_KEY travels out-of-band. Per-environment credentials (credentials:edit --environment production) keep staging keys from ever touching prod.
Gotcha — why it goes wrong: the classic deploy-day trio: missing RAILS_MASTER_KEY on the server (boot crash), forgetting production eager-loads code (an autoload-order bug dev never showed), and dev-mode caching hiding a stampede. Run RAILS_ENV=production rails s locally once before your first real deploy — cheap insurance.
60Concerns — shared model/controller behavior done right

Scenario: Posts, Comments, and Photos all need soft-archiving with scopes and validations. A concern packages the whole trait — associations, scopes, class methods — into one includable module.

🧠
Analogy: a concern is a franchise starter-kit for a trait: include "Archivable" and the kit unboxes everything at once — the counter scope, the archive method, the validation poster for the wall. Plain modules (topic 19) hand over instance methods only; the concern kit also wires the class-level stuff (scopes, callbacks) on installation.
app/models/concerns/archivable.rb
module Archivable
  extend ActiveSupport::Concern

  included do                      # runs IN the including class's context
    scope :active,   -> { where(archived_at: nil) }
    scope :archived, -> { where.not(archived_at: nil) }
  end

  class_methods do
    def archive_all! = update_all(archived_at: Time.current)
  end

  def archive!  = update!(archived_at: Time.current)
  def archived? = archived_at.present?
end

class Post < ApplicationRecord
  include Archivable
end

Post.active.count
Post.first.archive!
Output
=> 41    # scope from the concern
=> true  # instance method too
SQL: UPDATE "posts" SET "archived_at" = ... WHERE "id" = 1

✅ Do — good use cases

  • True shared traits across models: Archivable, Sluggable, Searchable, Trackable
  • Controller concerns for shared auth/format behavior included in several controllers
  • Test the concern once with a dummy class — every includer inherits the coverage

❌ Don't — anti-patterns

  • Slicing ONE fat model into 6 concerns used once each — the 2000 lines didn't shrink, they hid (junk-drawer warning from topic 19 applies double)
  • Concerns depending on each other's ivars/methods invisibly — include order becomes load-bearing
Pattern & benefit: included do solves the problem plain Ruby makes verbose (the self.included(base) + base.class_eval dance — topics 19, 48): class-level declarations run in the includer's context at include time. One trait, one file, N models — with the full toolkit, not just methods.
Gotcha — why it goes wrong: a concern is still shared code — change Archivable for Posts and you changed Comments and Photos too; run all includers' tests. And name collisions bite silently: two concerns both defining status_label — last include wins (topic 29's chain rule). Keep concern method names trait-prefixed when generic.
61Service objects — where business logic goes to grow up★ used daily

Scenario: "place an order" means: validate stock, charge payment, create records, enqueue emails — five collaborators, one workflow. Controller? Too fat. Model callback? Spooky (topic 69). Enter the service object.

🧠
Analogy: a wedding planner. The venue, caterer, and photographer (models) each do their one job well; the planner owns the sequence — book venue, then caterer, refund everything if the photographer cancels. You don't ask the caterer to coordinate the wedding, and you don't ask the Order model to charge credit cards.
app/services/place_order.rb
class PlaceOrder
  Result = Data.define(:success?, :order, :error)     # topic 22!

  def initialize(cart:, user:, payment_method:)
    @cart, @user, @payment_method = cart, user, payment_method
  end

  def call
    ensure_stock!
    order = nil
    ActiveRecord::Base.transaction do                 # topic 71
      order  = @user.orders.create!(items: @cart.items, total: @cart.total)
      charge = PaymentGateway.charge!(@payment_method, @cart.total)
      order.update!(charge_id: charge.id)
    end
    OrderMailer.confirmation(order).deliver_later     # after commit
    Result.new(success?: true, order:, error: nil)
  rescue StockError, PaymentGateway::Declined => e
    Result.new(success?: false, order: nil, error: e.message)
  end
end

# controller shrinks to orchestration:
result = PlaceOrder.new(cart:, user: current_user, payment_method:).call
result.success?
Output
=> #<data Result success?=true, order=#<Order id: 8124...>, error=nil>
=> true

✅ Do — good use cases

  • Multi-model, multi-step workflows: checkout, onboarding, imports, sync jobs
  • Return a Result object — callers branch on success?, no exception-based control flow at the boundary
  • One public method (call), descriptive class name = greppable business vocabulary

❌ Don't — anti-patterns

  • UserService with 14 unrelated methods — that's a junk drawer with a title; one service = one use case
  • Services calling services calling services — if the chain is deep, the design wants rethinking
Pattern & benefit: the workflow gets a NAME, a home, and a test file — PlaceOrder tests run without HTTP, controllers shrink to ~5 lines, and models keep only their own invariants. When the product asks "what happens when someone orders?", the answer is one readable file.
Gotcha — why it goes wrong: mailers/jobs enqueued inside the transaction can fire before commit (the topic 94 race) — notice deliver_later sits after the block. And services that reach into params or session stop being plain Ruby — pass values in, keep the web out; that's what makes them a joy to test.
62ActiveSupport — the toolkit hiding in plain sight★ used daily

Scenario: half of "Rails magic" is actually ActiveSupport — core-class extensions you'll use hourly: present?, blank?, 2.days, squish, in_groups_of...

🧠
Analogy: ActiveSupport is the multitool Rails slips into Ruby's pocket — suddenly strings, numbers, hashes, and nil itself have extra blades. You've been using it without noticing: every 2.days.ago is a blade, not the base language.
Code — the greatest hits
"".blank?         # nil, "", "  ", [], {} → true
"  hi  ".present? # opposite of blank?
params[:q].presence || "all"   # THE default idiom (topic 3)

"  too   many spaces ".squish
"user_profile".camelize
"UserProfile".underscore.pluralize
"₹1,499".delete("^0-9").to_i

5.days.from_now
1.year.ago
Time.current.beginning_of_week

h = { name: "Asha", role: :admin, tmp: 1 }
h.slice(:name, :role)          # take only these keys
h.except(:tmp)                 # everything but
[1,2,3,4,5].in_groups_of(2, nil)
%w[a b].to_sentence
Output
=> true
=> true
=> "all"
=> "too many spaces"
=> "UserProfile"
=> "user_profiles"
=> 1499
=> 2026-07-16 14:22 +0530
=> {name: "Asha", role: :admin}
=> [[1, 2], [3, 4], [5, nil]]
=> "a and b"

✅ Do — good use cases

  • blank?/present?/presence as your default nil-and-empty vocabulary in Rails code
  • Duration helpers (2.days) for readable time math — they're real Duration objects
  • Skim the ActiveSupport Core Extensions guide ONCE — hours of future code deleted

❌ Don't — anti-patterns

  • Hand-rolling what exists (str.strip.gsub(/\s+/, " ") is squish) — the reviewer will know
  • Assuming these work in plain Ruby scripts — outside Rails you must require "active_support/all" (or accept NoMethodError)
Pattern & benefit: a shared extended vocabulary across every Rails codebase on Earth — presence, squish, slice mean the same thing in every shop you'll ever work at. The inflectors (camelize/underscore/pluralize) are the same engine driving convention-over-configuration (topic 53).
Gotcha — why it goes wrong: blank? and empty-string truthiness diverge: "" is truthy to Ruby (topic 3) but blank to Rails — if str and if str.present? disagree exactly when it matters (form fields!). In Rails code, prefer present? for "has a real value". And 0.blank? is false — zero is a value, not an absence; don't reach for blank? on numbers.
63Time zones — Time.current vs Time.now★ used daily

Scenario: your server runs UTC, your users are in IST, and "today's orders" is off by 5½ hours at both ends of the day. Time zones are the most-shipped bug in web apps.

🧠
Analogy: the database stores every event on a single world clock (UTC) — one truth, no ambiguity. Each user wears a local wristwatch. Rails is the translator at the border: Time.current reads the wristwatch Rails knows about; Time.now reads the server's wall clock — some random machine in Virginia. Every zone bug is someone reading the wrong clock.
Code
# config/application.rb
config.time_zone = "Asia/Kolkata"     # display zone; DB stays UTC

Time.current                # ✅ zone-aware (ActiveSupport::TimeWithZone)
Time.now                    # ❌ server's OS zone — avoid in app code
Date.current                # ✅ today in YOUR zone
Date.today                  # ❌ today on the SERVER

Time.current.zone
Time.zone.parse("2026-07-11 09:00")   # parse as IST, stored as UTC

# per-user zones — the pro move:
Time.use_zone(user.time_zone) do
  Date.current              # "today" for THIS user
end

# range for "today's orders" — zone-correct:
Order.where(created_at: Time.current.all_day)
Output
=> Sat, 11 Jul 2026 14:22:07.281 IST +05:30
=> 2026-07-11 08:52:07 +0000        # server clock (UTC) — different!
=> "IST"
=> Sat, 11 Jul 2026 09:00:00 IST +05:30
SQL: WHERE created_at BETWEEN '2026-07-10 18:30:00' AND '2026-07-11 18:29:59.999'
      -- note the UTC shift — Rails translated for you

✅ Do — good use cases

  • Always: Time.current, Date.current, Time.zone.parse, x.hours.ago — the zone-aware family
  • Store UTC (Rails default — don't fight it), convert at display: t.in_time_zone(user.tz)
  • "Whole day" queries via all_day/all_week ranges — they encode the zone math

❌ Don't — anti-patterns

  • Time.now / Date.today in app code — works on your laptop (your zone), breaks on the UTC server. RuboCop-Rails flags this; let it
  • Storing local times or zone-less strings in the DB — the ambiguity is forever
Pattern & benefit: one rule — UTC at rest, zone at the edges — and the whole class of bugs disappears: comparisons work (UTC is total-ordered), DST transitions are the library's problem, and adding a second user zone someday is a display change, not a migration. Postgres timestamptz is this same philosophy — you already believe it.
Gotcha — why it goes wrong: the boundary bug: at 11 PM IST on July 11, Date.today on a UTC server says July 11 but the user's "today" started at 18:30 UTC yesterday — daily reports quietly include/exclude the wrong 5½ hours and nobody notices until finance reconciles. Any grouping by "day" must pick whose day — that's use_zone or explicit ranges, never bare Date.today.

ActiveRecord Mastery

64Query interface — chaining & lazy evaluation★ used daily

Scenario: where, order, limit... chained freely, and no SQL runs until you actually use the results. Understanding when the query fires is the single biggest ActiveRecord insight.

🧠
Analogy: building a query is writing a shopping list — you can keep adding lines ("only paid ones", "newest first", "just 10") and nothing happens at the store. The trip happens once, when you actually need the groceries (each, to_a, first). One list, one trip, one SQL statement.
Code — with generated SQL
orders = Order.where(status: "paid")     # no SQL yet!
orders = orders.where("total > ?", 500)  # still no SQL
orders = orders.order(created_at: :desc).limit(10)

orders.each { |o| puts o.id }            # NOW it fires, once:
# SELECT "orders".* FROM "orders"
# WHERE "orders"."status" = 'paid' AND (total > 500)
# ORDER BY "orders"."created_at" DESC LIMIT 10

# aggregates — computed by Postgres, not Ruby:
Order.group(:status).sum(:total)
# SELECT SUM("total"), "status" FROM "orders" GROUP BY "status"

Order.where(status: "paid").pluck(:id)   # just the column, no objects
Order.where.not(status: "cancelled").exists?
Output
=> {"paid" => 145300.0, "pending" => 22100.0}
=> [1, 3, 8, 14]
=> true

✅ Do — good use cases

  • Build relations conditionally: q = q.where(city:) if city — compose, then fire
  • Aggregate in SQL: .count .sum .group .average — never load-then-loop
  • pluck for bare values, exists? for presence — lighter than loading models

❌ Don't — anti-patterns

  • Order.all.select { |o| o.paid? } — loads the whole table into Ruby to do a WHERE clause's job
  • Order.all.count vs Order.count? Fine — but orders.to_a.count after you only needed a number: you shipped rows to count them
Pattern & benefit: laziness makes relations composable — scopes, conditionals, and pagination all stack onto one relation object that compiles to a single efficient SQL statement. Check what fires with .to_sql in console, and watch the log: your Postgres instincts fully transfer here.
Gotcha — why it goes wrong: calling an array method (.map, .select {} with a block, .sort_by) silently converts the relation to loaded records — chain order matters: .where(...).map is fine, .map.where is impossible. The stealthiest version: .size on a loaded relation counts in Ruby, on an unloaded one runs COUNT — usually what you want, but know which you're getting.
65Associations — belongs_to / has_many★ used daily

Scenario: orders belong to customers; customers have many orders. Two lines of declaration and the foreign key becomes a rich object graph you navigate with methods.

🧠
Analogy: the foreign key is the phone number written in a contact bookbelongs_to :customer teaches the order to dial it for you: order.customer rings up the right row. has_many :orders is the reverse directory. You stop thinking in JOIN keys and start thinking "this order's customer."
Code
class Customer < ApplicationRecord
  has_many :orders, dependent: :destroy   # decide the orphan policy!
end

class Order < ApplicationRecord
  belongs_to :customer                    # FK column: customer_id
end

c = Customer.create!(name: "Acme")
c.orders.create!(total: 500)              # FK set automatically
c.orders.create!(total: 1200)

c.orders.count                    # SELECT COUNT(*) ... WHERE customer_id = 1
c.orders.where("total > ?", 800)  # associations are relations — chain away!
Order.first.customer.name

c.destroy                         # children destroyed too (dependent: :destroy)
Output
=> 2
=> [#<Order id: 2, total: 1200>]
=> "Acme"

✅ Do — good use cases

  • Always declare dependent: on has_many:destroy (callbacks), :delete_all (fast SQL), :nullify, or :restrict_with_error
  • t.references :customer, foreign_key: true in the migration — real DB constraint underneath
  • Treat c.orders as a scope-able relation: c.orders.recent.unpaid

❌ Don't — anti-patterns

  • Managing raw customer_id by hand when the association could — order.customer = c reads better and sets the FK
  • No dependent: option at all — deleting a parent leaves orphans (or explodes on the FK constraint)
Pattern & benefit: belongs_to also validates presence by default (Rails 5+) — an order without a customer fails validation before Postgres even sees it. Association methods (build, create!, <<) handle the FK bookkeeping, and everything stays lazy and chainable like topic 64.
Gotcha — why it goes wrong: the name states the direction and people flip it: belongs_to goes on the table holding the FK column (orders have customer_id → Order belongs_to). Flip them and nothing works. Second trap: dependent: :destroy on huge associations loads and destroys children one by one (callbacks each) — for a million rows you want :delete_all semantics or FK ON DELETE CASCADE, which you already know from Postgres.
66has_many :through — many-to-many with a real join model

Scenario: patients see many doctors; doctors see many patients — and each connection carries data (the appointment time). The join table deserves to be a first-class model.

🧠
Analogy: the appointment book at a clinic's front desk. Doctor and patient aren't linked directly — every relationship flows through a booked slot, and the slot itself holds real information: date, room, notes. has_many :through says "to find my patients, walk through my appointment book."
Code
class Doctor < ApplicationRecord
  has_many :appointments
  has_many :patients, through: :appointments
end

class Appointment < ApplicationRecord   # the join model — with data!
  belongs_to :doctor
  belongs_to :patient
  # columns: doctor_id, patient_id, scheduled_at, room
end

class Patient < ApplicationRecord
  has_many :appointments
  has_many :doctors, through: :appointments
end

dr = Doctor.create!(name: "Dr. Rao")
pt = Patient.create!(name: "Asha")
Appointment.create!(doctor: dr, patient: pt, scheduled_at: 2.days.from_now)

dr.patients.pluck(:name)
# SELECT "patients"."name" FROM "patients"
#   INNER JOIN "appointments" ON "patients"."id" = "appointments"."patient_id"
#   WHERE "appointments"."doctor_id" = 1
pt.doctors.count
Output
=> ["Asha"]
=> 1

✅ Do — good use cases

  • Any many-to-many where the link has attributes or a lifecycle (enrollments, memberships, follows)
  • Unique index on [doctor_id, patient_id] if a pair may link once — DB-enforced
  • Name the join model after the domain concept: Appointment, Enrollment, Membership — not DoctorPatient

❌ Don't — anti-patterns

  • has_and_belongs_to_many — the old headless join table; the moment you need a timestamp or status on the link, you're stuck. Skip it entirely
  • Stuffing link data (role, status) onto one of the end models — it belongs on the join
Pattern & benefit: you get both views for free — the direct shortcut (dr.patients) and the rich one (dr.appointments.upcoming with scopes, validations, callbacks on Appointment). The generated SQL is the exact INNER JOIN you'd hand-write.
Gotcha — why it goes wrong: dr.patients << pt creates the join row with only the two FKs — required columns like scheduled_at make it fail validation. When the join carries data, create the join record explicitly (Appointment.create!(...)). Also mind duplicates: dr.patients lists a patient once per appointment — add .distinct when you mean unique people.
67N+1 queries — includes, the #1 Rails performance bug★ used daily

Scenario: a page lists 50 orders with customer names. It works — via 51 queries. The N+1 is the most common performance bug in all of Rails, and the fix is one word.

🧠
Analogy: a waiter taking 50 dessert orders and making 50 separate trips to the kitchen, one per table — instead of one trip with a tray. includes is the tray: "while you're fetching the orders, bring all their customers too."
Code
# ❌ THE BUG — looks innocent, fires 1 + 50 queries
Order.limit(50).each do |o|
  puts "#{o.id}: #{o.customer.name}"     # each .customer = one SELECT
end

# ✅ THE FIX — 2 queries total
Order.includes(:customer).limit(50).each do |o|
  puts "#{o.id}: #{o.customer.name}"     # already in memory
end

# nested associations? same idea:
Order.includes(:items, customer: :address)

# strict mode — make Rails RAISE on lazy loading (find N+1s in dev/test):
# config.active_record.strict_loading_by_default = true
The log tells the story
# before:
Order Load    SELECT "orders".* FROM "orders" LIMIT 50
Customer Load SELECT ... WHERE "id" = 7   ← ×50 of these!

# after:
Order Load    SELECT "orders".* FROM "orders" LIMIT 50
Customer Load SELECT "customers".* FROM "customers" WHERE "id" IN (7, 12, 31, ...)

✅ Do — good use cases

  • includes whenever a loop touches an association — lists, JSON serializers, mailers
  • Watch the dev log / use the bullet gem — N+1s announce themselves as repeated one-row SELECTs
  • strict_loading on hot models or globally in dev — turns silent N+1s into loud errors

❌ Don't — anti-patterns

  • includes-everything defensively "just in case" — loading megabytes nobody reads is the opposite bug
  • Fixing N+1 in the controller while the view secretly touches another association — trace the whole render path
Pattern & benefit: includes picks a strategy for you: separate WHERE id IN (...) queries (preload) normally, or a LEFT OUTER JOIN (eager_load) when you also filter on the association. You can force either. From your Postgres seat: it's the difference between two set-based queries and one big join — both infinitely better than 51 round-trips.
Gotcha — why it goes wrong: N+1s hide in views and partials — controller looks clean, but render @orders calls a partial that touches order.customer.name 50 times. And includes(:customer).where("customers.name = ?", ...) with a string condition needs references(:customers) — or better, where(customers: { name: ... }), which tells Rails to JOIN properly.
68Validations — guarding data at the model gate★ used daily

Scenario: no product without a name, no negative price, no duplicate SKU — declared once in the model, enforced on every save, with human-readable errors ready for the form.

🧠
Analogy: a nightclub doorman with a checklist. Every record queues at the door on save; fail any line and you're not getting in — plus you're told exactly why ("price must be greater than 0"). But a doorman can be raced or bribed — the concrete wall behind him is the database constraint (topic 56). Clubs need both.
Code
class Product < ApplicationRecord
  validates :name,  presence: true, length: { maximum: 80 }
  validates :sku,   presence: true, uniqueness: true
  validates :price, numericality: { greater_than: 0 }
  validates :state, inclusion: { in: %w[draft active retired] }

  validate :discount_below_price          # custom rule

  private

  def discount_below_price
    return if discount.blank? || discount < price
    errors.add(:discount, "must be below price")
  end
end

p = Product.new(name: "", price: -5)
p.valid?
p.errors.full_messages
p.save          # returns false — nothing hit the DB
Output
=> false
=> ["Name can't be blank", "Sku can't be blank",
    "Price must be greater than 0", "State is not included in the list"]
=> false

✅ Do — good use cases

  • Validations for user experience — instant, friendly, per-field messages
  • Mirror the critical ones as DB constraints — null: false, CHECK, unique index
  • Custom methods for cross-field rules; errors.add(:field, "...") targets the message

❌ Don't — anti-patterns

  • Trusting uniqueness: true alone — it's SELECT-then-INSERT, and you know that race: two requests, both pass, both insert. The unique index is the real guard
  • Validations with side effects or slow external calls — they run on every save
Pattern & benefit: one declaration powers everything: valid? checks, form error rendering, API 422 responses, and the bang methods' ActiveRecord::RecordInvalid. Conditional validations (if: :published?) and on: :create contexts keep rules precise.
Gotcha — why it goes wrong: validations only run through the front door — update_column, insert_all, delete_all, raw SQL all bypass them (and callbacks) entirely. That's sometimes exactly what you want in bulk ops (topic 72), but never forget: the model doorman checks nobody who climbs through the window. DB constraints catch those — which is why you keep both.
69Callbacks — lifecycle hooks, used sparingly

Scenario: normalize an email before saving, generate a slug before create. Callbacks hook into the record's lifecycle — powerful, and the most abused feature in Rails.

🧠
Analogy: airport checkpoints wired to your suitcase: every time the bag travels (save), it automatically passes security (before_validation), customs (before_save), and a text-message-on-arrival (after_commit). Brilliant for bag-related chores — but if checking in a suitcase also books a hotel and emails your boss, nobody can reason about a simple flight anymore.
Code
class User < ApplicationRecord
  before_validation :normalize_email
  before_create     :generate_referral_code
  after_commit      :send_welcome_email, on: :create   # AFTER the COMMIT

  private

  def normalize_email
    self.email = email.to_s.strip.downcase    # self. required! (topic 18)
  end

  def generate_referral_code
    self.referral_code = SecureRandom.alphanumeric(8).upcase
  end

  def send_welcome_email
    WelcomeMailer.hello(self).deliver_later   # enqueue, don't inline
  end
end

u = User.create!(email: "  ASHA@Corp.COM ")
[u.email, u.referral_code]
Output
=> ["asha@corp.com", "K7KQ2XVN"]
[ActiveJob] Enqueued WelcomeMailer after commit

✅ Do — good use cases

  • Data grooming on the record itself: normalize, slugify, set defaults, compute cached totals
  • Side effects (mail, jobs, webhooks) only in after_commit — never after_save
  • Complex multi-model workflows → a service object that calls things explicitly

❌ Don't — anti-patterns

  • Callbacks touching other models — save a User, three unrelated tables change: welcome to spooky action
  • Chains of 10+ callbacks — order-dependent, condition-riddled, untestable
  • External API calls in before_save — a slow API now blocks (and can abort) every save inside your transaction
Pattern & benefit: for record-local grooming, callbacks guarantee consistency no matter who saves — controller, console, job, or test. The full order: before_validation → before_save → before_create → INSERT → after_create → after_save → after_commit. Returning early? throw :abort cancels the save.
Gotcha — why it goes wrong: after_save runs inside the transaction — send an email there and the transaction can still roll back: user got "welcome!", database says they don't exist. after_commit exists precisely for this. The subtler version of the same bug: enqueueing a Sidekiq job in after_save — the job can run before the commit lands and find no record (topic 94).
70Scopes — named, composable query fragments★ used daily

Scenario: where(status: "active").where("expires_at > ?", Time.current) is pasted in nine places. Give the concept a name once — then compose names like LEGO.

🧠
Analogy: saved filters in your email client. Instead of rebuilding "unread + from boss + this week" checkbox-by-checkbox each morning, you saved it once as a named filter — and filters stack: "Unread ∩ From-boss ∩ This-week". Each scope is a saved filter on a table.
Code
class Subscription < ApplicationRecord
  scope :active,   -> { where(status: "active") }
  scope :expiring, ->(days = 7) { where(expires_at: ..days.days.from_now) }
  scope :premium,  -> { where(plan: %w[pro enterprise]) }
  scope :recent,   -> { order(created_at: :desc) }
end

Subscription.active.premium.expiring(3).recent
# SELECT "subscriptions".* FROM "subscriptions"
# WHERE "status" = 'active'
#   AND "plan" IN ('pro','enterprise')
#   AND "expires_at" <= '2026-07-14 ...'
# ORDER BY "created_at" DESC

# scopes work through associations too:
customer.subscriptions.active.expiring
Output
=> [#<Subscription id: 12, plan: "pro", status: "active", ...>,
    #<Subscription id: 31, plan: "enterprise", status: "active", ...>]

✅ Do — good use cases

  • Name every business concept the code filters by: .overdue, .visible_to(user), .this_month
  • Compose small scopes over writing mega-scopes — that's the whole point
  • Scopes taking arguments via lambda params (expiring(3))

❌ Don't — anti-patterns

  • default_scope — it infects every query including find, breaks unscoped expectations, and its ORDER BY sneaks into places you never intended. Veterans avoid it almost absolutely
  • Scopes with side effects or that return arrays — a scope must return a relation to stay chainable
Pattern & benefit: scopes ride on relation laziness (topic 64): each adds WHERE/ORDER fragments to an unfired query, so active.premium.expiring compiles to one SQL statement. Business vocabulary lives in the model — controllers read like sentences: current_user.subscriptions.active.recent.
Gotcha — why it goes wrong: the lambda is mandatory for a reason — scope :recent, where("created_at > ?", 1.day.ago) (no lambda) evaluates 1.day.ago once at boot; three weeks later "recent" means "since three weeks ago." The lambda re-evaluates per call. Also: a scope returning nil (e.g. conditional inside) silently falls back to all — surprising; return none explicitly when you mean "nothing."
71Transactions & locking — all-or-nothing money moves

Scenario: transfer ₹500 between wallets: debit one, credit the other. Both succeed or neither — and simultaneous transfers must not double-spend. Your Postgres knowledge, now in Ruby clothes.

🧠
Analogy: the transaction block is a sealed envelope of instructions — the bank executes everything inside or shreds the envelope untouched (any exception = shredder). lock! is additionally taking the passbook to the counter: while you hold it, no one else can write a line in it (SELECT ... FOR UPDATE — your old friend).
Code
class WalletTransfer
  def self.call(from:, to:, amount:)
    ActiveRecord::Base.transaction do
      from.lock!                       # SELECT ... FOR UPDATE — row locked
      to.lock!
      raise InsufficientFunds if from.balance < amount

      from.update!(balance: from.balance - amount)   # bang! versions —
      to.update!(balance: to.balance + amount)       # exceptions roll back
    end
  end
end

# optimistic alternative: add integer column lock_version and
# ActiveRecord raises StaleObjectError when two edits collide.
The SQL that runs
BEGIN
SELECT "wallets".* FROM "wallets" WHERE id = 1 LIMIT 1 FOR UPDATE
SELECT "wallets".* FROM "wallets" WHERE id = 2 LIMIT 1 FOR UPDATE
UPDATE "wallets" SET "balance" = 1500 WHERE "wallets"."id" = 1
UPDATE "wallets" SET "balance" = 2500 WHERE "wallets"."id" = 2
COMMIT           -- or ROLLBACK if anything raised

✅ Do — good use cases

  • Multi-record invariants: transfers, order + line items, inventory decrements
  • Bang methods inside transactions — silent false does NOT roll back; exceptions do
  • Lock rows in a consistent order (e.g. by id) to dodge deadlocks — you know this dance

❌ Don't — anti-patterns

  • Slow work inside the block (API calls, mail) — you're holding locks and a connection hostage
  • save (no bang) inside a transaction, ignoring its false — the transaction happily commits half your intent
Pattern & benefit: the block maps 1:1 onto BEGIN/COMMIT/ROLLBACK, exceptions trigger rollback automatically, and with_lock combines transaction + lock! in one call. Optimistic locking (lock_version) suits low-contention edits (admin forms); pessimistic lock! suits money.
Gotcha — why it goes wrong: nested transaction do blocks are a lie by default — the inner one is absorbed into the outer (no real savepoint), so an inner rescue-and-continue still rolls back everything with the outer. Real savepoints need transaction(requires_new: true). And re-read topic 69's warning: after_commit, not after_save, for anything the rollback shouldn't unsend.
72Batching — find_each, insert_all, update_all

Scenario: "email all 2 million users" or "import a 500k-row CSV." Naive code either eats all your RAM (User.all.each) or takes hours (2M individual INSERTs).

🧠
Analogy: moving a library. User.all.each is carrying every book in one trip — your arms (RAM) give out. find_each brings a trolley of 1,000 at a time. And insert_all is the shipping container going the other way: one truck, thousands of books, no stopping to sign each one (no callbacks, no validations — pack carefully).
Code
# ❌ loads 2M ActiveRecord objects into RAM
User.all.each { |u| Digest.new(u).deliver_later }

# ✅ batches of 1000, constant memory
User.where(digest: true).find_each(batch_size: 1000) do |u|
  Digest.new(u).deliver_later
end

# bulk write — ONE INSERT statement, no callbacks/validations:
Product.insert_all([
  { sku: "MUG-01", name: "Mug",    price: 299 },
  { sku: "PEN-99", name: "Pen",    price: 49  },
])
# upsert_all → INSERT ... ON CONFLICT DO UPDATE (yes, that one!)

# bulk update — ONE statement, straight SQL:
Order.where(status: "pending").where(created_at: ...30.days.ago)
     .update_all(status: "expired")
The SQL
SELECT "users".* FROM "users" WHERE "digest" = TRUE
  AND "users"."id" > 1000 ORDER BY "users"."id" ASC LIMIT 1000   -- keyset!
INSERT INTO "products" ("sku","name","price") VALUES (...), (...)
UPDATE "orders" SET "status" = 'expired' WHERE ...

✅ Do — good use cases

  • find_each/in_batches for any loop over more than ~1k rows
  • insert_all/upsert_all for imports — hundreds of times faster than create! loops
  • update_all/delete_all for mass state flips where callbacks don't matter

❌ Don't — anti-patterns

  • User.all.map(&:email) on big tables — that's pluck(:email), or batched
  • Forgetting that update_all/insert_all skip validations, callbacks, and updated_at — set updated_at: Time.current yourself if it matters
Pattern & benefit: find_each pages by primary key under the hood — the keyset pagination pattern from your Postgres playbook (#10), applied automatically. The bulk writers compile to single multi-row statements Postgres chews through at wire speed; upsert_all is literally ON CONFLICT DO UPDATE generated for you.
Gotcha — why it goes wrong: find_each ignores your order(...) — it must order by id to page correctly (Rails warns). And because bulk methods skip the model layer entirely, they'll happily insert rows your validations would have rejected — run your data through a cleaning pass first, and lean on DB constraints (topics 56/68) as the final net.

ActiveRecord, Deeper

73enum — status columns with superpowers★ used daily

Scenario: an order moves through draft → paid → shipped. One enum line turns a humble integer column into named values, query scopes, and bang transitions.

🧠
Analogy: a hospital patient wristband system: the database stores a compact code (0, 1, 2), but everyone speaks in names ("stable", "critical"). The enum is the legend on the ward wall — and it auto-prints the tools: a stamp per status (paid!), a question per status (paid?), and a ward roster per status (Order.paid).
Code
class Order < ApplicationRecord
  enum :status, { draft: 0, paid: 1, shipped: 2, cancelled: 3 },
       default: :draft, validate: true
end

o = Order.create!
o.status          # reads as a string name
o.paid!           # transition + save (UPDATE ... SET status = 1)
o.paid?
o.shipped?

Order.paid.count            # free scope per value!
Order.not_cancelled.count   # negative scopes too
Order.statuses              # the legend itself

# in forms:
# f.select :status, Order.statuses.keys.map { |s| [s.humanize, s] }
Output
=> "draft"
=> true
=> true
=> false
=> 89        # SELECT COUNT(*) WHERE status = 1
=> 231
=> {"draft" => 0, "paid" => 1, "shipped" => 2, "cancelled" => 3}

✅ Do — good use cases

  • Explicit hash mapping ({ draft: 0, ... }) — NEVER the array form (see gotcha)
  • validate: true (Rails 7.1+) — invalid values become validation errors, not exceptions
  • Status flows with few states; add a state machine gem only when transitions need rules

❌ Don't — anti-patterns

  • Array form enum :status, [:draft, :paid] — insert :refunded in the middle later and every stored integer now means the wrong thing. Career-memorable incident, guaranteed
  • Two enums with overlapping value names in one model — method collisions (active? from which?) — use prefix:/suffix:
Pattern & benefit: one line replaces a constants file, hand-written scopes, predicate methods, and guard clauses — and the integer column stays tiny and indexable. With validate: true bad input degrades politely into errors[:status], form-friendly like any validation (topic 68).
Gotcha — why it goes wrong: enums define methods for every value — enum :status, { new: 0 } tries to define new and detonates the class. Rails raises on dangerous names now, but near-misses (valid, save_draft) still create confusion. Also remember the DB stores integers: raw SQL reports need the legend (WHERE status = 1), or use a string-backed enum when analysts query the table directly.
74Polymorphic associations — one table, many parents

Scenario: comments on posts, photos, AND events. Three comment tables? A junction per type? No — one comments table that points at any parent.

🧠
Analogy: a valet ticket: instead of separate cloakrooms per item type, one ticket system with two fields — what kind of thing (coat / bag / umbrella = commentable_type) and which one (hook #42 = commentable_id). Any new item type works day one; the cloakroom never changes.
Code
# migration:
create_table :comments do |t|
  t.references :commentable, polymorphic: true, null: false
  # → commentable_type (string) + commentable_id (bigint) + composite index
  t.text :body
  t.timestamps
end

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

class Post < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy
end
class Photo < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy
end

post.comments.create!(body: "Great write-up!")
photo.comments.create!(body: "📸")
Comment.last.commentable          # returns a Photo — Rails reads the type column
Output
SQL: INSERT INTO "comments" ("commentable_type","commentable_id","body")
     VALUES ('Post', 17, 'Great write-up!')
=> #<Photo id: 4, ...>

✅ Do — good use cases

  • Cross-cutting attachables: comments, likes, taggings, attachments, audit notes
  • Composite index on [commentable_type, commentable_id] — the generator's polymorphic: true does it
  • dependent: :destroy on every parent — orphan tickets help nobody

❌ Don't — anti-patterns

  • Polymorphism when parents genuinely differ in behavior/columns needed — consider delegated_type (topic 75) or separate tables
  • Storing anything but class names in _type — renaming a model silently strands old rows (data migration required!)
Pattern & benefit: new commentable types cost one has_many ... as: line — no migrations, no new tables. The trade you're making (you'd spot it instantly from the Postgres side): no real foreign key is possible on a two-column pointer, so referential integrity moves from the DB into Rails' hands — hence the dependent: discipline.
Gotcha — why it goes wrong: eager loading across mixed parents is the trap: Comment.includes(:commentable) works (Rails groups by type), but you cannot joins(:commentable) or order by parent columns across types — there's no single table to join. Feed-style pages over polymorphic rows need per-type preloads or a denormalized column. Know this before designing the activity feed.
75STI & delegated_type — one table, many classes

Scenario: Car, Bike, and Truck share 90% of their columns and behavior. Single Table Inheritance stores them in one table with a type column — elegant until the subtypes diverge.

🧠
Analogy: STI is one shared wardrobe for three roommates who wear almost the same size — efficient while tastes match. As tastes diverge, the wardrobe fills with clothes only one person wears (NULL columns for everyone else). delegated_type gives each roommate a private drawer (own table for unique attrs) while sharing the common rail.
Code — STI
class Vehicle < ApplicationRecord; end     # table: vehicles, column: type
class Car   < Vehicle
  def toll = base_toll
end
class Truck < Vehicle
  def toll = base_toll * axles              # axles column: NULL for cars...
end

Car.create!(name: "Swift")     # INSERT ... type = 'Car'
Vehicle.all.map(&:toll)        # each row instantiates its OWN class → polymorphism!
Truck.count                    # WHERE type = 'Truck' — automatic

# delegated_type — the modern alternative when subtypes diverge:
class Entry < ApplicationRecord
  delegated_type :entryable, types: %w[Message Comment]
end
# entries: shared cols + entryable_type/id; messages & comments: own tables
entry.entryable      # the specific record
entry.message?
Output
=> [45, 45, 180]     # same call, class-specific behavior
=> 12
=> #<Message id: 3, subject: "...", ...>
=> true

✅ Do — good use cases

  • STI when subtypes share ~90% of columns and differ mostly in behavior
  • delegated_type when each subtype carries its own attributes — shared timeline, private drawers
  • Index the type column — every subclass query filters on it

❌ Don't — anti-patterns

  • STI with wildly divergent subtypes — a table where most columns are NULL for most rows is the schema crying for help
  • Checking vehicle.type == "Car" in conditionals — that's polymorphism refused (topic 21); override the method instead
Pattern & benefit: STI queries stay trivially simple (one table), associations point at the base class naturally, and each row wakes up as its real class — map(&:toll) dispatches per type with zero case statements. Duck typing (topic 21) with a database backing.
Gotcha — why it goes wrong: type is a reserved column name for exactly this feature — an innocent type column on a non-STI table detonates with "The single-table inheritance mechanism failed to locate the subclass". And renaming an STI subclass orphans its rows (the column stores class names — same trap as topic 74). In dev, lazy loading can make Vehicle.all miss subclasses not yet loaded — Vehicle.descendants surprises are an eager-loading topic 59 footnote.
76counter_cache & touch — denormalize the hot numbers

Scenario: every post in the list shows "42 comments" — COUNT(*) per post per page-view is silly money. Cache the count on the parent row and let Rails maintain it.

🧠
Analogy: the tally counter at a museum door — instead of sweeping the galleries to count visitors every time someone asks (COUNT query), the guard clicks +1 in / −1 out, and the current number is always on the dial. touch is the guard also updating the "last activity" clock — which the cache system (topic 96) reads as "this room changed."
Code
# migration:
add_column :posts, :comments_count, :integer, default: 0, null: false

class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true, touch: true
  #                 ^ maintains posts.comments_count
  #                                      ^ bumps posts.updated_at on change
end

post.comments.create!(body: "hi")   # UPDATE posts SET comments_count = +1 ...
post.comments_count                 # read the dial — no COUNT query
post.comments.size                  # smart: uses the cache if present!

# backfill existing data once:
Post.find_each { |p| Post.reset_counters(p.id, :comments) }
Output
SQL: UPDATE "posts" SET "comments_count" = COALESCE("comments_count", 0) + 1,
     "updated_at" = ... WHERE "id" = 17
=> 43
=> 43        # size reads comments_count — zero queries

✅ Do — good use cases

  • List pages showing child counts — the textbook win
  • touch: true when parent caches must invalidate on child changes (Russian-doll caching, topic 96)
  • reset_counters in the deploy that introduces the column — backfill before trusting

❌ Don't — anti-patterns

  • Counter caches on wildly hot parents (a viral post's row becomes an UPDATE hotspot — every comment locks it)
  • Trusting counters maintained alongside insert_all/delete_all bulk ops — those skip callbacks, the dial drifts (topic 72)
Pattern & benefit: classic read-optimized denormalization with the maintenance automated — and size (not count) transparently prefers the cached column, so idiomatic code gets the win for free. You know this trade from the DB side: pay a little on write, save a lot on every read.
Gotcha — why it goes wrong: count vs size vs length matters here: count ALWAYS fires SELECT COUNT (ignoring your cache), length loads the whole association to count in Ruby, size picks the best available. Use size by default. And touch: true cascades updates up the tree — great for cache invalidation, an accidental write-amplifier on bulk child churn.
77Advanced queries — joins, or, merge, subqueries★ used daily

Scenario: "customers with paid orders over ₹1000, or VIPs, excluding anyone with an open ticket" — real filters span tables and combine conditions. Here's the full toolkit between basic where and raw SQL.

🧠
Analogy: relation composition is LEGO plumbing: joins connects tanks (tables) with pipes, merge lets you snap another model's pre-built valve (scope) into YOUR pipeline, or is a Y-junction, and a subquery is a whole pre-assembled filter unit you drop in as one piece.
Code — with the SQL
Customer.joins(:orders)                        # INNER JOIN
        .where(orders: { status: "paid" })
        .distinct

Customer.left_joins(:orders)                   # LEFT OUTER JOIN
        .where(orders: { id: nil })            # customers with NO orders!

# merge — reuse Order.big's logic inside a Customer query:
class Order < ApplicationRecord
  scope :big, -> { where("total > ?", 1000) }
end
Customer.joins(:orders).merge(Order.big)

Customer.where(vip: true).or(Customer.where("orders_count > ?", 10))

# subquery — no ids shipped to Ruby and back:
Customer.where(id: Order.paid.select(:customer_id))
# WHERE "customers"."id" IN (SELECT "customer_id" FROM "orders" WHERE ...)

Customer.joins(:orders).group(:id).having("SUM(orders.total) > ?", 5000)
Output
=> SELECT DISTINCT "customers".* FROM "customers"
   INNER JOIN "orders" ON "orders"."customer_id" = "customers"."id"
   WHERE "orders"."status" = 'paid'
=> 37 customers with zero orders
=> SELECT ... WHERE "customers"."vip" = TRUE OR (orders_count > 10)

✅ Do — good use cases

  • left_joins + where(assoc: { id: nil }) — the "parents without children" idiom
  • merge(OtherModel.scope) — each model keeps owning its own filter logic
  • Subqueries via select(:col) — one round trip, planner-friendly (you know why)

❌ Don't — anti-patterns

  • pluck-then-where(id: ids) for 100k ids — shipping an id truck to the DB it came from; use the subquery form
  • joins when you'll also render the association — that's includes territory (topic 67); joins doesn't load, it filters
Pattern & benefit: everything here still returns a lazy relation (topic 64) — pagination, further scopes, and count stack on top, and it all compiles to one SQL statement you can inspect with .to_sql. Your SQL brain maps 1:1: joins/or/having/IN-subselects, just composable.
Gotcha — why it goes wrong: two classics: (1) joins(:orders) multiplies rows — a customer with 5 orders appears 5 times; distinct or aggregate. (2) .or demands "structurally compatible" relations — different joins/includes on each side raise ArgumentError: Relation passed to #or must be structurally compatible; build both sides from the same base.
78Raw SQL escape hatches — find_by_sql, select_all, sanitize

Scenario: a window-function report or a recursive CTE that ActiveRecord can't express — with your Postgres depth, sometimes SQL is the right tool. Rails has clean doors for it.

🧠
Analogy: ActiveRecord is the hotel's room service menu — covers 95% of meals politely. Raw SQL is walking into the kitchen and cooking yourself: totally allowed for the dish the menu can't make, as long as you follow kitchen hygiene (bind parameters!) and don't start cooking every meal there.
Code
# models back — when rows map to a model:
top = Order.find_by_sql([<<~SQL, { min: 1000 }])
  SELECT o.*, RANK() OVER (PARTITION BY customer_id ORDER BY total DESC) rnk
  FROM orders o
  WHERE total > :min
SQL
top.first.rnk               # extra columns become readers!

# plain rows back — reports, no model overhead:
rows = ActiveRecord::Base.connection.select_all(
  "SELECT status, SUM(total) t FROM orders GROUP BY status"
).to_a                       # array of hashes

# fragments inside relations — sanitized:
Order.where("created_at > ?", 30.days.ago)                 # bind param
Order.order(Arel.sql("RANDOM()"))                          # explicit trust
scope = Order.sanitize_sql_like(params[:q])                # escape % _ for LIKE
Order.where("sku LIKE ?", "#{scope}%")
Output
=> #<Order id: 8, total: 4200>  o.rnk => 1
=> [{"status" => "paid", "t" => 145300}, {"status" => "pending", "t" => 22100}]
SQL: SELECT ... WHERE (created_at > '2026-06-11 ...')   -- bound, not interpolated

✅ Do — good use cases

  • Window functions, CTEs, DISTINCT ON, fancy aggregates — your playbook patterns, verbatim
  • Placeholders (? / :named) for every value — non-negotiable (topic 98)
  • find_by_sql when you want models; select_all for report rows

❌ Don't — anti-patterns

  • Raw SQL for things relations do fine — you lose composability, scopes, and preloading for nothing
  • String-interpolating ANYTHING user-shaped into SQL — Arel.sql is a pledge that the string is trusted; never pledge params
Pattern & benefit: the escape hatches are graded: fragment-in-relation (keeps chaining) → find_by_sql (keeps models) → select_all (raw speed). Pick the shallowest one that works and the rest of the app never notices SQL was hand-rolled. Extra selected columns appearing as methods (o.rnk) makes hybrid queries surprisingly ergonomic.
Gotcha — why it goes wrong: find_by_sql results are read-mostly: virtual columns like rnk vanish on reload, and the relation methods (.where, .order) are gone — it returns an Array. Also Rails deliberately raises on order(params[:sort])-style raw strings it can't prove safe (UnknownAttributeReference) — the fix is an allowlist (topic 98), not sprinkling Arel.sql until the error stops.
79jsonb columns & store_accessor — your Postgres jsonb, in Rails

Scenario: variable product attributes, webhook payloads, user preferences — the jsonb patterns from your Postgres playbook (#2), now with ActiveRecord ergonomics.

🧠
Analogy: same filing cabinet with the flexible "misc" pouch (jsonb) — but now with Rails-installed labeled tabs into the pouch: store_accessor makes product.color read/write attrs["color"] as if it were a real drawer, validations included.
Code
# migration:
add_column :products, :attrs, :jsonb, null: false, default: {}
add_index  :products, :attrs, using: :gin        # containment queries!

class Product < ApplicationRecord
  store_accessor :attrs, :color, :ram_gb, :tags  # virtual attributes

  validates :ram_gb, numericality: true, allow_nil: true
end

p = Product.create!(name: "ThinkPad", color: "black", ram_gb: 32)
p.color                       # reads attrs["color"]
p.attrs

# querying — your @> containment operator, GIN-indexed:
Product.where("attrs @> ?", { color: "black" }.to_json)
Product.where("(attrs->>'ram_gb')::int >= ?", 16)
Output
=> "black"
=> {"color" => "black", "ram_gb" => 32}
SQL: SELECT ... WHERE (attrs @> '{"color":"black"}')   -- uses GIN index
=> [#<Product id: 1, name: "ThinkPad", ...>]

✅ Do — good use cases

  • The long tail of attributes; hot fields stay real columns — your own rule, unchanged
  • store_accessor + validations — schema-less storage, schema-ful behavior
  • GIN index + @> for filtered lookups (exactly playbook #2)

❌ Don't — anti-patterns

  • id + data jsonb app design — you know this one; Rails makes it easier to do and just as wrong
  • Filtering constantly on a jsonb key — promote it to a column (with an index and a data migration)
Pattern & benefit: jsonb round-trips as a Ruby Hash automatically — no serialization code — and store_accessor gives form builders, strong params, and validations a normal attribute surface. Dirty tracking works per-column (attrs_changed?), and defaults (default: {}) keep nil-checks out of your code.
Gotcha — why it goes wrong: jsonb keys come back as stringsp.attrs[:color] is nil, p.attrs["color"] works (topic 2's oldest trick, DB edition). And in-place mutation (p.attrs["x"] = 1) can dodge dirty tracking on some paths — safest is whole-hash assignment or the store_accessor writers, which mark changes properly.
80Pagination — pagy/kaminari & the keyset upgrade

Scenario: 100k orders can't render on one page. Offset pagination gets you shipping today; keyset pagination keeps page 4000 as fast as page 1 — you wrote that playbook entry yourself (#10).

🧠
Analogy: offset pagination is telling the librarian "skip the first 39,000 books, hand me the next 10" — she still walks past all 39,000. Keyset is a bookmark: "start from exactly here" — she opens straight to it regardless of how deep in the library it sits.
Code — pagy (fastest, simplest)
# Gemfile: gem "pagy"
class OrdersController < ApplicationController
  include Pagy::Backend

  def index
    @pagy, @orders = pagy(Order.recent.includes(:customer), limit: 25)
  end                              # ^ still a relation — scopes compose!
end

# view: <%== pagy_nav(@pagy) %>
# SQL: SELECT ... ORDER BY created_at DESC LIMIT 25 OFFSET 50

# keyset for infinite scroll / deep pages (playbook #10):
def feed
  after = params[:after]           # last seen id
  scope = Order.order(id: :desc).limit(25)
  scope = scope.where("id < ?", after) if after
  render json: { items: scope, next_after: scope.last&.id }
end
Output
page 3:    LIMIT 25 OFFSET 50      -- fine
page 4000: LIMIT 25 OFFSET 99975   -- scans and discards 99,975 rows!
keyset:    WHERE id < 12345 ORDER BY id DESC LIMIT 25   -- index seek, any depth

✅ Do — good use cases

  • Pagy for admin/UI pages — tiny, fast, plays with relations and includes
  • Keyset (where id < last) for APIs and infinite scroll — stable under inserts too
  • Always paginate ORDER BY something unique (id tiebreaker) — you know why

❌ Don't — anti-patterns

  • Unpaginated index actions "because there's not much data yet" — there will be
  • @orders.count AND rendering the page — pagy computes counts once; don't double-fire COUNT on big tables
Pattern & benefit: pagination composes with everything before it in this guide — scopes (topic 70), includes (topic 67), search filters — because it's just limit/offset appended to a lazy relation (topic 64). And the keyset upgrade is a controller-level change, not an architecture project.
Gotcha — why it goes wrong: offset pages drift under writes — a new order lands while the user reads page 2, and page 3 repeats a row (or skips one). Users report "duplicate entries" that no one can reproduce. Keyset is immune (the bookmark is a value, not a position) — reach for it the moment data is hot or pages go deep.
81Soft delete — discard, default scopes & the partial index

Scenario: "deleted" users must vanish from the app but survive for audits, undo, and foreign keys. Soft delete done wrong haunts every query in the app; done right it's two lines and an index.

🧠
Analogy: instead of shredding the file (DELETE), you stamp it ARCHIVED and move it to the basement — recoverable, auditable, out of sight. The danger: if every clerk must remember to say "excluding the basement" on every request, someone will forget. Make the basement explicit, not magical.
Code — the discard gem's pattern (or hand-rolled, same idea)
add_column :users, :discarded_at, :datetime
add_index  :users, :discarded_at
# your Postgres playbook move — partial index keeps live lookups tiny:
# CREATE UNIQUE INDEX ... ON users (email) WHERE discarded_at IS NULL

class User < ApplicationRecord
  include Discard::Model         # gem "discard"
end

user.discard                     # sets discarded_at — no row deleted
user.discarded?
User.kept.count                  # live users (explicit scope!)
User.discarded.count             # the basement
user.undiscard                   # undo — the whole point

# associations still work for audit trails:
user.orders.count                # history intact, FKs intact
Output
SQL: UPDATE "users" SET "discarded_at" = '2026-07-11...' WHERE id = 7
=> true
=> 4211
=> 89
SQL: UPDATE "users" SET "discarded_at" = NULL WHERE id = 7

✅ Do — good use cases

  • Explicit scopes (kept/discarded) at call sites — visible, greppable, composable
  • Partial unique indexes (WHERE discarded_at IS NULL) so a discarded user's email can be re-registered — pure playbook #8/#27
  • Decide the children's fate explicitly: discard cascades in a callback or service (topic 61)

❌ Don't — anti-patterns

  • default_scope { where(discarded_at: nil) } — the topic 70 warning, weaponized: joins, find, and uniqueness validations all silently exclude rows; debugging becomes paranormal investigation
  • Soft-deleting everything — logs and join rows often can just die; soft delete what audits/undo actually need
Pattern & benefit: undo support, intact audit trails, and no FK cascade surprises — while unique partial indexes preserve constraint integrity for the living rows only. The explicit-scope discipline (vs default_scope magic) is what separates codebases that age well from the haunted ones.
Gotcha — why it goes wrong: the leak: some forgotten query path — an admin search, a mailer list, a find_by(email:) — doesn't filter, and a "deleted" user gets the newsletter. With explicit scopes the misses are at least greppable (User.where without kept); with default_scope the inclusions are the invisible bug instead. And remember: uniqueness validation (topic 68) doesn't know about your partial index — align both or re-registration breaks.

Views & the Request Cycle

82ERB, partials & layouts — HTML with holes in it

Scenario: render the products page: shared page chrome (layout), the page itself (view), and a reusable card per product (partial) — without copy-pasting a single div.

🧠
Analogy: a picture-framing shop. The layout is the standard frame + matting every picture gets (header, nav, footer — with a window, yield, where the art goes). The view is this page's artwork. Partials are rubber stamps — carve the product-card design once, stamp it fifty times.
app/views/products/index.html.erb
<h1>Products (<%= @products.size %>)</h1>

<%# render the _product.html.erb partial once per record — one line: %>
<%= render @products %>

<%= link_to "New product", new_product_path, class: "btn" %>
app/views/products/_product.html.erb — the partial
<div class="card" id="<%= dom_id(product) %>">
  <h2><%= product.name %></h2>          <%# auto HTML-escaped! %>
  <p><%= number_to_currency(product.price, unit: "₹") %></p>
  <% if product.sale? %>                <%# <% %> = logic, no output %>
    <span class="tag">SALE</span>
  <% end %>
</div>
Rendered output
<h1>Products (2)</h1>
<div class="card" id="product_1">
  <h2>Mug</h2>
  <p>₹299.00</p>
</div>
<div class="card" id="product_2">...</div>

✅ Do — good use cases

  • <%= render @products %> — convention finds _product.html.erb, passes each record as local product
  • Helpers for view logic: number_to_currency, time_ago_in_words, truncate, your own in app/helpers
  • Layouts per audience: application.html.erb, admin.html.erb

❌ Don't — anti-patterns

  • Queries in views (<% Product.where(...) %>) — data loading is the controller's job; also the N+1 nursery (topic 67)
  • Partials referencing controller @ivars — pass locals explicitly: render "card", product: p; implicit state makes partials unreusable
Pattern & benefit: <%= %> outputs, <% %> computes, <%# %> comments — that's all of ERB. Rendering a collection with one line even gets you automatic per-item caching later (topic 96). The escaping default means views are XSS-safe unless you explicitly opt out.
Gotcha — why it goes wrong: <%= user_bio %> showing literal &lt;b&gt; tags means escaping is doing its job — the dangerous move is "fixing" it with raw or html_safe on user content: that's a stored-XSS invitation. If you must render user HTML, sanitize it. html_safe is a promise, not a cleaning product.
83form_with — forms bound to models★ used daily

Scenario: one form template that serves both "new product" and "edit product," posts to the right URL with the right verb, refills fields after validation errors, and carries the CSRF token — automatically.

🧠
Analogy: a smart envelope pre-addressed by looking at its contents: give it an unsaved product and it addresses itself to POST /products; give it an existing one and it addresses itself to PATCH /products/42. Fields pre-fill from the record like carbon paper, and the CSRF token is the tamper-proof seal.
app/views/products/_form.html.erb — shared by new AND edit
<%= form_with model: @product do |f| %>
  <% if @product.errors.any? %>
    <ul class="errors">
      <% @product.errors.full_messages.each do |msg| %>
        <li><%= msg %></li>
      <% end %>
    </ul>
  <% end %>

  <%= f.label :name %>
  <%= f.text_field :name %>

  <%= f.number_field :price, step: 0.01 %>
  <%= f.collection_select :category_id, Category.order(:name), :id, :name %>

  <%= f.submit %>   <%# button text adapts: "Create Product" / "Update Product" %>
<% end %>
Generated HTML (new record)
<form action="/products" method="post">
  <input type="hidden" name="authenticity_token" value="kX9...">
  <label for="product_name">Name</label>
  <input type="text" name="product[name]" id="product_name">
  ...
  <input type="submit" value="Create Product">
</form>
<!-- params arrive as { product: { name: ..., price: ... } } — topic 55 -->

✅ Do — good use cases

  • One _form partial for new + edit — the model binding handles the differences
  • f.collection_select / f.check_box etc. — field helpers name inputs so strong params just work
  • On failed save: render :new, status: :unprocessable_entity — fields keep the user's input

❌ Don't — anti-patterns

  • Hand-written <form> tags for model data — you lose CSRF, prefill, error round-trips, and param nesting
  • Mismatched names: f.text_field :name but permitting :title — the silent unpermitted-param bug from topic 55
Pattern & benefit: the full loop is the crown jewel: submit → validation fails → re-render → fields refilled + errors listed, zero code from you. form_with model: infers URL, verb, param nesting, and button text from the record's persisted? state — new vs edit stops being two problems.
Gotcha — why it goes wrong: forms submit via Turbo in modern Rails — a failed validation must respond with HTTP 422 (status: :unprocessable_entity) or Turbo ignores the re-render and your form "does nothing." This is the #1 "my form is broken" question since Rails 7. Checkboxes have their own classic: unchecked boxes send nothing — the hidden 0-value input Rails adds is load-bearing; don't strip it.
84Sessions, cookies & flash — remembering who's there

Scenario: HTTP forgets everyone between requests. Sessions remember the logged-in user, flash carries the "Saved!" message across a redirect, cookies persist the "remember me."

🧠
Analogy: a coat-check. The signed session cookie is your numbered ticket — the browser presents it each visit, and no, you can't scribble a different number on it (it's cryptographically signed). Flash is a sticky note passed to the very next request only — read once, then it's gone. Losing your ticket (clearing cookies) means starting over.
Code
class SessionsController < ApplicationController
  def create                      # POST /login
    user = User.authenticate_by(email: params[:email], password: params[:password])
    if user
      session[:user_id] = user.id            # signed+encrypted cookie
      redirect_to root_path, notice: "Welcome back!"   # notice = flash
    else
      flash.now[:alert] = "Invalid credentials"        # .now = THIS render
      render :new, status: :unprocessable_entity
    end
  end

  def destroy                     # logout
    reset_session                 # nuke it all — new session id too
    redirect_to root_path, notice: "Logged out"
  end
end

# in ApplicationController — the pattern every app has:
def current_user
  @current_user ||= User.find_by(id: session[:user_id])   # ||= memoize!
end
helper_method :current_user
In the layout
<% if flash[:notice] %><div class="flash"><%= flash[:notice] %></div><% end %>
→ renders once after the redirect, gone on refresh

✅ Do — good use cases

  • Store only IDs in the session — look the user up per request; store data, not objects
  • flash with redirects; flash.now with renders — mixing them up is the classic double-message bug
  • reset_session on login and logout — kills session-fixation attacks

❌ Don't — anti-patterns

  • Stuffing big objects into the session — cookie sessions cap at 4KB and every byte rides on every request
  • Trusting anything from a plain (unsigned) cookie — users edit those freely in devtools
Pattern & benefit: the default cookie session store is stateless server-side — no session table, scales horizontally for free, encrypted so users can't even read it. The current_user ||= memoization means one DB hit per request no matter how many times views ask.
Gotcha — why it goes wrong: flash[:notice] = "..." followed by render (not redirect) shows the message now and again on the next page — flash lives for one extra request. That's what flash.now is for. And session data survives login on some setups — always reset_session first, then set the new user id.

Frontend — Hotwire & APIs

85Nested forms — accepts_nested_attributes_for & fields_for

Scenario: one form that creates an invoice AND its line items — parent and children saved together, validated together, atomic.

🧠
Analogy: a job application with attachment slots — one envelope (form) carries the main sheet (invoice) plus N attached forms (items), each slot numbered. The clerk (Rails) opens the envelope and files everything in one visit — or rejects the whole envelope if any sheet is invalid.
Code
class Invoice < ApplicationRecord
  has_many :items, dependent: :destroy
  accepts_nested_attributes_for :items,
    allow_destroy: true, reject_if: :all_blank
end

# the view — fields_for renders per-child inputs:
# <%= form_with model: @invoice do |f| %>
#   <%= f.text_field :number %>
#   <%= f.fields_for :items do |item| %>
#     <%= item.text_field :description %>
#     <%= item.number_field :amount %>
#     <%= item.check_box :_destroy %>    <!-- tick to delete -->
#   <% end %>
# <% end %>

def invoice_params
  params.expect(invoice: [ :number,
    items_attributes: [[ :id, :description, :amount, :_destroy ]] ])
end

Invoice.create!(invoice_params)   # parent + children, one transaction
What the params look like
{ invoice: { number: "INV-7",
  items_attributes: {
    "0" => { description: "Design", amount: "40000" },
    "1" => { description: "Hosting", amount: "2000" },
    "2" => { id: "31", _destroy: "1" }        ← existing row, marked for deletion
} } }

✅ Do — good use cases

  • Tightly-coupled parent/children edited as one unit: invoice+items, survey+questions
  • reject_if: :all_blank — empty template rows silently dropped
  • Permit :id and :_destroy — updates and deletions need them

❌ Don't — anti-patterns

  • Nesting 3 levels of children — the params shape becomes archaeology; split the flow or use a form object (topic 61's cousin)
  • Forgetting :id in permitted params — every edit DUPLICATES the children instead of updating (the classic!)
Pattern & benefit: parent validations can inspect children, everything saves in one transaction (topic 71), and the error round-trip re-renders the whole nested form with per-field messages — machinery you'd hand-write badly over a week. Add/remove-row buttons are a few lines of Stimulus (topic 88) cloning a template.
Gotcha — why it goes wrong: the double-save bug above (missing :id) plus its sibling: forgetting allow_destroy: true makes _destroy checkboxes silently no-op. And fields_for renders nothing for a new record with zero items — build defaults in the controller: @invoice.items.build × 3.
86Turbo Drive & Frames — SPA feel, zero JavaScript★ used daily

Scenario: your server-rendered app feels snappy like an SPA — links don't full-reload, and the "edit" button swaps just the card it lives in. This is Rails' default frontend since 7.

🧠
Analogy: Turbo Drive turns full page loads into set changes behind a closed curtain — the audience (browser) keeps their seats (JS state, scroll), only the scenery swaps. A Turbo Frame is a picture-in-picture TV window: navigation inside the window changes only the window; the rest of the screen doesn't blink.
Code
<%# Turbo Drive: already on — every link/form is intercepted,
    body swapped via fetch. You wrote nothing. %>

<%# Turbo Frame: scope navigation to a fragment %>
<%= turbo_frame_tag dom_id(@product) do %>
  <h2><%= @product.name %></h2>
  <%= link_to "Edit", edit_product_path(@product) %>
<% end %>

<%# edit.html.erb — response must contain a MATCHING frame: %>
<%= turbo_frame_tag dom_id(@product) do %>
  <%= render "form", product: @product %>
<% end %>

<%# clicking Edit swaps ONLY the card for the form. Cancel = link back
    to show — swaps the form back out. Inline editing, no JS. %>

<%# lazy frame — loads when scrolled into view: %>
<%= turbo_frame_tag "stats", src: dashboard_stats_path, loading: :lazy %>
What the network tab shows
GET /products/42/edit
→ 200, text/html — full page rendered, but Turbo extracts ONLY
  <turbo-frame id="product_42"> ... </turbo-frame> and swaps it in place.
No full reload. Scroll, playing video, JS state — all preserved.

✅ Do — good use cases

  • Inline edit-in-place, tabbed panels, lazy-loaded dashboard widgets — frames' home turf
  • dom_id(record) for frame ids — uniqueness convention for free
  • Escape a frame when needed: data: { turbo_frame: "_top" } on links that should navigate the page

❌ Don't — anti-patterns

  • Reaching for React/SPA architecture for CRUD apps — you're signing up for an API layer, state management, and two codebases where frames would do
  • Nesting frames deeply — target confusion multiplies; keep frames flat and purposeful
Pattern & benefit: the server keeps rendering plain HTML (all your ERB skills — topic 82 — unchanged), yet interactions feel instant. No client router, no JSON API for your own UI, no hydration bugs. The "response must contain a matching frame" contract is the entire mental model.
Gotcha — why it goes wrong: the #1 confusion: a link inside a frame navigates the frame — clicking "View details" swaps the card instead of going to the page (add turbo_frame: "_top"). #2: response missing the matching frame id → "Content missing" in the frame and a console warning. #3: redirect_to after frame form success renders into the frame — often you want a Turbo Stream (topic 87) or full-page redirect instead.
87Turbo Streams — surgical updates & live broadcasts

Scenario: submit a comment and it appears in the list — plus the counter increments — without reloading anything. Then: make it appear for every viewer of the page, live.

🧠
Analogy: a frame swaps one TV window; a stream is a stagehand with instructions: "append this card to that list, replace that counter, remove that row" — several precise moves in one delivery. Broadcasts hand the same instructions to every TV tuned to the channel (WebSocket) — the whole sports bar sees the goal at once.
Code
# controller — respond with surgical instructions:
def create
  @comment = @post.comments.create!(comment_params)
  respond_to do |format|
    format.turbo_stream    # renders create.turbo_stream.erb
    format.html { redirect_to @post }
  end
end
create.turbo_stream.erb — multiple moves, one response
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comments_count", @post.comments.size %>
<%= turbo_stream.replace "new_comment", partial: "comments/form",
                          locals: { comment: Comment.new } %>
Live for everyone — model broadcast + page subscription
class Comment < ApplicationRecord
  belongs_to :post
  broadcasts_to :post        # after_commit → WebSocket push
end
# view:  <%= turbo_stream_from @post %>
#        <div id="comments">...</div>
# every open browser appends the new comment. Chat-grade UX, ~3 lines.

✅ Do — good use cases

  • Form results updating multiple page regions (list + counter + reset form)
  • broadcasts_to for feeds, notifications, dashboards — real-time without writing Action Cable code
  • Keep the format.html fallback — streams degrade gracefully

❌ Don't — anti-patterns

  • Streams where a frame suffices — one region changing = frame; multiple regions = streams
  • Broadcasting user-specific markup to a shared channel — viewer A gets viewer B's "edit" buttons (same leak class as topic 96's caching gotcha)
Pattern & benefit: seven verbs (append, prepend, replace, update, remove, before, after) targeting DOM ids — that's the entire API, and the payloads are your existing partials. The broadcast layer rides after_commit (topic 69's lesson applied correctly by the framework itself).
Gotcha — why it goes wrong: broadcast markup renders outside a request — no current_user, no session! Partials used in broadcasts must not call controller helpers (undefined method errors in the job logs, pages mysteriously not updating). And target ids must exist: streaming to a typo'd id fails silently — check dom_id consistency first when "nothing happened."
88Stimulus — JavaScript sprinkles with discipline

Scenario: character counters, dropdown toggles, copy-to-clipboard, add-nested-row — the 5% of interactivity Turbo doesn't cover, organized so it never rots into jQuery soup.

🧠
Analogy: Stimulus controllers are appliance manuals bolted to the appliance: the HTML says right on it which controller runs it (data-controller), which buttons do what (data-action), and which parts matter (data-target). No more mystery wiring hidden in a 3000-line application.js — read the HTML, know the behavior.
app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["source", "button"]

  copy() {
    navigator.clipboard.writeText(this.sourceTarget.value)
    this.buttonTarget.textContent = "Copied!"
    setTimeout(() => this.buttonTarget.textContent = "Copy", 1500)
  }
}
The HTML wires itself
<div data-controller="clipboard">
  <input data-clipboard-target="source" value="<%= @api_key %>" readonly>
  <button data-clipboard-target="button"
          data-action="click->clipboard#copy">Copy</button>
</div>
Behavior
click → clipboard_controller#copy() → button reads "Copied!" → reverts.
Works after ANY Turbo navigation/stream — controllers connect automatically
whenever their HTML enters the DOM. No DOMContentLoaded dance.

✅ Do — good use cases

  • Small reusable behaviors: toggle, tabs, autosubmit, character count, clipboard
  • values API for config from HTML: data-countdown-seconds-value="30"
  • One controller = one behavior — compose multiple on one element

❌ Don't — anti-patterns

  • Fetching/rendering JSON in Stimulus — data flows through Turbo (frames/streams); Stimulus is for behavior
  • A 400-line "page_controller" — that's the soup returning with extra steps
Pattern & benefit: lifecycle-aware (connects/disconnects as DOM changes) — which is exactly what Turbo-swapped pages need and what raw addEventListener code gets wrong (dead listeners, double-binding). The naming conventions make behavior greppable: see data-controller="clipboard", open clipboard_controller.js, done.
Gotcha — why it goes wrong: hyphens vs underscores bite everyone once: file copy_link_controller.jsdata-controller="copy-link" → targets data-copy-link-target. Get one wrong and nothing connects, silently. Debug ritual: connect() { console.log("connected!") } first, wiring second.
89JSON APIs — render json:, serializers & versioning

Scenario: your mobile app needs endpoints. Rails serves JSON as naturally as HTML — the craft is in controlling the shape, the status codes, and not leaking columns.

🧠
Analogy: an API is a drive-through window: fixed menu (documented shapes), numbered combos (status codes), no peeking into the kitchen (internal columns). as_json without limits is handing customers the whole fridge — including the things you really didn't mean to serve (password digests, feature flags, soft-deleted rows).
Code
# app/controllers/api/v1/products_controller.rb
module Api::V1
  class ProductsController < ApplicationController
    skip_forgery_protection            # token auth instead (topic 91)

    def index
      products = Product.kept.includes(:category).limit(100)
      render json: products.as_json(
        only:    [:id, :name, :price],          # allowlist the shape!
        methods: [:display_price],
        include: { category: { only: [:id, :name] } }
      )
    end

    def create
      product = Product.new(product_params)
      if product.save
        render json: product, status: :created            # 201
      else
        render json: { errors: product.errors }, status: :unprocessable_entity
      end
    end
  end
end
# routes: namespace(:api) { namespace(:v1) { resources :products } }
Output
GET /api/v1/products → 200
[{"id":1,"name":"Mug","price":"299.0","display_price":"₹299",
  "category":{"id":3,"name":"Kitchen"}}]

POST (invalid) → 422
{"errors":{"name":["can't be blank"]}}

✅ Do — good use cases

  • Allowlist fields (only:) or a serializer layer (jbuilder / Blueprinter / your own POROs) — never default as_json
  • Version from day one (/api/v1/) — breaking changes will come
  • Correct statuses: 201 created, 422 invalid, 401 unauthenticated, 403 forbidden, 404 missing

❌ Don't — anti-patterns

  • render json: @user raw — serializes EVERY column today and every column you add next year (that's how digests leak)
  • N+1s hidden in serializers — include: without includes() is topic 67 wearing a JSON hat
Pattern & benefit: the whole request stack you already know — routes, strong params, validations, rescue_from (topic 100) — serves APIs unchanged; only the render changes. For API-only services, rails new --api strips the view layers and slims the middleware.
Gotcha — why it goes wrong: shape drift: adding a column silently changes every response using default serialization — clients parse-crash weeks later and git blame points at a "harmless migration." The allowlist isn't optional hygiene; it's the API's contract. Also mind ActiveRecord::RecordNotFound — without a rescue_from returning JSON 404, API clients get an HTML error page as their "JSON".
90I18n — t(), locale files & never hardcoding text

Scenario: the app needs Hindi and Marathi next quarter — or just consistent wording today. The I18n layer is also where Rails looks up model names, date formats, and validation messages.

🧠
Analogy: a theater with subtitle tracks: the actors perform keys (t(".title")), and each audience member hears their chosen track (locale file). Adding a language means writing a new track — the play itself never changes. Hardcoded strings are actors ad-libbing: impossible to translate, impossible to keep consistent.
Code
# config/locales/en.yml
# en:
#   orders:
#     show:
#       title: "Order %{number}"
#       state: { paid: "Paid ✓", pending: "Awaiting payment" }
#   activerecord:
#     attributes:
#       order: { placed_at: "Order date" }   # form labels read this!

I18n.t("orders.show.title", number: "A-42")
I18n.l(Time.current, format: :short)         # localized dates
I18n.with_locale(:mr) { I18n.t("orders.show.title", number: "A-42") }

# in views — "lazy" keys resolve by view path:
# app/views/orders/show.html.erb:  <%= t(".title", number: @order.number) %>
#                                       ^ means orders.show.title

# per-request locale — the standard hook:
# around_action { |_, blk| I18n.with_locale(current_user&.locale || :en, &blk) }
Output
=> "Order A-42"
=> "11 Jul 14:22"
=> "ऑर्डर A-42"
missing key → translation_missing span in dev (loud), raise in test (louder)

✅ Do — good use cases

  • Lazy keys (t(".title")) in views — file path IS the namespace
  • %{var} interpolation — translators reorder words; concatenation can't
  • config.i18n.raise_on_missing_translations = true in test — missing keys fail CI, not users

❌ Don't — anti-patterns

  • String concatenation for sentences (t("hello") + name) — word order differs across languages
  • Interpolating user input into keys (t("status.#{params[:s]}")) without an allowlist — missing-key errors as a service
Pattern & benefit: even a single-language app wins: all copy in one reviewable file, consistent terminology, and activerecord.attributes keys renaming every form label and error message at once. Pluralization (count:) handles "1 item / 3 items" grammar per language — logic you never want to hand-write.
Gotcha — why it goes wrong: locale leakage: setting I18n.locale = directly (instead of with_locale) on a threaded server can bleed one user's language into another's request — the spooky "my dashboard turned Marathi" bug (topic 51's shared-state lesson). And YAML's indentation strictness means a two-space slip silently orphans a whole subtree — i18n-tasks gem lints for that.

Auth, Files & Users

91Authentication — has_secure_password & the Rails 8 generator★ used daily

Scenario: who are you? Passwords hashed with bcrypt, sessions to remember the login (topic 84), tokens for APIs — and Rails 8 ships a generator that writes the whole thing.

🧠
Analogy: the club never keeps a photo album of members' faces (plaintext passwords) — it keeps inked fingerprints (bcrypt digests): easy to press a finger and compare, computationally hopeless to reconstruct the finger from the ink. The wristband you get after the door check is the session cookie — checked at every bar inside, issued once.
Code
# $ bin/rails generate authentication     (Rails 8 — writes all of this)
# The core it generates, worth understanding line by line:

class User < ApplicationRecord
  has_secure_password            # needs password_digest column + bcrypt gem
  normalizes :email_address, with: ->(e) { e.strip.downcase }
end

User.create!(email_address: "asha@corp.com", password: "s3cure-pass!")
# INSERT ... password_digest = "$2a$12$N9qo8uLOickgx2ZMRZoMye..."

user = User.authenticate_by(email_address: "asha@corp.com",
                            password: "s3cure-pass!")   # timing-attack safe
user ? "in" : "out"

# session controller sets: session[:user_id] / signed cookies
# API flavor: user.generate_token_for(:api) — expiring signed tokens
Output
=> #<User id: 1, email_address: "asha@corp.com">   # wrong password → nil
=> "in"
password_digest: "$2a$12$..."   ← salt + cost + hash, never reversible

✅ Do — good use cases

  • Rails 8 auth generator or has_secure_password for most apps; Devise when you need its ecosystem (invitations, lockouts, omniauth)
  • authenticate_by — constant-time, enumeration-resistant (vs find-then-authenticate)
  • reset_session on login/logout (topic 84), rate-limit the login route (Rails 8: rate_limit)

❌ Don't — anti-patterns

  • Rolling your own hashing (MD5/SHA — rainbow-tabled) or storing plaintext "temporarily" — bcrypt or nothing
  • Revealing which field was wrong ("email not found" vs "wrong password") — that's a user-enumeration oracle
Pattern & benefit: has_secure_password is ~3 lines that give you salted bcrypt digests, password_confirmation validation, and authenticate — audited by the whole ecosystem. The Rails 8 generator adds sessions, password resets with expiring tokens, and browser rate limiting: readable code in YOUR app, not a gem black box.
Gotcha — why it goes wrong: bcrypt is deliberately slow (~100ms) — that's the security feature, but it makes seed scripts and tests crawl if each record hashes fresh; use one shared digest in factories. And the quiet one: has_secure_password validates on create but allows update without password — know your flows, require current-password confirmation for email/password changes.
92Authorization — Pundit policies: may you, though?

Scenario: logged-in ≠ allowed. Editors edit their own posts, admins edit all, viewers none — and the rules must hold in the controller, the view, AND the query for "which posts do I even see?"

🧠
Analogy: authentication is the passport check (who are you?); authorization is the visa (what may you do here?). A Pundit policy is the immigration rulebook for one resource type — every rule for Posts lives on one page, not scattered across forty border posts (controllers).
Code
class PostPolicy < ApplicationPolicy         # gem "pundit"
  def update? = user.admin? || record.author_id == user.id
  def destroy? = user.admin?

  class Scope < Scope                        # "which rows exist for me?"
    def resolve
      user.admin? ? scope.all : scope.where(published: true)
                                     .or(scope.where(author_id: user.id))
    end
  end
end

class PostsController < ApplicationController
  include Pundit::Authorization
  after_action :verify_authorized, except: :index    # forget = raise!

  def index  = @posts = policy_scope(Post)            # scoped SELECT
  def update
    @post = Post.find(params[:id])
    authorize @post                # raises Pundit::NotAuthorizedError
    @post.update!(post_params)
  end
end
# view: <%= link_to "Edit", ... if policy(post).update? %>
Output
editor updating own post    → 200
editor updating other's     → Pundit::NotAuthorizedError → rescue to 403/404
index as viewer             → SELECT ... WHERE published = TRUE OR author_id = 7

✅ Do — good use cases

  • One policy class per model — plain Ruby, trivially unit-testable
  • policy_scope for every index/list — filtering in SQL, not after loading
  • verify_authorized guard — unauthorized-by-forgetting becomes impossible

❌ Don't — anti-patterns

  • if current_user.admin? sprinkled through controllers/views — rules everywhere = rules nowhere; centralize
  • Authorizing only in the UI (hiding buttons) — the request is one curl away; the controller check is the real wall
Pattern & benefit: the same rule object answers all three questions — may they? (controller authorize), should we show it? (view policy()), what's visible? (query policy_scope) — so rules can't drift apart. Policies being POROs means testing every role×action combination is a fast, DB-light unit spec.
Gotcha — why it goes wrong: the leak path is almost always the index/list: update is locked down but Post.all in index shows drafts to everyone — policy_scope is not optional. And rescue NotAuthorizedError to 404 (not 403) for resources whose existence is itself private — a 403 confirms "it exists, you can't see it," which is information (topic 98 thinking).
93Active Storage — uploads, variants & direct upload

Scenario: avatars, invoices, product photos — stored on S3 (or disk in dev), resized on demand, attached to models with one line, uploaded without melting your web workers.

🧠
Analogy: a coat check for files: the model keeps only the ticket stubs (blob metadata rows), the coats hang in whatever cloakroom you configured (S3/GCS/disk) — swap cloakrooms and the stubs don't change. Variants are the tailor: "same coat, size M" cut on first request, then kept ready. Direct upload = guests hanging their own coats instead of queueing at your one clerk (web worker).
Code
# $ bin/rails active_storage:install && rails db:migrate

class Product < ApplicationRecord
  has_one_attached  :photo
  has_many_attached :manuals

  validates :photo, content_type: %w[image/png image/jpeg],   # gem a-s-validations
                    size: { less_than: 5.megabytes }
end

product.photo.attach(params[:photo])       # from form_with file_field
product.photo.attached?

# views — variants resize lazily, cached forever:
# <%= image_tag product.photo.variant(resize_to_limit: [300, 300]) %>
# <%= f.file_field :photo, direct_upload: true %>   ← browser → S3 directly

url_for(product.photo)                      # signed, expiring URL
Output
=> true
INSERT INTO "active_storage_blobs" (key, filename, byte_size, checksum...)
INSERT INTO "active_storage_attachments" (record_type: "Product", ...)
=> "https://bucket.s3.ap-south-1.amazonaws.com/xk2v...?X-Amz-Expires=300..."

✅ Do — good use cases

  • direct_upload: true for anything user-facing — the file goes browser→S3; your app only sees the metadata
  • Validate content type AND size — "image" fields receiving 2GB EXEs is a Tuesday
  • Variants for every display size — never ship 4MB originals to an avatar slot

❌ Don't — anti-patterns

  • Files in the database or in git — blobs belong in object storage, always
  • Serving originals of user uploads inline without scanning/content-disposition care — stored-XSS via SVG is real (topic 98)
Pattern & benefit: the two-table design (blobs + polymorphic attachments — topic 74 in the wild!) means "attach anything to anything" with zero migrations per model, service-swappable storage (disk in dev, S3 in prod, mirror during migration), and N+1-aware preloading via with_attached_photo.
Gotcha — why it goes wrong: deleting rows with delete/delete_all (topic 57's warning, storage edition) orphans real S3 objects — the purge jobs ride destroy callbacks. And listing pages that render product.photo without with_attached_photo fire three queries per row (attachment, blob, variant) — the sneakiest N+1 in modern Rails (topic 67's JSON-hat cousin).

Production Rails

94Background jobs — ActiveJob, Sidekiq & Solid Queue★ used daily

Scenario: checkout must feel instant, but it triggers a receipt email, a PDF invoice, and a webhook. Do them later, off the request thread — that's background jobs.

🧠
Analogy: a restaurant's takeaway ticket rail. The cashier (web request) takes your order in seconds, clips a ticket to the rail (the queue), and you're free. Kitchen staff (worker processes) pull tickets and cook at their own pace. A dropped dish? The ticket goes back on the rail (retry) — which only works if cooking it twice is harmless (idempotency).
Code
class ReceiptJob < ApplicationJob
  queue_as :default
  retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
  discard_on ActiveRecord::RecordNotFound   # order deleted? drop the job

  def perform(order_id)                 # pass IDs, never objects!
    order = Order.find(order_id)
    return if order.receipt_sent?       # idempotency guard
    ReceiptMailer.send_receipt(order).deliver_now
    order.update!(receipt_sent: true)
  end
end

# enqueue — controller or after_commit callback (topic 69!):
ReceiptJob.perform_later(order.id)
ReceiptJob.set(wait: 10.minutes).perform_later(order.id)
Output — server log
[ActiveJob] Enqueued ReceiptJob (Job ID: 44e...) to Sidekiq(default)
  with arguments: 8123
[ActiveJob] [ReceiptJob] Performing ReceiptJob from Sidekiq(default)
[ActiveJob] [ReceiptJob] Performed ReceiptJob in 231ms

✅ Do — good use cases

  • Everything slow or flaky: email, PDFs, image processing, external APIs, imports
  • Design every job idempotent — retries WILL run it twice someday
  • Backend choice: Solid Queue (Rails 8 default, DB-backed, zero extra infra) or Sidekiq (Redis, huge scale)

❌ Don't — anti-patterns

  • Passing ActiveRecord objects to perform_later — they serialize (GlobalID), go stale, and blow up if deleted before the job runs; pass IDs
  • One mega-job doing 10 things — one failure retries all 10; split into small jobs
Pattern & benefit: ActiveJob is the adapter layer — the same job class runs on Solid Queue, Sidekiq, or inline in tests. Retries with backoff, scheduled runs (set(wait:)), and queue priorities come free. Response times drop from seconds to milliseconds because the user never waits on the kitchen.
Gotcha — why it goes wrong: enqueue inside a transaction (or after_save) and the worker can grab the job before your COMMIT landsRecordNotFound for a record that "obviously exists." Enqueue from after_commit (Rails 7.2+ defers automatically inside transactions, but don't rely on it across backends). It's topics 69 + 71 conspiring — the most instructive bug in Rails.
95ActionMailer — email as views + delivery

Scenario: send a styled receipt email with the order details — written like a controller with views, previewed in the browser, delivered in the background.

🧠
Analogy: a mailer is a greeting-card studio: the mailer class picks the recipient and occasion (headers), the ERB template is the card design, and deliver_later drops it at the post office (job queue — topic 94) instead of you driving it across town mid-request.
Code
class ReceiptMailer < ApplicationMailer
  default from: "billing@shop.example"

  def send_receipt(order)
    @order = order                       # ivars flow to the view
    @customer = order.customer
    mail to: @customer.email,
         subject: "Receipt for order ##{@order.number}"
  end
end
# views: app/views/receipt_mailer/send_receipt.html.erb (+ .text.erb twin)

ReceiptMailer.send_receipt(order).deliver_later    # via the job queue

# preview while developing — app/mailers/previews/receipt_mailer_preview.rb
class ReceiptMailerPreview < ActionMailer::Preview
  def send_receipt = ReceiptMailer.send_receipt(Order.last)
end
# → http://localhost:3000/rails/mailers
Output — log
ReceiptMailer#send_receipt: processed outbound mail in 54ms
Delivered mail 66a1... (312ms)
To: asha@corp.com  Subject: Receipt for order #8123

✅ Do — good use cases

  • Always deliver_later from web requests — SMTP is slow and fails at the worst times
  • Both HTML and plain-text templates — spam filters and terminal readers thank you
  • Mailer previews — design emails without sending a single one

❌ Don't — anti-patterns

  • Heavy queries inside mailer views — compute in the mailer method, views just render
  • Sending straight from model callbacks pre-commit — the ghost-email bug (topics 69/94)
Pattern & benefit: the controller/view mental model transfers 1:1 — same ERB, same ivars, same layouts (mailer.html.erb). In dev, letter_opener-style setups or previews mean zero accidental real emails; in test, mails land in ActionMailer::Base.deliveries for assertions.
Gotcha — why it goes wrong: calling ReceiptMailer.send_receipt(order) does nothing by itself — it returns a lazy mail object; nothing sends until .deliver_later/.deliver_now. "My mailer silently doesn't send" = a missing deliver call, nine times out of ten. Also URLs in emails need _url helpers (absolute), not _path — there's no "current host" inside an inbox.
96Caching — fragment, Russian-doll & low-level

Scenario: the homepage renders 100 product cards and takes 800ms — but products change rarely. Cache the rendered fragments and serve them in 30ms, invalidating automatically when a product changes.

🧠
Analogy: a bakery display case. Instead of baking a fresh croissant per customer (re-rendering), you bake once and serve from the case. The cache_key is the freshness sticker — it includes updated_at, so updating a product changes the sticker, and the stale croissant is simply never picked up again (no manual expiry!). Russian-doll: a hamper containing wrapped items — swap one item, rewrap only the hamper, not every item inside.
Code
<%# fragment cache — key auto-includes product.updated_at: %>
<% @products.each do |product| %>
  <% cache product do %>
    <%= render product %>
  <% end %>
<% end %>

<%# collection render + cache in ONE line (fast path): %>
<%= render partial: "product", collection: @products, cached: true %>
Low-level — cache any computation
stats = Rails.cache.fetch("dashboard/stats", expires_in: 5.minutes) do
  Order.group(:status).sum(:total)     # runs only on cache miss
end

# model touch chain for russian-doll invalidation:
class Review < ApplicationRecord
  belongs_to :product, touch: true     # review changes → product.updated_at bumps
end
Log — second request
Read fragment views/products/index:abc123/product/42-20260711 (0.2ms) HIT
Rendered products/index.html.erb (Duration: 31ms | 100 cache hits)

✅ Do — good use cases

  • Fragment-cache expensive partials; cached: true on collections
  • Rails.cache.fetch with expires_in for aggregates and API responses
  • touch: true up the association chain so nested caches self-invalidate

❌ Don't — anti-patterns

  • Caching before measuring — cache the proven-slow 20%, not everything
  • Keys missing a version/dependency — permanently stale fragments after every deploy-time template change (Rails digests templates into keys for you — don't fight it)
  • Caching per-user content under a shared key — hello, data leak
Pattern & benefit: recycled keys beat manual expiry — [model, updated_at] keys mean you never delete, you just stop reading old entries (the store LRU-evicts them). Rails 8's Solid Cache keeps it all in Postgres — no Redis to run. Dev tip: bin/rails dev:cache toggles caching locally.
Gotcha — why it goes wrong: the two classic burns: (1) forgetting touch: true — child updates, parent's cached fragment keeps showing old data, "caching is broken!"; (2) caching something containing current_user-specific markup under a global key — user A sees user B's name. Include the variabilities in the key or don't cache that fragment.
97Testing — models & requests, fixtures & factories★ used daily

Scenario: refactor the pricing logic on Friday, deploy without fear — because 400 tests re-verify the whole app in 90 seconds. Rails ships with everything wired.

🧠
Analogy: tests are the circuit breakers in your house — invisible while you renovate, instantly loud the moment a wire crosses. Renovating without them isn't faster; it just moves the fire to production. Factories are IKEA flat-packs for test data: a valid user in one line, customize only what the test cares about.
Model test (Minitest — Rails default)
class ProductTest < ActiveSupport::TestCase
  test "is invalid without a name" do
    p = Product.new(price: 10)
    assert_not p.valid?
    assert_includes p.errors[:name], "can't be blank"
  end

  test "discounted price applies percentage" do
    p = Product.new(price: 200, discount_pct: 25)
    assert_equal 150, p.discounted_price
  end
end
Request test — the workhorse: full HTTP → routes → controller → DB
class ProductsFlowTest < ActionDispatch::IntegrationTest
  test "creating a product" do
    assert_difference "Product.count", 1 do
      post products_path, params: { product: { name: "Mug", price: 299 } }
    end
    assert_redirected_to product_path(Product.last)
  end

  test "rejects bad input" do
    post products_path, params: { product: { name: "" } }
    assert_response :unprocessable_entity
  end
end
Shell
$ bin/rails test
Running 4 tests in parallel using 4 processes
....
4 runs, 7 assertions, 0 failures, 0 errors, 0 skips  (1.42s)

✅ Do — good use cases

  • Most value per line: model tests for business logic, request tests for each endpoint's happy + error paths
  • FactoryBot (or fixtures) for data; assert_difference for count changes
  • Test behavior ("discount applies") not implementation ("calls compute_discount")

❌ Don't — anti-patterns

  • Mock-everything tests that pass while production burns — request tests hit the real stack for a reason
  • Sleeping/flaky time logic — use travel_to 5.days.from_now (built into Rails tests)
  • Chasing 100% coverage through getters — cover decisions, not lines
Pattern & benefit: each test runs in a DB transaction rolled back at the end — a pristine database every test, no cleanup code, parallel by default. RSpec (the popular alternative) maps 1:1 conceptually: describe/it/expect vs class/test/assert — the skills transfer entirely.
Gotcha — why it goes wrong: the transaction-rollback magic means data can't leak between tests — but also that after_commit callbacks don't fire in old-style transactional tests (Rails 7.1+ handles this; know it exists when a "works in prod, fails in test" callback haunts you). And jobs don't run unless you ask: perform_enqueued_jobs { ... } or assert with assert_enqueued_with.
98Security — the defaults that save you, and the holes that remain

Scenario: the classic web attacks — SQL injection, XSS, CSRF, mass assignment — and exactly which line of your code opens or closes each door. Rails defends by default; your job is to not disarm it.

🧠
Analogy: Rails hands you a house with locks already fitted: doors self-lock (CSRF tokens), windows are laminated (auto-escaping), the mail slot has a chute that can't reach the safe (parameterized SQL). Every breach story is someone propping a door open for conveniencehtml_safe, string-interpolated SQL, permit!.
The four doors — locked vs propped open
# 1. SQL injection
User.where("email = '#{params[:email]}'")     # ❌ propped open
User.where(email: params[:email])             # ✅ parameterized
User.where("created_at > ?", params[:since])  # ✅ placeholder form

# 2. XSS (views auto-escape — topic 82)
<%= @comment.body %>                          # ✅ escaped
<%= @comment.body.html_safe %>                # ❌ stored XSS
<%= sanitize @comment.body %>                 # ✅ if HTML is required

# 3. Mass assignment (topic 55)
Product.new(params[:product])                 # ❌ raises, thankfully
Product.new(product_params)                   # ✅ allowlisted

# 4. Secrets
config/credentials.yml.enc                    # ✅ encrypted, committable
Rails.application.credentials.stripe_key      # read at runtime
ENV["STRIPE_KEY"]                             # ✅ also fine (12-factor)
What the attacker sent — and what Postgres received
params[:email] = "x' OR '1'='1"
# ❌ version: SELECT * FROM users WHERE email = 'x' OR '1'='1'  ← every row!
# ✅ version: SELECT * FROM users WHERE email = $1   ["x' OR '1'='1"]  ← literal

✅ Do — good use cases

  • Hash conditions or ? placeholders for every scrap of user input reaching SQL
  • CSRF protection stays on (default); form_with handles tokens invisibly
  • has_secure_password + bcrypt for auth (or the Rails 8 built-in auth generator)
  • Run brakeman in CI — free static analysis for exactly these holes

❌ Don't — anti-patterns

  • String interpolation into where/order/pluckorder("#{params[:sort]}") is injection too
  • skip_forgery_protection "to fix a bug" — you just unlocked every door for that bug
  • Secrets in the repo or in plain ENV files committed "temporarily"
Pattern & benefit: the safe path is also the short path — where(email: x) is less typing than the injectable version. That's Rails' security genius: convention makes the secure choice the default choice, so a code review only needs to hunt for the exceptions.
Gotcha — why it goes wrong: developers trust "it's just an ORM" — but any string you build and hand to ActiveRecord (where strings, order, select, joins) is SQL you wrote. The sneakiest: order(params[:sort]) passes symbols/columns fine for months, until someone sends ?sort=price;--. Allowlist sortable columns: order(SORTS.fetch(params[:sort], :id)) — your fetch habit from topic 10, saving you again.
99rails console & debugging — your daily cockpit★ used daily

Scenario: "why is this order stuck?" You don't add puts and redeploy — you open a console, poke the actual objects, watch the actual SQL, and set a breakpoint in the actual request.

🧠
Analogy: the console is a stethoscope on the live app — same models, same DB, same code paths, but interactive. debugger is pausing the movie mid-scene to walk around the set: inspect any prop (variable), play the next frame (next), or follow an actor into another room (step).
Console session — a real investigation
$ bin/rails console
> o = Order.last
> o.status                          # "pending" — why?
> o.payments.map(&:state)           # ["failed", "failed"]
> o.payments.last.error_message     # "card_declined" — mystery solved
> ActiveRecord::Base.logger = Logger.new(STDOUT)   # show me the SQL
> Order.expiring.to_sql             # inspect a scope without running it
> app.get "/products/42"            # drive a real request from console!
> helper.number_to_currency(499.5)  # test any helper

$ bin/rails console --sandbox       # everything ROLLS BACK on exit — safe prod spelunking
Breakpoints — the debug gem (built in)
def create
  @order = Order.new(order_params)
  debugger                 # server pauses HERE on next request
  @order.save
end
# in the paused terminal:
#   order_params        → inspect anything
#   next / step / continue
#   break Order#total   → breakpoint elsewhere
Also learn to READ the log — every answer is already there
Started POST "/orders"
  Parameters: {"order" => {"total" => "500"}}
  Order Create (2.1ms)  INSERT INTO "orders" ...
  ↳ app/controllers/orders_controller.rb:12    ← the exact line that queried
Completed 302 Found in 48ms (ActiveRecord: 6.3ms | Allocations: 12043)

✅ Do — good use cases

  • Console-first development: try the query/scope/method live, then paste into code
  • --sandbox for production consoles — read freely, mutations vanish
  • Tail log/development.log — repeated one-row SELECTs = N+1 (topic 67), slow lines announce themselves

❌ Don't — anti-patterns

  • puts-and-redeploy debugging loops — debugger is one word and infinitely faster
  • Un-sandboxed prod console mutations without a transaction wrapper — one typo'd update_all and you're restoring backups (you know this pain from the DB side)
Pattern & benefit: the console collapses the feedback loop from minutes to seconds — most "how does this API behave?" questions in this playbook are answerable there in one line. to_sql, explain (yes, ActiveRecord has EXPLAIN — Order.expiring.explain(:analyze)), and the query-source log line connect every Rails mystery straight back to the Postgres knowledge you already own.
Gotcha — why it goes wrong: console writes are real writes — the same muscle memory that types User.delete_all in dev works in prod. Sandbox by default, and remember class reloading: after editing code, type reload! or your console keeps running the old code — the source of many "but I fixed that!" moments.

Ship It — Errors, Logs & Deploys

100rescue_from — app-wide error handling

Scenario: a missing record should 404, a forbidden action 403, a payment failure should apologize politely — without every action wearing its own begin/rescue. One place, app-wide.

🧠
Analogy: rescue_from is the hospital triage desk: every incident in the building routes to one desk that knows the protocol per case — broken record? Room 404. No visa (topic 92)? Room 403. Unknown emergency? Page the specialists (error tracker) and show the calm face to visitors. Individual wards (actions) stop improvising first aid.
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # order matters: LAST declared wins ties, so put the generic FIRST
  rescue_from StandardError, with: :internal_error unless Rails.env.local?
  rescue_from ActiveRecord::RecordNotFound,    with: :not_found
  rescue_from Pundit::NotAuthorizedError,      with: :not_found  # hide existence
  rescue_from PaymentGateway::Declined do |e|
    redirect_to checkout_path, alert: "Payment declined: #{e.message}"
  end

  private

  def not_found
    respond_to do |f|
      f.html { render "errors/not_found", status: :not_found }
      f.json { render json: { error: "not_found" }, status: :not_found }
    end
  end

  def internal_error(e)
    Rails.error.report(e)          # → Sentry/Honeybadger via subscriber
    render "errors/internal", status: :internal_server_error
  end
end
Output
GET /products/999999   → 404, branded page (HTML) or {"error":"not_found"} (JSON)
declined card          → redirect + flash, user keeps their cart
anything else          → 500 page + one Sentry event with full backtrace

✅ Do — good use cases

  • Map your domain errors (topic 35's custom classes!) to user-facing outcomes in ONE file
  • Rails.error.report — the built-in error-reporting interface trackers subscribe to
  • Respond per format — API clients need JSON errors, not HTML (topic 89)

❌ Don't — anti-patterns

  • rescue_from StandardError in development too — you've hidden the stack traces you debug with (note the unless Rails.env.local?)
  • Rescuing and NOT reporting — a 500 page with no tracker event is a bug you'll never hear about (topic 35's swallowing rule, controller edition)
Pattern & benefit: handlers inherit — every controller gets the triage desk free, subclasses override for special wards (API base rescues to JSON). Business exceptions raised deep in services (topic 61) surface as tidy UX with zero plumbing between: raise where it happens, handle where it's shown.
Gotcha — why it goes wrong: declaration order is inverted from intuition — Rails walks handlers bottom-up, so a StandardError handler declared LAST shadows every specific one above it (opposite of begin/rescue! topic 35). Generic first, specific after. And rescue_from can't catch errors raised in middleware or in the error pages themselves — keep those templates dependency-free static.
101Logging & instrumentation — evidence beats vibes

Scenario: "the import was slow yesterday around noon" — with good logs that's a query, not a séance. Structured logging, tagged requests, and timing instrumentation.

🧠
Analogy: logs are the ship's logbook — worthless if entries read "stuff happened," priceless when each line carries who/what/how-long and the voyage id (request id) so you can replay one crossing end to end. ActiveSupport::Notifications is the engine room's set of built-in gauges — every query, render, and job already emits timings; subscribing is just wiring a dial to them.
Code
Rails.logger.info  "order placed"                       # fine
Rails.logger.info  "order.placed id=#{o.id} total=#{o.total} gateway=stripe"
                                                        # grep-able: better
Rails.logger.tagged("import", batch.id) do              # prefix a whole block
  Rails.logger.info "start rows=#{rows.size}"
end

# time anything:
ActiveSupport::Notifications.instrument("import.batch", rows: rows.size) do
  process(rows)
end
ActiveSupport::Notifications.subscribe("import.batch") do |event|
  Rails.logger.info "import.batch #{event.duration.round}ms rows=#{event.payload[:rows]}"
end

# config/environments/production.rb — one line per request:
# config.log_tags = [:request_id]     ← ties every log line to its request
Output — logs you can query
[req-8f3a] [import] [42] start rows=5000
[req-8f3a] import.batch 2314ms rows=5000
[req-8f3a] Completed 200 OK in 2371ms (ActiveRecord: 1204.7ms)
→ grep req-8f3a production.log  = the entire story of one request

✅ Do — good use cases

  • key=value log lines for business events — future-you greps; log aggregators parse
  • log_tags = [:request_id] — the single highest-value logging config in Rails
  • Instrument slow suspects (imports, external APIs) — thresholds beat anecdotes

❌ Don't — anti-patterns

  • Logging secrets/PII — passwords, tokens, full card numbers (filter_parameters covers params, not your custom lines)
  • puts debugging left in production code — it skips the logger's levels, tags, and formatting entirely
Pattern & benefit: the request log you learned to read in topic 99 — SQL timings, source lines, totals — is all Notifications under the hood; your custom events join the same stream. When an incident hits, request_id correlation across web + job logs turns an hour of guessing into five minutes of grep.
Gotcha — why it goes wrong: log levels bite in both directions: debug in production logs every SQL statement (disk + noise + PII risk), while warn-only hides the request lines you'll desperately want during the outage. Production sweet spot: info with intentional events. And interpolated log lines evaluate even when filtered — use block form logger.debug { expensive.inspect } in hot paths.
102Seeds & sample data — db/seeds.rb done right

Scenario: a fresh checkout should become a working app with one command — reference data (countries, plans, roles) guaranteed present, and dev-only sample data that looks real.

🧠
Analogy: seeds are the display suite in a new apartment tower: move-in-ready furniture so viewers (developers, demos) experience the real thing immediately. Two kinds of furniture though — the built-in fixtures every unit ships with (reference data, idempotent, prod-safe) and the staging props (fake users, lorem orders) that must never appear in a sold unit.
db/seeds.rb
# reference data — idempotent, safe to run ANY time, ANY env:
%w[starter pro enterprise].each_with_index do |name, i|
  Plan.find_or_create_by!(slug: name) do |p|     # find_or_create = re-runnable
    p.name, p.position = name.titleize, i
  end
end

# dev/demo sample data — guarded and generated:
if Rails.env.development?
  require "faker"
  admin = User.find_or_create_by!(email_address: "admin@example.com") do |u|
    u.password = "password"; u.role = :admin
  end
  50.times do
    c = Customer.find_or_create_by!(email: Faker::Internet.unique.email) do |x|
      x.name = Faker::Name.name
    end
    c.orders.create!(total: rand(100..9000),
                     status: Order.statuses.keys.sample,
                     created_at: Faker::Time.backward(days: 90))
  end
end
puts "Seeded: #{Plan.count} plans, #{Customer.count} customers"
Shell
$ bin/rails db:seed          # run seeds alone (re-runnable!)
$ bin/rails db:setup         # create + schema:load + seed — new machine, one line
$ bin/rails db:reset         # drop + setup — the "start over" button
Seeded: 3 plans, 50 customers

✅ Do — good use cases

  • Idempotency via find_or_create_by! — seeds run on every deploy without duplicating
  • Faker with realistic volume (50 customers, 90 days of orders) — pagination, N+1s, and slow queries show up in dev
  • Env guards around sample data — reference data global, props dev-only

❌ Don't — anti-patterns

  • Customer.create! bare in seeds — second run = duplicates or uniqueness explosions
  • Seeding with production data dumps on laptops — PII on 15 laptops is a breach checklist item
Pattern & benefit: onboarding drops from "pair with a senior for a day" to bin/setup; demos stop depending on whoever's database is least broken. Idempotent seeds double as the reference-data deploy mechanism — new plan tier next quarter is a seeds diff, reviewable in the PR.
Gotcha — why it goes wrong: seeds run through your models — validations (good!) but also callbacks (topic 69): seeding 50 users fires 50 welcome-email jobs unless guarded. And bcrypt (topic 91) makes 500 user creations take minutes — share a precomputed digest for bulk fake users. Seeds hanging or spamming = callbacks you forgot you had.
103Deployment — Kamal, migrations & the boring checklist

Scenario: from laptop to a real server without a PaaS bill: Rails 8 ships Kamal — dockerized deploys to any VPS — plus the handful of production rules that prevent 2am pages.

🧠
Analogy: Kamal is a shipping-container port for your app: identical sealed containers (Docker images) craned onto any ship (VPS) with a standard manifest (deploy.yml) — no artisanal hand-rigging per server. Zero-downtime deploys are the port keeping the old container serving until the new one answers the health check — then swapping the gangway.
config/deploy.yml — the whole deployment story
service: shop
image: balu/shop
servers:
  web: [203.0.113.10]
  job:
    hosts: [203.0.113.11]
    cmd: bin/jobs                    # Solid Queue workers
proxy:
  ssl: true                          # auto Let's Encrypt
  host: shop.example.com
registry:
  username: balu
  password: [KAMAL_REGISTRY_PASSWORD]
env:
  secret: [RAILS_MASTER_KEY, DATABASE_URL]
Shell
$ kamal setup      # first deploy: installs Docker, boots everything
$ kamal deploy     # build → push → health-check → swap. Zero downtime.
$ kamal app logs -f
$ kamal rollback   # previous image, one command

# release phase runs migrations before the swap:
#   bin/rails db:prepare   (migrate if needed, create+load if fresh)

✅ Do — good use cases

  • Backwards-compatible migrations (add column THEN deploy code using it) — old and new code overlap during the swap; your zero-downtime DDL playbook entry (#33) applies verbatim
  • Health-check endpoint (/up is built in) — deploys that verify before they swap
  • Secrets via env/credentials (topic 59) — the image itself stays secret-free

❌ Don't — anti-patterns

  • Deploying migration + code that requires it in one shot with long DDL — the overlap window 500s (or the migration locks the table: playbook #33 again)
  • SSHing into prod to "quickly fix" a file — the next deploy erases it; containers are cattle, not pets
Pattern & benefit: the whole modern default stack — Kamal + Thruster + Solid Queue/Cache/Cable — runs a serious app on one ₹800/mo VPS with SSL, zero-downtime deploys, and no Redis to babysit. Every deploy is an immutable image: rollback is re-pointing, not re-building.
Gotcha — why it goes wrong: the classic first-deploy trio (topic 59's warning, now live): missing RAILS_MASTER_KEY env (boot loop), assets compiled without the right env, and migrations that lock a big table during the release phase (site hangs mid-deploy — algorithm: :concurrently, separate deploy). Rehearse once on a throwaway VPS; the checklist becomes muscle memory.
104Performance toolkit — find the slow, fix the proven★ used daily

Scenario: "the app is slow" — where? The pro workflow: measure → attribute (DB? view? Ruby? external?) → fix the top item → measure again. Tools for each step.

🧠
Analogy: performance work is fixing a slow morning commute: you don't buy a faster car (bigger server) before checking WHERE the time goes — the 40 minutes at one broken signal (an N+1), the daily fuel-station queue (missing index), the scenic detour nobody remembers choosing (over-rendering). The profiler is the dashcam replay showing minutes per segment.
Code — the attribution toolkit
# 1. every dev page, always-on: rack-mini-profiler (badge shows ms + SQL count)
# Gemfile(dev): gem "rack-mini-profiler"

# 2. the request log — already telling you (topic 99):
# Completed 200 OK in 840ms (Views: 210ms | ActiveRecord: 590ms | Allocations: 148k)
#                                            ^^^^^ DB-bound: hunt queries first

# 3. find the queries:
Order.recent.explain(:analyze)      # your EXPLAIN, via ActiveRecord
# config: config.active_record.query_log_tags_enabled = true
#         → every SQL gets a comment: /*controller:orders,action:index*/
#           pg_stat_statements now maps hot SQL to Rails code! (playbook #22)

# 4. common wins, in the order they usually pay:
Order.includes(:customer)           # N+1 (topic 67)
add_index :orders, [:status, :created_at]   # the WHERE you filter hourly
Order.select(:id, :total)           # stop loading 40 columns for 2
Rails.cache.fetch(...) { ... }      # cache the proven-expensive (topic 96)
Output — before/after with query_log_tags
pg_stat_statements top query:
  SELECT "orders".* ... /*controller:dashboard,action:index*/
  calls: 41,203/day   mean: 212ms
→ add composite index → mean: 3.1ms
Completed 200 OK in 96ms (Views: 71ms | ActiveRecord: 11ms)

✅ Do — good use cases

  • mini-profiler in dev permanently — you SEE every page's cost while building it
  • query_log_tags + pg_stat_statements — your playbook #22 workflow, now Rails-aware
  • Fix in evidence order: N+1s → indexes → over-fetching → caching → then Ruby micro-stuff (topic 52)

❌ Don't — anti-patterns

  • Caching as the first move — it hides the disease (and adds invalidation bugs, topic 96) when a 3ms index fix existed
  • Load-testing dev mode — code reloading and no-cache make dev 10× slower; measure production-mode numbers
Pattern & benefit: the log line's three-way split (Views / ActiveRecord / total) attributes the problem in one glance, and 80%+ of real Rails slowness resolves to the database layer — exactly where your Postgres depth makes you the strongest person in the room. The full loop — playbook #21/#22 on one side, includes/select/indexes on the other — is your unfair advantage.
Gotcha — why it goes wrong: averages lie — a 96ms mean hiding a 4-second p99 (one user's giant account, one cold cache) is where complaints actually come from; look at percentiles, and profile that user's request. And "Allocations: 2,400,000" in the log line is the Ruby-side smell (building huge arrays — topic 72's batching exists for this); memory pressure shows up as GC time no single trace explains.
🎯 Quiz — Test Yourself

Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.

Score: 0 / 0 answered · 0 total