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.
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.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" endhello 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..nloops — Rubyists useeach/mapiterators if x == true— justif x; comparing totrueis a smell
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)?
: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=> 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/gsubit, it's data → string
:status says "this is a name of a thing" while "shipped" says "this is a value." Idiomatic Ruby reads correctly because of this split.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.
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."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
0 is truthy! empty is truthy! => nil => "guest"
✅ Do — good use cases
&.for genuinely-optional chains:current_user&.emailx ||= defaultfor memoization and lazy defaults- In Rails:
params[:q].presence || "all"— treats""as missing too
❌ Don't — anti-patterns
a&.b&.c&.deverywhere — you're hiding a design problem, and bugs surface far from their causeif x == nil— usex.nil?or just truthiness
if will do. ||= is the standard Rails memoization idiom: @user ||= User.find(session[:user_id]) runs the query once per request.|| 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.
#{...} 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.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=> "Hi Asha, total: ₹499.50" => "ruby-on-rails" => true => "SELECT id, email\nFROM users\n"
✅ Do — good use cases
"#{}"over+concatenation — handlesto_sfor you<<~SQLsquiggly heredocs for multi-line SQL/text (indentation-stripped)- Chain the cleaners:
.strip.downcaseon any external input
❌ Don't — anti-patterns
str += pieceinside big loops — allocates a new string each pass; collect in an array andjoin- Interpolating user input into SQL strings — that's an injection; use placeholders (topic 98)
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).'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.
* is the catch-all bag that scoops up whatever's left over.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)=> "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?) — neveris_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:savereturns false,save!raises)
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.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.
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.# 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"
endtook 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…endmulti-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
ActiveRecord::Base.transaction do ... end can promise atomicity — your code runs inside its guarantee.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.
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)=> 10 => 10 => 1 => ["RUBY", "RAILS"] => 800.0
✅ Do — good use cases
- Default to
->lambdas — strict args and sanereturn map(&:name)instead ofmap { |x| x.name }- Hash-of-lambdas to replace long if/elsif dispatch chains
❌ Don't — anti-patterns
Proc.new/procunless you specifically want loose arity- Storing big multi-step logic in lambdas — that's a class or method's job
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).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.
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.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=> [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_mapwhen you'd otherwisemapthencompact
❌ Don't — anti-patterns
each+ manually pushing into an accumulator array — that ISmap/select, hand-rolled worse- Loading
Order.allinto Ruby to filter/sum what SQL could do — push it to the DB (topic 64)
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.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.
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.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=> {"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_byfor report buckets,tallyfor counters,min_by/max_by/sort_byfor "by some attribute"each_slice(500)to batch API calls or insertszip,flat_map,uniq { }— skim the Enumerable docs once; it pays forever
❌ Don't — anti-patterns
- Hand-rolling
h[k] ||= []; h[k] << vloops — that'sgroup_by - Doing this on millions of DB rows in Ruby —
Order.group(:status).sum(:total)does it in Postgres (topic 64)
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.
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.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=> "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
fetchwhen the key must exist — fail loud, fail earlydigfor nested API/JSON payloads instead of&.chains- Lookup-table dispatch:
HANDLERS[event] || DEFAULTbeats 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
merge, transform_values, slice, except cover most JSON-wrangling without loops. Rails' params, options hashes, and where clauses are all just hashes.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 ===.
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.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")=> ["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
casereturns a value — assign it directly
❌ Don't — anti-patterns
if score >= 90 && score <= 100chains — ranges say it better- Huge
caseon your own types — that's polymorphism begging to happen (topic 21)
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? }.(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.
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.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=> [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
compactafter maps that may produce nil (orfilter_mapin 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'sa.last flattenon deliberately structured pairs —[[k, v], ...]often wantsto_h, not flattening
flatten(1) takes a depth argument; uniq takes a block (uniq { |u| u.email }).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.
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.%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.next0: 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?" checkseach_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 += 1counters alongsideeach— that'swith_index's whole jobforloops with manual ranges to fake windows —each_cons/each_sliceexist
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.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.
# 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=> [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
.lazyon small arrays — the laziness bookkeeping costs more than it saves- Forgetting the terminator — a lazy chain without
first/take/forcecomputes nothing at all
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.
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) }=> 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
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."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.
scan sweeps the whole beach and brings back every find.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/)=> 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;scanfor 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
/xextended mode or plain Ruby steps
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.^ 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.
rice, fish, pickle = box. The splat is the big plate that takes all the leftovers, and nested parens unpack the compartment inside a compartment.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=> [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_objectpairs first, *restfor 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
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.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.
@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.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#101 (sent) ₹4999 => 101
✅ Do — good use cases
attr_readerby default;attr_accessoronly when outsiders truly must write- Keyword args in
initialize—Invoice.new(number: 101, ...)self-documents - Methods that change state return
selffor chaining
❌ Don't — anti-patterns
attr_accessorfor 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
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.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.
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.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=> "archived at 14:02" => true => [Post, Archivable, Object]
✅ Do — good use cases
- Cross-cutting behavior:
Archivable,Taggable,Searchableacross unrelated models - Namespacing:
module Billing; class Invoice→Billing::Invoice - In Rails:
ActiveSupport::Concernfor 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
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.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.
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.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)=> "Hi, Asha [admin L2]" => [Admin, User, Object]
✅ Do — good use cases
- True is-a relationships:
Adminis-aUser;CsvReportis-aReport superininitializewhenever 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
ancestors in order — the same chain super climbs — so behavior is always findable, never a diamond-problem mystery.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.
respond_to?(:drum)). Anyone who can play, plays. A drum machine gets the gig too.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)=> ["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
deliverIS a valid channel; no mocking framework gymnastics - Accept "anything with
each" / "anything withto_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
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.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.
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).# 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=> 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
OpenStructin 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
== — perfect hash keys and test expectations), fields are typo-proof (pune.lot → NoMethodError instantly), and Data's immutability makes objects safely shareable across threads and cache entries.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.
<=> 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.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=> 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.triplegives 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) —sortoutput becomes nondeterministic garbage
< <= == >= > 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.<=> 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?
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".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.statsclass body: Order => "instance method: Order" => "class method: Order"
✅ Do — good use cases
- When debugging "undefined method" — first ask "what is self here?" (
p selfworks anywhere) self.attr = valuefor setters inside instance methods — mandatory- Return
selffrom mutating methods to allow chaining
❌ Don't — anti-patterns
- Writing
self.method_namefor ordinary calls — implicit is idiomatic; explicit is only for setters and disambiguation - Assuming
selfinside a block equals the block's location —instance_eval-style APIs swap it (topic 48)
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).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.
== 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).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=> true => false => true => false => true => 5
✅ Do — good use cases
- Define
==(+ aliaseql?, +hash) on domain values used in hashes/sets/uniq - Or skip the boilerplate:
Data.define/Structgive value equality free (topic 22) equal?only when identity truly matters (caching, cycle detection)
❌ Don't — anti-patterns
- Overriding
==withouthash— Hash/Set/uniq quietly treat equal objects as different - Using
===as "extra-equal" — it's case-matching, nothing else
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.== 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.
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"=> true => "NoMethodError: protected method 'balance' called"
✅ Do — good use cases
- Default new helper methods to
private— promote to public only on demand protectedfor 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
sendto bypass private in app code — it works (topic 37), which is exactly the problem
private also stops being routable as an action — a real safety line.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.
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.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))=> 2 => ["pdf", "csv"]
✅ Do — good use cases
- Factories (
from_json,build_default), finders, aggregate queries def self.xfor one or two;class << selfwhen 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
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.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.
@@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.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]=> 100 # Car's assignment overwrote Vehicle's @@count! => [["a"], ["b"]] # clean separation
✅ Do — good use cases
- Class instance variables (
@xat 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)
@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.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.
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).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)
=> [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 Rubyprependfor wrapping existing methods with logging/timing (thensuper)ancestorswhen 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
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.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.
a + 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".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)₹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
+ - * / == <=> << [] []= ! 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.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.
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.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)=> 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:
eachfetches page after page; callers justmap - Add
Comparableto 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
def to_a (free via Enumerable) and splatting works too. This is the highest leverage-per-line pattern in Ruby.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.
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).# 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
=> "Acme Corp" => "buyer@acme.com" # with allow_nil: true and no customer: => nil # instead of NoMethodError on nil
✅ Do — good use cases
prefix: trueso the origin stays visible (customer_name)allow_nil: truewhen 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
delegate line changes instead of forty call sites.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.
||= 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.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# 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)
@current_user ||= appears in effectively every Rails app ever written.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.
=> variables) pop out already filled. Wrong shape? The cutter simply doesn't fit and the next in clause tries.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=> "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/whenreads better - Forgetting the fall-through: no
else+ no match =NoMatchingPatternErrorraised
dig chains, intent is visible: the code literally shows the shape of the data it accepts.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.
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.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!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 ensurefor 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 unkillablerescue => 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)
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.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.
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).# 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)"
FrozenError: can't modify frozen Array FrozenError: can't modify frozen String => ["draft", "sent", "paid", "custom"]
✅ Do — good use cases
# frozen_string_literal: trueat the top of every file (Rails generates it).freezeevery constant Array/Hash/String — constants in Ruby aren't actually constant!dupbefore 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
freezefor deep structures without freezing the elements too
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.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."
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.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=> "report as PDF" => "report as CSV" => "report as XLSX"
✅ Do — good use cases
- Killing hard duplication: N near-identical methods → one
define_methodloop public_sendwhen the method name arrives as data (respectsprivate!)- Reading it fluently — Rails source is full of this; now it's legible
❌ Don't — anti-patterns
method_missingwithoutrespond_to_missing?— breaksrespond_to?, confuses everyonesend(params[:action])— letting users invoke arbitrary methods is a security hole- Metaprogramming to look clever — every layer of magic is a layer of debugging
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.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.
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."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$ 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.lockalways — it IS the reproducibility guarantee ~> 8.0pessimistic pins for frameworks; loose for small utilities- Put test/dev-only gems in groups — production installs skip them
❌ Don't — anti-patterns
gem install whateverglobally for project code — invisible to teammates and CI- Editing
Gemfile.lockby 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
bin/rails, bin/rspec) wrap bundle exec for you — prefer them.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.
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.# 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}"=> [1, [2, 3]] # first, rest 3 items
✅ Do — good use cases
- Guard clauses at method top — flat happy path, edge cases dispatched early
unlessfor simple negative conditions:raise unless valid?tapto configure-and-return;thento chain a value into a block
❌ Don't — anti-patterns
unless x.nil? && !y.empty?— compound negatives melt brains; useifunless ... else— legal, universally hated- Clever one-liners that need a comment to explain — clarity beats brevity
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.
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.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!=> [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
@ivarsfor object state only — not as sneaky parameter-passing between methods
❌ Don't — anti-patterns
$globalsin app code — invisible coupling, thread-unsafe, untestable (loggers are the rare exception)- Long methods where block-mutated locals accumulate — extract methods with explicit args
@ = 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.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.
:: 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).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"=> 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 freezeconstant arrays/hashes/strings — the nameplate doesn't protect the contents
❌ Don't — anti-patterns
- Cross-district reach-ins everywhere (
A::B::C::Dchains in unrelated code) — high coupling in plain sight - Defining constants dynamically at runtime — Zeitwerk and grep both lose track
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.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.
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."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| ... }=> 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 theis_a?(Array)branchingto_sin string interpolation contexts — it's called for you; rely on it
❌ Don't — anti-patterns
params[:amount].to_ifor money paths — "abc" → ₹0 orders are real incidents- Implementing
to_str/to_arycasually — those are implicit conversions Ruby calls behind your back; only true string/array-like classes qualify
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.)"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.
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=> 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) ort.decimal— nevert.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
numeric maps to BigDecimal in ActiveRecord automatically, so a decimal column round-trips exactly: your DB instinct (never float for money) carries straight over.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.)
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.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!=> "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;strftimeonly 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.parseon user-entered dates without a format — "01/02/2026" is ambiguous (parsed day-first!); useDate.strptime(s, "%d/%m/%Y")
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.Date + n adds days but Time + n adds seconds — Time.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?
& 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.%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")=> ["RUBY", "RAILS"] => [1, 3] => [3, 7] => ["new-delhi", "navi-mumbai"] => "ruby-on-rails" hello
✅ Do — good use cases
&:methodwhenever 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
&:symwhen arguments are needed —map(&:round)can't pass 2; write the block- Composition chains so long nobody can name the intermediate steps — name them
& works because Symbol implements to_proc; understanding that one line demystifies the most common idiom in modern Ruby.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.
*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.# 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")took 0.0s
=> 30
=> "div.span {id: \"x\"}"✅ Do — good use cases
...for pure pass-through wrappers — decorators, retries, instrumentation*args/**optswhen 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
**optswhen forwarding — keyword args silently become a trailing hash in old styles; forward all three shapes
define_method-based instrumentation, Rails' delegate (topic 32), and every middleware pattern you'll meet.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.
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?.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"=> "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 callingsuper - Prefer
define_methodwhen 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
find_by_name_and_email dynamic finders worked (since replaced by real methods — a lesson in itself).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.
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.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=> [:fetch, :transform, "parallel(4)", :load] => "HELLO!"
✅ Do — good use cases
- Configuration DSLs where bare-word vocabulary genuinely reads better
class_evalwith 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
selfis a place readers get lost; plain methods often win - String eval (
class_eval "def ...") with interpolated input — injection + un-debuggable; use block form
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).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.
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).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", ".*")=> 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_linefor anything possibly large — pairs with.lazy(topic 14)Pathname/File.joinover string concatenation — no"dir" + "/" + "file"slash bugs
❌ Don't — anti-patterns
File.readon user uploads/logs of unknown size — one 4GB file and the process dies- Building paths from user input without validation —
../../etc/passwdtraversal is alive and well
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.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.
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"]=> ["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: trueon 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— plainYAML.loadcan 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
render json: and jsonb columns (topic 79) extend the same mental model.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.
Mutex is the "one cook at the board" rule. Best kitchen: every cook brings their own board (no shared mutable state).# 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=> [["/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"
@@cacheis a race (topic 28) - Threads for CPU-bound speedups — the GVL serializes Ruby bytecode; use processes/Ractors for that
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.
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.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)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
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.
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
$ 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
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.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.
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.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
endGET /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
resourceswithonly:/except:— expose only what exists- Path helpers everywhere:
product_path(@product), never"/products/#{@product.id}" bin/rails routes -g productwhen 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" iscreateon acancellation - Deeply nested resources 3+ levels —
/users/1/orders/2/items/3/reviewsis pain; nest one level max
resources :products to a different path and every link in the app updates itself.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.
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.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
endPOST 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
*_paramsmethod per resource — the single place field access is granted before_actionfor 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 raisesForbiddenAttributesError, and it's right topermit!(allow-everything) — you've disabled airport security entirely- Business logic in actions — extract to models/service objects once an action passes ~10 lines
params[:id] vs params.expect(...): reading single values is fine; it's writing to models that must pass the checkpoint.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.)
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.# $ 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$ 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_indexon a huge production table withoutalgorithm: :concurrently— table locked, site down
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.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).
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.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?=> #<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
findfor URLs/ids (missing = legit 404);find_bywhen absence is normal
❌ Don't — anti-patterns
- Ignoring
save's return value — the silent-false bug: nothing saved, nobody told you update_attribute/update_columnto "get around" validations — you're injecting invalid data past your own guards
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).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.
$ 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 itselfinvoke 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 modeldaily;g scaffoldonce while learning, then rarelyreferencestype in generators — FK column + index +belongs_towiredrails destroyto 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
config/application.rb can switch test framework, skip helpers/assets per generate — tune once, benefit every time.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.
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!)=> "production" => true => "sk_live_..." => "AKIA..."
✅ Do — good use cases
- Behavior differences live in
config/environments/*, notif Rails.env.production?sprinkled through app code - Credentials for static secrets; ENV for per-deploy infrastructure (URLs, pool sizes)
ENV.fetch(raises when missing) overENV[](silent nil) for required vars
❌ Don't — anti-patterns
- Committing
master.keyor real.envfiles — 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
RAILS_MASTER_KEY travels out-of-band. Per-environment credentials (credentials:edit --environment production) keep staging keys from ever touching prod.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.
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!=> 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
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.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.
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?=> #<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
UserServicewith 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
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.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...
2.days.ago is a blade, not the base language."".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=> 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?/presenceas 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+/, " ")issquish) — the reviewer will know - Assuming these work in plain Ruby scripts — outside Rails you must
require "active_support/all"(or accept NoMethodError)
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).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.
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.# 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)=> 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_weekranges — they encode the zone math
❌ Don't — anti-patterns
Time.now/Date.todayin 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
timestamptz is this same philosophy — you already believe it.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.
each, to_a, first). One list, one trip, one SQL statement.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?=> {"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 pluckfor 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 jobOrder.all.countvsOrder.count? Fine — butorders.to_a.countafter you only needed a number: you shipped rows to count them
.to_sql in console, and watch the log: your Postgres instincts fully transfer here..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.
belongs_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."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)=> 2 => [#<Order id: 2, total: 1200>] => "Acme"
✅ Do — good use cases
- Always declare
dependent:onhas_many—:destroy(callbacks),:delete_all(fast SQL),:nullify, or:restrict_with_error t.references :customer, foreign_key: truein the migration — real DB constraint underneath- Treat
c.ordersas a scope-able relation:c.orders.recent.unpaid
❌ Don't — anti-patterns
- Managing raw
customer_idby hand when the association could —order.customer = creads better and sets the FK - No
dependent:option at all — deleting a parent leaves orphans (or explodes on the FK constraint)
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.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.
has_many :through says "to find my patients, walk through my appointment book."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
=> ["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
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.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.
includes is the tray: "while you're fetching the orders, bring all their customers too."# ❌ 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# 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
includeswhenever 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_loadingon 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
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.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.
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.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=> 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: truealone — 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
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.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.
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.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]=> ["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— neverafter_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
before_validation → before_save → before_create → INSERT → after_create → after_save → after_commit. Returning early? throw :abort cancels the save.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.
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=> [#<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 includingfind, breaksunscopedexpectations, 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
active.premium.expiring compiles to one SQL statement. Business vocabulary lives in the model — controllers read like sentences: current_user.subscriptions.active.recent.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.
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).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.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
falsedoes 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
with_lock combines transaction + lock! in one call. Optimistic locking (lock_version) suits low-contention edits (admin forms); pessimistic lock! suits money.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).
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).# ❌ 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")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_batchesfor any loop over more than ~1k rowsinsert_all/upsert_allfor imports — hundreds of times faster thancreate!loopsupdate_all/delete_allfor mass state flips where callbacks don't matter
❌ Don't — anti-patterns
User.all.map(&:email)on big tables — that'spluck(:email), or batched- Forgetting that
update_all/insert_allskip validations, callbacks, andupdated_at— setupdated_at: Time.currentyourself if it matters
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.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.
paid!), a question per status (paid?), and a ward roster per status (Order.paid).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] }=> "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:refundedin 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?) — useprefix:/suffix:
validate: true bad input degrades politely into errors[:status], form-friendly like any validation (topic 68).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.
commentable_type) and which one (hook #42 = commentable_id). Any new item type works day one; the cloakroom never changes.# 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
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'spolymorphic: truedoes it dependent: :destroyon 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!)
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.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.
delegated_type gives each roommate a private drawer (own table for unique attrs) while sharing the common rail.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?
=> [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_typewhen each subtype carries its own attributes — shared timeline, private drawers- Index the
typecolumn — 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
map(&:toll) dispatches per type with zero case statements. Duck typing (topic 21) with a database backing.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.
touch is the guard also updating the "last activity" clock — which the cache system (topic 96) reads as "this room changed."# 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) }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: truewhen parent caches must invalidate on child changes (Russian-doll caching, topic 96)reset_countersin 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_allbulk ops — those skip callbacks, the dial drifts (topic 72)
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.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.
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.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)=> 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" idiommerge(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 formjoinswhen you'll also render the association — that'sincludesterritory (topic 67); joins doesn't load, it filters
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.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.
# 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}%")=> #<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_sqlwhen you want models;select_allfor 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.sqlis a pledge that the string is trusted; never pledge params
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.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.
store_accessor makes product.color read/write attrs["color"] as if it were a real drawer, validations included.# 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)=> "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 jsonbapp 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)
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.p.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).
# 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 }
endpage 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.countAND rendering the page — pagy computes counts once; don't double-fire COUNT on big tables
limit/offset appended to a lazy relation (topic 64). And the keyset upgrade is a controller-level change, not an architecture project.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.
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
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
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.
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.<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" %>
<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><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 localproduct- Helpers for view logic:
number_to_currency,time_ago_in_words,truncate, your own inapp/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
<%= %> 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.<%= user_bio %> showing literal <b> 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.
<%= 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 %><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
_formpartial for new + edit — the model binding handles the differences f.collection_select/f.check_boxetc. — 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 :namebut permitting:title— the silent unpermitted-param bug from topic 55
form_with model: infers URL, verb, param nesting, and button text from the record's persisted? state — new vs edit stops being two problems.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."
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<% 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
flashwith redirects;flash.nowwith renders — mixing them up is the classic double-message bugreset_sessionon 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
current_user ||= memoization means one DB hit per request no matter how many times views ask.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.
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{ 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
:idand:_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
:idin permitted params — every edit DUPLICATES the children instead of updating (the classic!)
: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.
<%# 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 %>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
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.
# 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<%= 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 } %>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_tofor feeds, notifications, dashboards — real-time without writing Action Cable code- Keep the
format.htmlfallback — 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)
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).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.
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.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)
}
}<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>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
valuesAPI 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
addEventListener code gets wrong (dead listeners, double-binding). The naming conventions make behavior greppable: see data-controller="clipboard", open clipboard_controller.js, done.copy_link_controller.js → data-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.
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).# 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 } }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 defaultas_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: @userraw — serializes EVERY column today and every column you add next year (that's how digests leak)- N+1s hidden in serializers —
include:withoutincludes()is topic 67 wearing a JSON hat
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.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.
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.# 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) }=> "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'tconfig.i18n.raise_on_missing_translations = truein 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
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.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.
# $ 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=> #<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_passwordfor most apps; Devise when you need its ecosystem (invitations, lockouts, omniauth) authenticate_by— constant-time, enumeration-resistant (vs find-then-authenticate)reset_sessionon 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
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.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?"
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? %>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_scopefor every index/list — filtering in SQL, not after loadingverify_authorizedguard — 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
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.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.
# $ 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=> 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: truefor 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)
with_attached_photo.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.
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)[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
set(wait:)), and queue priorities come free. Response times drop from seconds to milliseconds because the user never waits on the kitchen.after_save) and the worker can grab the job before your COMMIT lands — RecordNotFound 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.
deliver_later drops it at the post office (job queue — topic 94) instead of you driving it across town mid-request.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/mailersReceiptMailer#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_laterfrom 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)
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.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.
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.<%# 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 %>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
endRead 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: trueon collections Rails.cache.fetchwithexpires_infor aggregates and API responsestouch: trueup 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
[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.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.
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
endclass 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$ 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_differencefor 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
describe/it/expect vs class/test/assert — the skills transfer entirely.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.
html_safe, string-interpolated SQL, permit!.# 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)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_withhandles tokens invisibly has_secure_password+ bcrypt for auth (or the Rails 8 built-in auth generator)- Run
brakemanin CI — free static analysis for exactly these holes
❌ Don't — anti-patterns
- String interpolation into
where/order/pluck—order("#{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"
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.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.
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).$ 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
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
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
--sandboxfor 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 —debuggeris one word and infinitely faster- Un-sandboxed prod console mutations without a transaction wrapper — one typo'd
update_alland you're restoring backups (you know this pain from the DB side)
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.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.
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.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
endGET /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 StandardErrorin development too — you've hidden the stack traces you debug with (note theunless 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)
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.
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.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[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_parameterscovers params, not your custom lines) putsdebugging left in production code — it skips the logger's levels, tags, and formatting entirely
request_id correlation across web + job logs turns an hour of guessing into five minutes of grep.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.
# 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"$ 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
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.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.
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.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]$ 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 (
/upis 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
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.
# 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)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
Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.