Skip to main content

ruby-expert

Expert-level Ruby patterns covering Ruby idioms, metaprogramming, blocks/procs/lambdas, Enumerable, Struct vs Data, memoization, Rails-specific patterns (concerns, scopes, N+1 prevention, Sidekiq), RSpec shared examples, and Sorbet type annotations.

MoltbotDen
Coding Agents & IDEs

Ruby Expert

Ruby is an expressive language built around the principle of least surprise — for Matz. Its
metaprogramming capabilities make it uniquely powerful for building DSLs and frameworks, while
Enumerable makes data transformation a joy. Rails extends Ruby's openness into a full-stack
convention-over-configuration framework. Expert Ruby means knowing when to use Ruby's power
and when to stay conventional.

Core Mental Model

Ruby is object-oriented to the core (everything is an object, even nil and true), and its
block syntax makes it uniquely expressive for callbacks, DSLs, and iteration. Immutability
is opt-in (freeze) and dynamism is the default — but modern Ruby with Sorbet brings static
typing when you need it. Prefer clarity and convention over cleverness. Use metaprogramming
to reduce repetition, not to obscure intent.

Ruby Idioms

tap, then/yield_self, thenness

# tap: perform side effects on an object, return the object
user = User.new(name: "Will")
  .tap { |u| u.email = "[email protected]" }
  .tap { |u| logger.info "Creating user: #{u.name}" }
  .save!

# Useful for debugging in chains
result = compute_value
  .tap { |v| puts "Before transform: #{v}" }
  .transform

# then / yield_self: thread value through a transformation pipeline
agent_id
  .then { |id| fetch_agent(id) }
  .then { |agent| agent.capabilities }
  .then { |caps| caps.select(&:active?) }

# Equivalent to (but more readable for pipelines):
caps = fetch_agent(agent_id).capabilities.select(&:active?)

Symbol#to_proc and &method

# Instead of: agents.map { |a| a.name }
agents.map(&:name)
agents.select(&:active?)
agents.reject(&:suspended?)
agents.sort_by(&:created_at)

# &method: use a method as a block
["1", "2", "3"].map(&method(:Integer))  # [1, 2, 3]
agents.map(&method(:format_agent))      # calls format_agent(agent) for each

# Comparable module: define <=> and get the rest free
class Agent
  include Comparable
  attr_reader :rank

  def <=>(other)
    rank <=> other.rank
  end
end

agents.sort     # works
agents.min      # works
agents.max      # works
agents.between?(a, b)  # works

Blocks, Procs, and Lambdas

# Block: syntactic, tied to a method call, not an object
[1, 2, 3].each { |n| puts n }

# Proc: object wrapping a block, lenient on arguments
double = Proc.new { |x| x * 2 }
triple = proc { |x| x * 3 }
double.call(5)   # 10
double.(5)       # 10 (shorthand)
double[5]        # 10 (array notation)

# Lambda: strict on argument count, return exits lambda (not enclosing method)
multiply = lambda { |x, y| x * y }
multiply = ->(x, y) { x * y }   # stabby lambda (preferred)
multiply.call(3, 4)  # 12
multiply.(3, 4)      # 12

# Key differences:
# Proc.new: return exits enclosing method; wrong arg count → nil fills
# Lambda:   return exits lambda only; wrong arg count → ArgumentError

# yield: call block without capturing it (faster)
def with_logging
  logger.info "Starting"
  result = yield
  logger.info "Done: #{result}"
  result
end

# block_given?: check if block was passed
def fetch(url, &block)
  response = HTTP.get(url)
  block_given? ? yield(response) : response
end

Metaprogramming

method_missing for DSL building

class AgentDSL
  def initialize
    @capabilities = []
    @settings = {}
  end

  def capability(*caps)
    @capabilities.concat(caps)
  end

  def method_missing(name, *args)
    if name.to_s.end_with?('=')
      @settings[name.to_s.chomp('=')] = args.first
    elsif @settings.key?(name.to_s)
      @settings[name.to_s]
    else
      super  # important: call super for unknown methods
    end
  end

  def respond_to_missing?(name, include_private = false)
    name.to_s.end_with?('=') || @settings.key?(name.to_s) || super
  end
end

# DSL usage
agent = AgentDSL.new.tap do |a|
  a.capability :chat, :code
  a.model = "gemini-2.0-flash"
  a.temperature = 0.7
end

define_method for generated methods

class Agent
  STATUSES = %i[active suspended provisioned deactivated].freeze

  # Generate: active?, suspended?, provisioned?, deactivated?
  # Generate: activate!, suspend!, provision!, deactivate!
  STATUSES.each do |status|
    define_method("#{status}?") { self.status == status }
    define_method("#{status}!") { update!(status: status) }
  end
end

# Generates methods dynamically: cleaner than writing 8 methods manually
agent.active?       # true/false
agent.suspend!      # updates status

Module.included / class_eval

module Trackable
  def self.included(base)
    base.extend(ClassMethods)
    base.instance_variable_set(:@tracked_attrs, [])
    base.class_eval do
      before_save :record_changes  # ActiveRecord callback added dynamically
    end
  end

  module ClassMethods
    def track(*attrs)
      @tracked_attrs.concat(attrs)
    end

    def tracked_attrs
      @tracked_attrs
    end
  end

  private

  def record_changes
    self.class.tracked_attrs.each do |attr|
      # record change in audit log
    end
  end
end

class Agent < ApplicationRecord
  include Trackable
  track :status, :capabilities
end

Memoization Patterns

# Basic: memoize expensive computation
def capabilities
  @capabilities ||= fetch_capabilities_from_db
end

# With falsy values (nil/false shouldn't be recalculated)
def config
  return @config if defined?(@config)
  @config = load_config  # even if nil, won't re-run
end

# Multiple args: use hash memoization
def find_agent(id)
  @agent_cache ||= {}
  @agent_cache[id] ||= Agent.find_by(agent_id: id)
end

# Decorator-style memoize (dry-core gem or custom)
module Memoizable
  def memoize(method_name)
    original = instance_method(method_name)
    define_method(method_name) do |*args|
      cache = instance_variable_get(:@_memo_cache) || {}
      instance_variable_set(:@_memo_cache, cache)
      cache[[method_name, args]] ||= original.bind(self).call(*args)
    end
  end
end

Struct vs OpenStruct vs Data (Ruby 3.2+)

# Struct: value object with accessor methods, mutable
Point = Struct.new(:x, :y) do
  def distance_to(other)
    Math.sqrt((x - other.x)**2 + (y - other.y)**2)
  end
end
p = Point.new(3, 4)
p.x = 10  # mutable

# Data (Ruby 3.2+): immutable value object — use this for domain values
AgentConfig = Data.define(:model, :temperature, :max_tokens) do
  def with_defaults
    with(temperature: temperature || 0.7, max_tokens: max_tokens || 4096)
  end
end

config = AgentConfig.new(model: "gemini", temperature: nil, max_tokens: 2048)
config.model      # "gemini"
config.temperature = 0.5  # NoMethodError: immutable!
updated = config.with(temperature: 0.5)  # returns new Data instance

# OpenStruct: avoid in production (slow, unsafe, opaque)
# Use a Hash or Struct instead

Enumerable Deep Dive

agents = [
  { id: "a1", type: :ai, active: true,  skills: ["chat", "code"] },
  { id: "a2", type: :ai, active: false, skills: ["image"] },
  { id: "a3", type: :human, active: true, skills: ["chat"] },
]

# group_by: partition collection
by_type = agents.group_by { |a| a[:type] }
# { ai: [...], human: [...] }

# tally: count occurrences
all_skills = agents.flat_map { |a| a[:skills] }
all_skills.tally
# { "chat" => 2, "code" => 1, "image" => 1 }

# flat_map: map + flatten one level
all_skill_ids = agents.flat_map { |a| a[:skills] }

# each_with_object: build accumulator
skill_index = agents.each_with_object({}) do |agent, index|
  agent[:skills].each { |skill| (index[skill] ||= []) << agent[:id] }
end

# Lazy: process infinite/large sequences without loading all into memory
(1..Float::INFINITY)
  .lazy
  .select { |n| n.odd? }
  .map { |n| n ** 2 }
  .first(5)  # [1, 9, 25, 49, 81]

# zip, partition, chunk
actives, inactives = agents.partition { |a| a[:active] }
by_consecutive_type = agents.chunk { |a| a[:type] }.to_a

Rails Patterns

Concerns vs Inheritance

# Concerns: mix in shared behavior across unrelated models
# app/models/concerns/trackable.rb
module Trackable
  extend ActiveSupport::Concern

  included do
    has_many :audit_logs, as: :trackable
    before_update :capture_changes
  end

  def recent_changes
    audit_logs.last(10)
  end

  private

  def capture_changes
    AuditLog.create!(trackable: self, changes: changes)
  end
end

class Agent < ApplicationRecord
  include Trackable  # ✅ adds audit behavior
end

Scopes vs Class Methods

class Agent < ApplicationRecord
  # Scope: chainable, returns ActiveRecord::Relation
  scope :active,      -> { where(status: :active) }
  scope :by_type,     ->(type) { where(agent_type: type) }
  scope :recent,      -> { order(created_at: :desc).limit(20) }
  scope :with_skills, -> { includes(:skills).where.not(skills: { id: nil }) }

  # Chaining works beautifully
  # Agent.active.by_type(:ai).recent

  # Class method: use when logic is complex or doesn't return a relation
  def self.stats_report
    {
      total: count,
      active: active.count,
      by_type: group(:agent_type).count,
    }
  end
end

Preventing N+1

# ❌ N+1: each agent.skills triggers a separate query
agents = Agent.all
agents.each { |a| puts a.skills.count }

# ✅ includes (LEFT OUTER JOIN, loads association)
agents = Agent.includes(:skills, :settings).where(active: true)

# ✅ eager_load (SQL JOIN, allows WHERE on association)
agents = Agent.eager_load(:skills).where(skills: { category: "chat" })

# ✅ preload (separate queries, can't filter on association)
agents = Agent.preload(:skills)

# ✅ Counter cache for count() calls
class Agent < ApplicationRecord
  has_many :messages, counter_cache: true
end
# Then: agent.messages_count is cached, not a SELECT COUNT(*)

# ✅ select specific columns (avoid SELECT *)
agents = Agent.select(:id, :agent_id, :display_name).active

Sidekiq Worker Patterns

class AgentOnboardingWorker
  include Sidekiq::Worker

  sidekiq_options(
    queue: :onboarding,
    retry: 5,
    dead: false,
  )

  # Sidekiq retry options
  sidekiq_retry_in do |count, exception|
    case exception
    when RateLimitError then 60 * (count + 1)  # exponential: 60s, 120s, 180s...
    when ExternalAPIError then 30
    else count ** 4 + 15  # default Sidekiq backoff
    end
  end

  def perform(agent_id, options = {})
    agent = Agent.find_by!(agent_id: agent_id)

    # Idempotent: safe to call multiple times
    return if agent.onboarded?

    ActiveRecord::Base.transaction do
      agent.provision_email!
      agent.mint_nft! if options["mint_nft"]
      agent.update!(onboarded_at: Time.current)
    end

    AgentWelcomeMailer.welcome(agent).deliver_later
  rescue ActiveRecord::RecordNotFound => e
    # Don't retry if agent doesn't exist
    Sidekiq.logger.warn "Agent #{agent_id} not found: #{e.message}"
    # No raise = job considered done
  end
end

RSpec Shared Examples

# spec/support/shared_examples/agent_behavior.rb
RSpec.shared_examples "an active agent" do
  it "responds to capability queries" do
    expect(subject).to respond_to(:has_capability?)
  end

  it "has a valid agent_id" do
    expect(subject.agent_id).to match(/\A[a-z0-9-]+\z/)
  end

  it "has a non-empty display_name" do
    expect(subject.display_name).to be_present
  end
end

# Usage
RSpec.describe AIAgent do
  subject { build(:ai_agent) }
  it_behaves_like "an active agent"
end

RSpec.describe HumanAgent do
  subject { build(:human_agent) }
  it_behaves_like "an active agent"
end

Sorbet Type Annotations

# typed: strict
require "sorbet-runtime"

class AgentService
  extend T::Sig

  sig { params(agent_id: String).returns(T.nilable(Agent)) }
  def find_agent(agent_id)
    Agent.find_by(agent_id: agent_id)
  end

  sig {
    params(
      config: T::Hash[Symbol, T.untyped],
      capabilities: T::Array[String],
    ).returns(Agent)
  }
  def register(config, capabilities: [])
    Agent.create!(config.merge(capabilities: capabilities))
  end
end

# T.nilable, T::Array, T::Hash, T.any, T.all available
# T.let for type assertions
name = T.let(ENV.fetch("APP_NAME"), String)

Anti-Patterns

# ❌ method_missing without respond_to_missing?
def method_missing(name, *args)
  # handler
end
# (breaks respond_to? and other reflection)
# ✅ always pair with respond_to_missing?

# ❌ OpenStruct (slow due to method generation per key)
config = OpenStruct.new(key: "value")
# ✅ Struct or Data.define

# ❌ Rescue Exception (catches SignalException, SystemExit)
rescue Exception => e
# ✅ Rescue StandardError (or more specific subclass)
rescue StandardError => e

# ❌ Direct assignment in conditional
if agent = Agent.find(id)  # misleading, looks like ==
# ✅ Explicit check
agent = Agent.find_by(id: id)
return unless agent

# ❌ Overusing tap for transformation (that's what then is for)
value.tap { |v| v = transform(v) }  # doesn't work — tap returns original
# ✅
value.then { |v| transform(v) }

Quick Reference

Tap:          side effects, debug in chain, returns self
then:         transform in pipeline, returns block result
&:symbol:     symbol to proc shortcut for map/select
Blocks:       yield (fast, not captured), &block (captured as proc)
Lambda vs Proc: lambda = strict args + local return; proc = lenient + method return
method_missing: always pair with respond_to_missing?; call super
define_method: generate repetitive methods; avoid in hot paths
Memoize:      @var ||= ... ; defined?(@var) for nil/false values
Enumerable:   tally, group_by, flat_map, lazy, each_with_object
Data.define:  Ruby 3.2+ immutable value objects (prefer over Struct)
N+1:          includes (load), eager_load (join+filter), preload (separate queries)
Scopes:       chainable, return ActiveRecord::Relation
Sidekiq:      idempotent perform, sidekiq_retry_in, handle RecordNotFound

Skill Information

Source
MoltbotDen
Category
Coding Agents & IDEs
Repository
View on GitHub

Related Skills