| name | ruby-patterns |
| description | Ruby/Rails: blocks, metaprogramming, ActiveRecord, Sidekiq, RSpec, Sorbet, Hanami. Triggers: Ruby, Rails, ActiveRecord, Sidekiq, RSpec, Gemfile, bundler, Hanami, Sorbet. |
| effort | medium |
| user-invocable | false |
| allowed-tools | Read |
Ruby Patterns
Project Structure
Gem Layout
my_gem/
āāā lib/
ā āāā my_gem.rb # Entry point, require sub-files
ā āāā my_gem/
ā āāā version.rb
ā āāā configuration.rb
ā āāā client.rb
ā āāā errors.rb
āāā spec/
ā āāā spec_helper.rb
ā āāā my_gem/
ā ā āāā client_spec.rb
ā āāā fixtures/
āāā bin/
ā āāā console # IRB with gem loaded
āāā sig/ # RBS type signatures
āāā Gemfile
āāā Rakefile
āāā my_gem.gemspec
āāā .rubocop.yml
āāā .ruby-version
Rails Standard Structure
app/
āāā controllers/
ā āāā application_controller.rb
ā āāā api/v1/
ā āāā users_controller.rb
āāā models/
ā āāā application_record.rb
ā āāā user.rb
ā āāā concerns/
ā āāā searchable.rb
āāā services/
ā āāā users/
ā āāā create_service.rb
ā āāā import_service.rb
āāā jobs/
ā āāā user_sync_job.rb
āāā mailers/
āāā serializers/
ā āāā user_serializer.rb
āāā views/
config/
āāā routes.rb
āāā database.yml
āāā initializers/
ā āāā sidekiq.rb
ā āāā cors.rb
āāā environments/
db/
āāā migrate/
āāā schema.rb
āāā seeds.rb
spec/
āāā rails_helper.rb
āāā spec_helper.rb
āāā models/
āāā requests/
āāā services/
āāā factories/
ā āāā users.rb
āāā support/
āāā shared_examples/
Gemfile Best Practices
source "https://rubygems.org"
ruby "~> 3.3"
gem "rails", "~> 7.2"
gem "pg"
gem "puma", ">= 6.0"
gem "sidekiq", "~> 7.0"
gem "redis", ">= 5.0"
group :development, :test do
gem "rspec-rails"
gem "factory_bot_rails"
gem "faker"
gem "debug"
gem "rubocop-rails", require: false
gem "rubocop-rspec", require: false
end
group :test do
gem "shoulda-matchers"
gem "webmock"
gem "vcr"
gem "simplecov", require: false
end
Idioms / Code Style
Blocks, Procs, and Lambdas
def with_retry(attempts: 3)
attempts.times do |i|
return yield
rescue StandardError => e
raise if i == attempts - 1
sleep(2**i)
end
end
with_retry { http_client.get("/data") }
validator = Proc.new { |val| val.to_s.strip.length > 0 }
transform = ->(x) { x.to_s.downcase.strip }
words = ["Hello ", " WORLD"].map(&transform)
names = users.map(&:name)
valid = values.select(&method(:valid?))
Modules and Mixins
module Searchable
extend ActiveSupport::Concern
included do
scope :search, ->(query) {
where("name ILIKE ?", "%#{sanitize_sql_like(query)}%")
}
end
class_methods do
def searchable_columns
%i[name email]
end
end
end
module Loggable
def logger
@logger ||= Logger.new($stdout, progname: self.class.name)
end
def log_info(msg) = logger.info(msg)
def log_error(msg) = logger.error(msg)
end
method_missing with respond_to_missing?
class Config
def initialize(data = {})
@data = data
end
def method_missing(name, *args)
key = name.to_s.chomp("=").to_sym
if name.to_s.end_with?("=")
@data[key] = args.first
elsif @data.key?(key)
@data[key]
else
super
end
end
def respond_to_missing?(name, include_private = false)
@data.key?(name.to_s.chomp("=").to_sym) || super
end
end
Frozen String Literals
Pattern Matching (Ruby 3+)
case response
in { status: 200, body: { data: Array => items } }
process_items(items)
in { status: 200, body: { data: Hash => item } }
process_item(item)
in { status: 404 }
raise NotFoundError
in { status: (500..) }
raise ServerError, response[:body]
end
case users
in [*, { role: "admin", name: String => admin_name }, *]
puts "Found admin: #{admin_name}"
end
expected_status = 200
case response
in { status: ^expected_status }
handle_success(response)
end
Enumerable Idioms
active_emails = users
.select(&:active?)
.reject { |u| u.email.nil? }
.map(&:email)
.uniq
.sort
users.group_by(&:role)
users.tally_by(&:role)
orders.sum(&:total)
scores.filter_map { |s| s.value if s.valid? }
users.each_with_object({}) do |user, memo|
memo[user.id] = user.name
end
Error Handling
begin/rescue/ensure
def fetch_user(id)
user = api_client.get("/users/#{id}")
User.new(user)
rescue Faraday::TimeoutError => e
logger.warn("Timeout fetching user #{id}: #{e.message}")
nil
rescue Faraday::ClientError => e
raise if e.response_status != 404
nil
rescue StandardError => e
logger.error("Unexpected error: #{e.class} - #{e.message}")
raise
ensure
api_client.close if api_client
end
Custom Exceptions
module MyApp
class Error < StandardError; end
class NotFoundError < Error
attr_reader :resource, :id
def initialize(resource:, id:)
@resource = resource
@id = id
super("#{resource} not found: #{id}")
end
end
class ValidationError < Error
attr_reader :errors
def initialize(errors)
@errors = errors
super(errors.join(", "))
end
end
class RateLimitError < Error
attr_reader :retry_after
def initialize(retry_after:)
@retry_after = retry_after
super("Rate limited. Retry after #{retry_after}s")
end
end
end
Retry with Backoff
def with_retries(max: 3, base_delay: 0.5, errors: [StandardError])
attempts = 0
begin
attempts += 1
yield
rescue *errors => e
raise if attempts >= max
delay = base_delay * (2**(attempts - 1)) + rand(0.0..0.5)
sleep(delay)
retry
end
end
with_retries(max: 5, errors: [Net::OpenTimeout, Faraday::TimeoutError]) do
api_client.post("/webhook", payload)
end
Dry::Monads (Railway-oriented)
require "dry/monads"
class CreateUser
include Dry::Monads[:result, :do]
def call(params)
values = yield validate(params)
user = yield persist(values)
yield send_welcome_email(user)
Success(user)
end
private
def validate(params)
result = UserContract.new.call(params)
result.success? ? Success(result.to_h) : Failure(result.errors.to_h)
end
def persist(values)
user = User.create(values)
user.persisted? ? Success(user) : Failure(user.errors.full_messages)
end
def send_welcome_email(user)
UserMailer.welcome(user).deliver_later
Success(user)
rescue StandardError => e
Rails.logger.error("Welcome email failed: #{e.message}")
Success(user)
end
end
Testing Patterns
RSpec Structure
RSpec.describe UserService, "#create" do
subject(:result) { described_class.new(repo: repo).create(params) }
let(:repo) { instance_double(UserRepository) }
let(:params) { { name: "Ada", email: "ada@example.com" } }
context "when params are valid" do
before do
allow(repo).to receive(:save).and_return(build(:user, **params))
end
it "returns the created user" do
expect(result).to be_a(User)
expect(result.name).to eq("Ada")
end
it "persists via repository" do
result
expect(repo).to have_received(:save).with(hash_including(name: "Ada"))
end
end
context "when email is taken" do
before do
allow(repo).to receive(:save).and_raise(ActiveRecord::RecordNotUnique)
end
it "raises a duplicate error" do
expect { result }.to raise_error(UserService::DuplicateEmail)
end
end
end
FactoryBot
FactoryBot.define do
factory :user do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
role { :user }
trait :admin do
role { :admin }
end
trait :with_orders do
transient do
order_count { 3 }
end
after(:create) do |user, ctx|
create_list(:order, ctx.order_count, user: user)
end
end
end
end
create(:user, :admin)
create(:user, :with_orders, order_count: 5)
build_stubbed(:user)
VCR / WebMock
VCR.configure do |c|
c.cassette_library_dir = "spec/fixtures/cassettes"
c.hook_into :webmock
c.filter_sensitive_data("<API_KEY>") { ENV.fetch("API_KEY") }
c.default_cassette_options = { record: :once, decode_compressed_response: true }
end
RSpec.describe GitHubClient do
it "fetches repositories", vcr: { cassette_name: "github/repos" } do
repos = described_class.new.repos("rails")
expect(repos).not_to be_empty
expect(repos.first).to respond_to(:name)
end
end