Notes from Brandon Weaver’s Rails: The Sharp Parts series. Rails’ convenience features each have an edge that bites at scale. This note keeps the Rails-specific mechanics; the language-agnostic idea behind each one lives in its own note, linked per section.
Locking → Concurrency and Locks
ActiveRecord’s three “lock” methods are not equivalent:
Model.lock.find(id)— appendsFOR UPDATEto the SELECT, but gives zero protection outside a transaction. Bare, it acquires and releases in one breath.record.lock!— reloads an already-loaded record with a lock; raises if the record has unsaved changes.record.with_lock { … }— preferred: wraps the block in a transaction, so the boundary that makes the lock meaningful is impossible to forget.
Optimistic locking: add a lock_version column and Rails appends WHERE lock_version = ? to updates, raising ActiveRecord::StaleObjectError on
conflict — good for rare collisions where you’d rather not pay lock-wait. Watch
the hidden lock-scope expanders: association autosaves, counter caches, and
touch: true on a parent quietly pull more rows into your transaction. For
observability, query timers miss lock waits — read performance_schema.data_locks
/ information_schema.INNODB_TRX; Rails 7+ QueryLogTags traces a query back to
its source line.
Indexes → Indexes and Query Planning
ActiveRecord::Relation#explainshows the estimated plan;explain(:analyze)runs it and reports real timings. This is the source of truth, not the schema.- MySQL 8 lets you add an index
INVISIBLE, confirm the plan improves, then flip itVISIBLE— a safe rollout with no regression risk. ActiveRecord::QueryLogs(Rails 7+) tags queries with controller / action /source_locationso slow queries point back at the code that generated them.sys.schema_redundant_indexes/schema_unused_indexes/schema_tables_with_full_table_scansfind dead weight and missing indexes.
Callbacks → Constraints over Conventions
save isn’t atomic-and-simple; it threads the record through a compiled callback
chain, and the framework inserts its own (two trivial models with zero user
callbacks can still register eleven, via autosave et al.). The sharp edges:
- Paths that skip the chain entirely:
update_all,insert_all,update_column,delete_all, raw SQL. A backfill viaupdate_allsilently skips the callback that kept the search index in sync. - Validations are just chain entries — a
before_saverunning after validation can mutate an attribute back to something invalid and persist it. after_saveruns inside the transaction (an IO failure leaves orphaned effects), whileafter_commitfires once at the outermost commit — so where in the chain you hang side effects changes their guarantees.- The only callback that’s safe to keep is a pure transform of the record’s own
fields — e.g.
normalizes :email, with: ->(e) { e.strip.downcase }(Rails 7.1+). Everything cross-record, IO, or webhook-shaped belongs in an explicit command.
Enforcement Rails-side: Packwerk enforce_privacy to make the model unreachable
from outside its pack, RuboCop cops to flag mutations outside command files,
Flipper feature flags to retire a callback incrementally (guard legacy → mirror in
command → enable per-actor → delete). Deferred side effects go through a
transactional outbox.
Queries & read models → Read Models and Boundaries
The “live object” hazard: a returned Seat can be .update!-ed; a returned
relation (Seat.where(...)) is a writable handle a caller can .update_all on,
walking around your command. Mechanics for closing it:
- N+1 comes from per-row queries in a loop — collapse
SeatDetails-per-id into aSeatDetailsByIdsbatch returning a hash keyed by id; useincludes/preload/eager_loaddeliberately,find_eachfor large scans. pluck_hash/pick_hashhelpers onApplicationRecordreturn plain hashes instead of hydrating models (and refuse to run ifincludes/preloadare present, so they can’t drag associations along).- Cross a pack boundary as an immutable
T::Struct(Sorbetconstfields), never a live model — the return-type signature fails at the boundary if a model leaks. BatchLoaderdefers and coalesces lookups when you can’t collect keys up front (GraphQL, serializers); needs middleware to clear its per-request cache.
The 37signals counterpoint
Whether you should go this far is itself contested — the “vanilla Rails is plenty” camp argues disciplined callbacks and rich models scale further than this gives them credit for. That debate, and where I land, is in Vanilla Rails vs. the Sharp Parts.