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) — appends FOR UPDATE to 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#explain shows 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 it VISIBLE — a safe rollout with no regression risk.
  • ActiveRecord::QueryLogs (Rails 7+) tags queries with controller / action / source_location so slow queries point back at the code that generated them.
  • sys.schema_redundant_indexes / schema_unused_indexes / schema_tables_with_full_table_scans find 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 via update_all silently skips the callback that kept the search index in sync.
  • Validations are just chain entries — a before_save running after validation can mutate an attribute back to something invalid and persist it.
  • after_save runs inside the transaction (an IO failure leaves orphaned effects), while after_commit fires 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 a SeatDetailsByIds batch returning a hash keyed by id; use includes / preload / eager_load deliberately, find_each for large scans.
  • pluck_hash / pick_hash helpers on ApplicationRecord return plain hashes instead of hydrating models (and refuse to run if includes/preload are present, so they can’t drag associations along).
  • Cross a pack boundary as an immutable T::Struct (Sorbet const fields), never a live model — the return-type signature fails at the boundary if a model leaks.
  • BatchLoader defers 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.

Further reading