Overview
Idiomatic, production-grade Ruby 3.x: Bundler, the Ruby 3 language additions, blocks and closures, Rails, and RSpec/Minitest. Assumes competence; the value is in what is easy to get wrong (keyword-argument separation, proc return semantics, ||= on falsy values, N+1 queries, unsafe deserialization, the GVL) and in writing tests that loom's rspec and minitest adapters can select one at a time.
Tooling
Preserve the repository's version manager and lockfile. The Ruby version lives in .ruby-version (read by rbenv, chruby, asdf, mise) and in the Gemfile's ruby directive.
Bundler resolves dependencies and isolates the load path.
| Task | Command |
|---|---|
| Add a dependency | bundle add faraday (edits Gemfile, resolves, updates Gemfile.lock) |
| Add a dev/test dependency | bundle add rspec --group "development, test" |
| Install from the lock | bundle install; set BUNDLE_FROZEN=true in CI so drift fails the build |
| Update one gem | bundle update --conservative faraday (shared dependencies stay pinned) |
| Run inside the bundle | bundle exec rspec, or the binstubs bin/rails, bin/rspec |
| Known CVEs in the lock | bundle exec bundler-audit check --update |
| Deploy platforms | bundle lock --add-platform x86_64-linux |
bundle execactivates exactly the locked versions; a barerspecorrakemay load a different installed version.require "bundler/setup"does the same from inside a script.- A gem declares runtime dependencies in its
.gemspec; itsGemfileholdsgemspecplus development tools.
Format and lint: RuboCop (rubocop -a applies safe corrections; -A adds unsafe ones, so review that diff) or Standard (standardrb --fix, RuboCop with a fixed config). Rails 7.2+ generates bin/rubocop (rubocop-rails-omakase) and bin/brakeman. Plugins: rubocop-rspec, rubocop-performance, rubocop-rails. Disable a cop for a range with # rubocop:disable Style/Foo ... # rubocop:enable Style/Foo; never disable all cops for a file.
Types (gradual, optional): RBS signatures in sig/*.rbs checked by Steep, or Sorbet (# typed: strict, sig { params(id: Integer).returns(String) }, srb tc). Follow whichever the project uses; never add a second one.
The gate:
bundle exec rubocop # or: bundle exec standardrb
bundle exec srb tc # Sorbet projects only (Steep: bundle exec steep check)
bundle exec rspec # or: bin/rails test, bundle exec rake test
bundle exec brakeman --no-pager # Rails apps: static security scan
Ruby 3 Language Features
.ruby-version bounds what you may use.
| Version | Feature |
|---|---|
| 3.0 | Keyword arguments separated from positional hashes; case/in pattern matching; endless methods def sq(x) = x * x; Ractor (experimental); fiber scheduler interface |
| 3.1 | Hash shorthand {x:, y:}; anonymous block forwarding def f(&) = g(&); YJIT; Psych 4, so YAML.load is safe by default |
| 3.2 | Data.define; anonymous * and ** forwarding; Struct.new accepts keyword arguments without keyword_init: true; Fiber[] storage |
| 3.3 | Prism parser shipped; YJIT speedups; M:N thread scheduler (opt-in) |
| 3.4 | it as the implicit block parameter; Prism is the default parser; string literals are "chilled" (mutation warns under -W:deprecated); Hash#inspect prints {name: "x"} |
Keyword argument separation (3.0). A hash passed positionally no longer becomes keywords, and keywords no longer collapse into a trailing positional hash. Code that relied on the 2.x conversion raises ArgumentError. Splat explicitly, and delegate with ....
def connect(host, port: 5432, ssl: false) = open_socket(host, port, ssl)
opts = { port: 6543, ssl: true }
connect("db", opts) # ArgumentError on 3.x: opts is a second positional argument
connect("db", **opts) # keywords
def with_logging(...) = connect(...) # forwards positionals, keywords and the block
Pattern matching. case/in destructures arrays, hashes and any object implementing deconstruct/deconstruct_keys. With no matching branch and no else it raises NoMatchingPatternError, so an unhandled shape fails loudly. expr => pattern binds or raises; expr in pattern returns a boolean.
case response
in { status: 200, body: { items: [_, *] => items } }
process(items)
in { status: 404 }
nil
in { status: 500.. => code }
raise UpstreamError, "upstream returned #{code}"
end
config => { database: { host: String => host } } # binds host; a missing key raises NoMatchingPatternKeyError, a non-String host NoMatchingPatternError
Hash patterns match a subset of keys (**nil forbids extra keys); array patterns match the whole array unless a *rest is present. Pin a local with ^expected, an expression with ^(expr).
Value objects with Data.define (3.2). Immutable, keyword or positional construction, value equality, with for modified copies, pattern-matchable. Use it where older code used Struct for values that should never change; keep Struct for the rare case where mutability is the point.
Point = Data.define(:x, :y) do
def +(other) = with(x: x + other.x, y: y + other.y)
end
origin = Point.new(x: 0, y: 0)
moved = origin.with(y: 5) # origin is unchanged
Point.new(1) # ArgumentError: missing keyword: :y
Frozen string literals. Put # frozen_string_literal: true at the top of every file (RuboCop enforces it): literals are frozen and deduplicated, which saves allocations and turns accidental mutation into FrozenError. Build strings from +"" or String.new(capacity: n); str.dup returns an unfrozen copy.
Blocks, Procs and Lambdas
Block / proc {} |
lambda {} / ->(x) {} |
|
|---|---|---|
| Arity | lenient: missing arguments are nil, extras dropped, a lone array auto-splats |
strict: ArgumentError on mismatch |
return |
returns from the method that defined the block | returns from the lambda |
next |
ends this call of the block, with a value | same as return |
- A method takes a block implicitly (
yield,block_given?) or explicitly (&block, which allocates aProc; the anonymous&of 3.1 forwards without naming it). - Return an
Enumeratorwhen no block is given, so callers can chain:return enum_for(__method__) unless block_given?. &:namecallsSymbol#to_proc;&method(:parse)passes an existing method.- Blocks capture local variables by reference: a block stored and called later sees the current value and can reassign it.
it(3.4) and_1suit one-line blocks; name the parameter when a block spans lines.- ⚠
returninside aprocthat outlives its defining method raisesLocalJumpError. Store callbacks as lambdas.
def each_batch(size: 100)
return enum_for(__method__, size:) unless block_given?
offset = 0
loop do
rows = fetch(offset:, limit: size)
break if rows.empty?
yield rows
offset += size
end
end
each_batch.with_index { |rows, i| logger.info("batch #{i}: #{rows.size} rows") }
present = ->(value) { value.is_a?(String) && !value.empty? }
names.select(&present) # a lambda passed as the block
ids = users.map { it.id } # 3.4
Errors and Exceptions
- Bare
rescueandrescue => ecatchStandardErroronly. Neverrescue Exception: it also swallowsInterrupt,SystemExitandSignalException, so Ctrl-C andexitstop working. - Give a library one base error and a hierarchy under it. Callers rescue the base for everything or a subclass for one case.
- Raising inside a
rescuesetscauseautomatically, so the original stays reachable ase.cause. - Keep the
beginbody to the call that can fail. A method body is an implicitbegin:def x ... rescue ... ensure ... end. ensureruns on every exit path. Areturninsideensurediscards the in-flight exception; never write one.retryre-runs thebeginbody; always bound it with a counter.- ⚠
Timeout.timeoutraises into the block from another thread at an arbitrary point, which can leave connections, locks and files half-updated. Use the library's own timeouts (Net::HTTP#read_timeout, driverconnect_timeout, databasestatement_timeout).
module Spool
class Error < StandardError; end
class SymlinkError < Error; end
class FetchError < Error; end
end
def fetch_with_retry(url, attempts: 3)
tries = 0
begin
tries += 1
http_get(url)
rescue Net::OpenTimeout, Errno::ECONNRESET
retry if tries < attempts
raise Spool::FetchError, "fetch failed after #{tries} attempts: #{url}" # original is #cause
end
end
Concurrency
- The GVL lets one thread run Ruby code at a time (per Ractor). Threads help IO-bound work because blocking IO and
sleeprelease the GVL; they give no speedup for CPU-bound Ruby. For CPU parallelism use processes (Puma workers, Sidekiq processes,Process.fork) or a native extension. - Puma runs
workers(processes) timesthreads. Class-level and global mutable state is shared by every thread in a worker: memoized class instance variables,@@cache ||= {}, a constant holding a mutable hash. Guard withMutex#synchronize, useConcurrent::Mapfrom concurrent-ruby, or keep the state per request. - Fibers: under a fiber scheduler (the
asyncgem, Falcon) stdlib IO,sleepandNet::HTTPyield instead of blocking, so one thread serves many IO-bound tasks. - Ractors give real parallelism with isolated objects, but remain experimental and most gems are not Ractor-safe. Keep them out of application code.
- Background jobs go through ActiveJob backends (Sidekiq, Solid Queue, GoodJob). Arguments must be serializable primitives or GlobalID records, and every backend retries, so jobs must be idempotent.
class RateCache
def initialize
@lock = Mutex.new
@rates = {}
end
def fetch(currency)
@lock.synchronize { @rates[currency] ||= load_rate(currency) } # check and set under one lock
end
end
Rails Essentials
- Autoloading (Zeitwerk): paths must match constant names (
app/services/spool/writer.rbdefinesSpool::Writer). Neverrequirean autoloaded file;bin/rails zeitwerk:checkvalidates the tree. - N+1 queries:
includeslets Rails choose,preloadruns separate queries,eager_loaduses oneLEFT JOIN(needed to filter on the association).strict_loadingon a model, association or relation raises on lazy loads; thebulletgem reports them in development. - Large tables:
find_each/in_batches;Model.all.eachloads every row into memory. - Strong parameters:
params.expect(user: [:name, :email])(Rails 8) orparams.require(:user).permit(:name, :email). Neverpermit!, and never pass rawparamstocreate/update. - Callbacks: keep them to the model's own invariants (normalization, derived columns).
after_saveruns inside the transaction, so an email or job triggered there can run before the commit or for a row that is rolled back; useafter_commit/after_create_commitor a service object for side effects. - Bypasses:
update_column(s),update_all,insert_allanddelete_allskip validations and callbacks.validates :email, uniqueness: truealone races; back it with a unique index. - Transactions:
ActiveRecord::Base.transaction do ... endrolls back on any exception;raise ActiveRecord::Rollbackrolls back without propagating. - Secrets:
bin/rails credentials:edit(encrypted) or environment variables; never plaintext in the repository. - Rails 7.1+:
normalizes,generates_token_for,authenticate_by(timing-safe credential lookup). Rails 8 adds the authentication generator and Solid Queue/Cache/Cable defaults.
class Order < ApplicationRecord
belongs_to :customer
has_many :line_items, dependent: :destroy
normalizes :reference, with: ->(ref) { ref.strip.upcase }
validates :reference, presence: true
after_create_commit -> { OrderMailer.confirmation(self).deliver_later }
scope :recent, -> { where(created_at: 7.days.ago..) }
end
Order.recent.includes(:customer, :line_items).find_each do |order|
export(order) # no per-row customer or line-item queries
end
Testing
RSpec.
# spec/spool/writer_spec.rb
RSpec.describe "Spool::Writer" do
subject(:writer) { Spool::Writer.new(root:) }
let(:root) { Pathname(Dir.mktmpdir) }
after { FileUtils.remove_entry(root) }
it "writes the payload into the spool directory" do
writer.write("job-1", "payload")
expect(root.join("job-1").read).to eq("payload")
end
it "rejects a symlinked spool directory" do
target = Pathname(Dir.mktmpdir)
link = root.join("spool")
File.symlink(target, link)
expect { Spool::Writer.new(root: link).write("job-1", "payload") }
.to raise_error(Spool::SymlinkError, /symlink/)
expect(target.join("job-1")).not_to exist
ensure
FileUtils.remove_entry(target) if target
end
end
letis lazy and memoized per example;let!runs before every example. Preferlet; alet!that nothing reads is hidden setup.- Verified doubles (
instance_double(Spool::Store, put: true)) fail when the stubbed method does not exist on the real class or is called with the wrong arity; a plaindoublechecks nothing. - Always name the class in
raise_error: a bareraise_errorpasses on theNoMethodErrorfrom a typo. aggregate_failuresreports every failed expectation in an example instead of stopping at the first.- Run in random order (
config.order = :randomplusKernel.srand config.seed); reproduce with--seed N;rspec --bisectfinds the minimal order-dependent pair. .rspecholds default flags (--require spec_helper). Rails apps userails_helperwithconfig.use_transactional_fixtures = true.
Minitest.
# test/spool/writer_test.rb
require "test_helper"
class SpoolWriterTest < Minitest::Test
def setup
@root = Pathname(Dir.mktmpdir)
end
def teardown
FileUtils.remove_entry(@root)
end
def test_writes_payload_into_spool_directory
Spool::Writer.new(root: @root).write("job-1", "payload")
assert_equal "payload", @root.join("job-1").read
end
end
assert_equal expected, actual: expected first, or the failure message reads backwards. Useassert_nilfor nil;assert_equal nil, xis deprecated.assert_raises(Spool::SymlinkError) { ... }returns the exception, so assert on its message afterwards.minitest/mock:Object#stubreplaces a method for the block;Minitest::Mock#expectplusverifychecks calls.- Minitest randomizes order by default;
--seed Nreproduces a run. - Rails:
ActiveSupport::TestCasewithtest "writes the payload" do(definestest_writes_the_payload), YAML fixtures intest/fixtures,parallelize(workers: :number_of_processors),bin/rails test test/models/order_test.rb:12.
Loom Test Runner Adapter
Adapter. Two adapters cover Ruby; loom project detect prints the one chosen for each package (kind ruby, marker Gemfile):
rspecwhen.rspecexists orGemfilenamesrspec;minitestotherwise.
Single-test command. Loom runs it with the package directory as cwd:
# rspec, when a Gemfile exists
bundle exec rspec {file} -e '{test}'
# rspec, without a Gemfile
rspec {file} -e '{test}'
# minitest
ruby -Itest {file} -n '/^{test}$/'
The test field.
rspec: the example's full description, the group descriptions and the example description joined by spaces. For the spec above:Spool::Writer rejects a symlinked spool directory. When the parent group describes a class (RSpec.describe Spool::Writer), a nested description starting with#,.or::joins without the space. Copy the value from the runner's output: the failed-examples list prints it after#.minitest: the method name, e.g.test_rejects_symlinked_spool_directory.
No-match behaviour.
minitestexits 0 when-nmatches nothing (captured fixture:0 runs, 0 assertions, 0 failures, 0 errors, 0 skips).rspecis documented: no fixture was captured, and loom's parser was written from RSpec's documented output (N examples, M failures). An-ethat matches nothing reports0 examples, 0 failuresand, by default, exits 0.
Loom reads the runner's summary, so zero executed tests is NotSelected whatever the exit code: a contract whose test value does not match fails the freeze ("the runner did not select the test").
Writing contract tests.
- Keep RSpec contracts at
spec/**/*_spec.rband Minitest contracts attest/**/*_test.rb, the conventional locations. Loom maps a file to its language profile by extension and test-file glob. rspec -eselects every example whose full description contains the value (RSpec escapes it, so punctuation is literal). Make the contract's description unique in the file and not a substring of another example's ("rejects a symlink" also selects "rejects a symlink loop").minitestanchors the value as a regular expression. Write contracts as plaindef test_snake_casemethods: minitest/specit "..."blocks compile totest_0001_...names whose number shifts when an example is added above, and Railstest "..."names keep punctuation the regex would interpret.- Loom single-quotes the value on the command line; keep single quotes out of contract names.
- ⚠ A spec file that raises while loading (a
requireof a file that does not exist yet,RSpec.describe Spool::Writerbefore the constant exists) is reported as an error outside of examples with0 examples, which can read as a test that was never selected. Describe contract groups with a string and reference new constants inside the example, so a missing class raises in the example and counts as a failure. Apply the same rule to Minitest: name the test class without the new namespace (SpoolWriterTest). - The
minitestcommand runs withoutbundle exec:test/test_helper.rbmust putlibon the load path andrequire "bundler/setup"when the tests need locked gems. Rails'test_helperdoes this throughconfig/environment.
contracts:
- id: rejects-symlinked-spool
file: spec/spool/writer_spec.rb
test: "Spool::Writer rejects a symlinked spool directory"
scenario: points the spool root at a temp directory through a symlink and calls Writer#write
rejects: a writer that resolves the symlink and writes job-1 into the link target
The Minitest form of the same contract uses file: test/spool/writer_test.rb and test: test_rejects_symlinked_spool_directory. Quote YAML values that contain : or #.
Build failures. Ruby has no compile step, so a contract never reports BuildFailed; it must fail inside the test at freeze time (see the load-error rule above). A Minitest file that cannot load prints no summary: loom treats the run as unparsed and requires a non-zero exit.
For test-impact runs loom builds one command over several files: rspec f1 f2, or ruby -Itest per file joined by &&.
Anti-Patterns
# ||= memoization recomputes whenever the cached value is nil or false
def admin?
@admin ||= role_lookup == :admin # BAD: re-queries on every call for non-admins
end
def admin?
return @admin if defined?(@admin) # GOOD: caches false and nil too
@admin = role_lookup == :admin
end
# Shared default objects
Hash.new([]) # BAD: every missing key shares ONE array
Hash.new { |hash, key| hash[key] = [] } # GOOD: a new array per key
Array.new(3, []) # BAD: three references to one array
Array.new(3) { [] } # GOOD
# Mutable constants are shared by every caller and every thread
DEFAULTS = { retries: 3 } # BAD: one DEFAULTS.merge!(x) changes it everywhere
DEFAULTS = { retries: 3 }.freeze # GOOD: merge! raises FrozenError; merge copies
Quick swaps:
rescue Exception⇒rescue StandardErroror the specific class.send(params[:op])⇒public_sendagainst an allowlist of method names.method_missingalone ⇒ pair it withrespond_to_missing?, or define the methods withdefine_method.x = a or b(assignsa;or/andbind looser than=) ⇒x = a || b.where("name = '#{name}'")⇒where(name:)orwhere("name = ?", name).Time.nowin Rails ⇒Time.current(respectsconfig.time_zone).unless ... else⇒ifwith the branches swapped.- Reopening core classes globally ⇒ a refinement (
using) or a wrapper object. - Leftover
binding.irb,debugger,putsdebugging ⇒ remove before commit (RuboCopLint/Debugger). - Float for money ⇒ integer cents or
BigDecimal.
Ruby evaluates default arguments on every call, so Python's mutable-default trap does not exist; the shared-object traps above are the Ruby equivalent.
Expert Practices
Idioms
Hash#fetch(:key)for required keys (KeyErrornames the key);[]returnsniland moves the failure somewhere else.digfor optional nested reads.Integer("42")raises on garbage;"abc".to_ireturns0silently.Integer(value, exception: false)returnsnilinstead.- Include
Comparablewith<=>, orEnumerablewitheach, to get the whole protocol. - Define
eql?andhashtogether (or useData) for objects used as Hash keys. filter_map,each_with_object,tally,sum,partition,each_slice,zip,group_by,min_by: reach for them before a manual accumulator loop.Enumerator::Lazy(each_line.lazy.select(...).first(10)) for large or infinite streams.privatedoes not apply todef self.x; useprivate_class_method :xor aclass << selfblock.
Gotchas
Thread.current[:key]is fiber-local. Under a fiber scheduler each fiber sees its own value;Thread.current.thread_variable_getis thread-wide, andFiber[:key](3.2) is inherited by child fibers.freezeis shallow: a frozen array's elements stay mutable. Deep-freeze constants built from nested literals, or build them fromDatavalues.- Integer division truncates:
7 / 2 == 3; use7.fdiv(2)or a float operand. array += otherinside a loop allocates a new array on every pass; useconcator<<, or build the result withflat_map.superwithout parentheses forwards the current method's arguments;super()forwards none. Mixing them up passes unexpected arguments to the parent.- Closures keep captured objects alive: a
define_methodblock that captures a large local retains it for the life of the class.
Performance
- Enable YJIT in production (
RUBY_YJIT_ENABLE=1or--yjit); Rails 7.2+ turns it on by default when running Ruby 3.3+. - ActiveRecord:
pluck(:id)orselect(:id, :name)instead of loading full models;exists?overpresent?on a relation (present?loads every row);sizeon a loaded association avoids aCOUNTquery,countalways issues one. - Build strings with
<<on a mutable buffer;+=allocates a new string each iteration. - Profile before tuning:
stackproforvernierfor CPU,memory_profilerfor allocations,benchmark-ipsfor micro-benchmarks,rack-mini-profilerfor requests.
Security
Marshal.loadandYAML.unsafe_loadon untrusted bytes are remote code execution.YAML.loadis safe from Psych 4 (Ruby 3.1) on; passpermitted_classes:explicitly when you need more than primitives.Kernel#open("|cmd")runs a shell command. UseFile.openorURI.openfor the specific need.- Shell out with an argument list:
system("tar", "-xf", path)andOpen3.capture3("git", "log", ref)bypass the shell; the single-string forms go through/bin/sh. constantize,send,public_sendandorder(params[:sort])on user input need an allowlist.- Views: ERB escapes output by default;
raw,html_safeand<%==bypass it. Usesanitizefor user HTML. - Compare secrets with
ActiveSupport::SecurityUtils.secure_compareorRack::Utils.secure_compare; generate them withSecureRandom. - User-supplied paths:
File.expand_path(path, root)then check the result starts with the root;Pathname#realpathresolves symlinks before the check. - Run
brakemanandbundler-auditin CI.
Verification Checklists
Before marking Ruby work done:
-
bundle exec rubocop(orstandardrb) clean; every disable is scoped to a range and names the cop - Type checker clean where the project uses one (
srb tc,steep check) -
bundle exec rspec/bin/rails testgreen; new behaviour has tests with named error classes inraise_error/assert_raises -
# frozen_string_literal: truein new files; norescue Exception; no bareraise_error - Keyword arguments passed explicitly (
**opts); delegation uses... - Dependencies added with
bundle add, never by hand-editingGemfile.lock - No
Marshal.load/YAML.unsafe_loadon external data; shell-outs use argument lists - No string-interpolated SQL; strong parameters on every write path
Rails review:
- No N+1 on new list pages or jobs (
includes/preload, orstrict_loadingpasses) - Side effects run from
after_commit(after_saveruns inside the transaction); jobs idempotent with serializable arguments - Uniqueness validations backed by a unique index; bulk writes that skip validations are deliberate
-
bin/rails zeitwerk:checkpasses;brakemanreports nothing new
Contract tests for loom:
-
testvalue copied from the runner's output (RSpec full description or Minitest method name), unique in its file, no single quotes - Contract spec groups described by string; new constants referenced only inside examples
- The contract fails before the implementation for the reason its
rejectsfield names