After 83 days of Rails, you're likely past the basics—but interviews often dig into Ruby's core mechanics in ways that feel abstract until you've built real systems. Ruby interview questions: a hands-on guide for engineers bridges that gap by grounding language fundamentals in production scenarios we've all encountered. This guide covers the deeper topics—blocks, procs, lambdas, modules, symbols, and metaprogramming—that separate confident practitioners from those who stumble.
Blocks, procs and lambdas
Blocks are the foundation of Ruby's elegant iteration patterns, yet the differences between blocks, procs, and lambdas confuse even experienced developers. Let's ground this in reality.
A block is not an object—it's syntax. When you pass code to Array#each or Hash#map, that's a block. It exists only in that context:
[1, 2, 3].each { |x| puts x }A proc is a Ruby object wrapping a block. You create it explicitly with Proc.new or the & operator. The critical difference: a proc has loose return semantics and argument handling.
my_proc = Proc.new { |x| x * 2 }
puts my_proc.call(5) # => 10
# Procs are lenient with arguments
my_proc = Proc.new { |x, y| x }
puts my_proc.call(1) # => 1 (y is nil, no error)
puts my_proc.call(1, 2) # => 1A lambda is stricter. It's also a proc, but behaves more like a proper function:
my_lambda = lambda { |x| x * 2 }
puts my_lambda.call(5) # => 10
# Lambdas enforce argument count
my_lambda = lambda { |x, y| x }
puts my_lambda.call(1) # => ArgumentError: wrong number of argumentsThe return behavior differs too. In a proc, return exits the enclosing method. In a lambda, it returns only from the lambda:
def test_proc
my_proc = Proc.new { return "from proc" }
my_proc.call
"never reached"
end
puts test_proc # => "from proc"
def test_lambda
my_lambda = lambda { return "from lambda" }
my_lambda.call
"from method"
end
puts test_lambda # => "from method"In production, we use lambdas for callbacks and data transformations where predictability matters, and procs for block conversion when we need flexibility. We've seen procs bite us in background jobs when an unexpected return caused silent failures.
Modules and mixins
Modules solve a real problem: Ruby has single inheritance. When we need to share behavior across unrelated classes, modules provide the answer through mixins.
A module is a container for methods and constants that you include (instance methods) or extend (class methods) into a class:
module Auditable
def audit_log(action)
puts "#{self.class} performed #{action} at #{Time.now}"
end
end
class User
include Auditable
end
class Order
include Auditable
end
user = User.new
user.audit_log("login") # => User performed login at 2024-01-15 10:30:00
order = Order.new
order.audit_log("shipped") # => Order performed shipped at 2024-01-15 10:30:01Include adds module methods as instance methods. Extend adds them as class methods:
module Cacheable
def cached_find(id)
Rails.cache.fetch("item_#{id}") { find(id) }
end
end
class Product < ApplicationRecord
extend Cacheable
end
Product.cached_find(42) # Class method callModules also provide namespacing and can contain constants:
module Payments
STRIPE_API_VERSION = "2023-10-16"
class Processor
def initialize
@version = STRIPE_API_VERSION
end
end
end
Payments::Processor.newMethod resolution order (MRO) matters. When you include multiple modules, Ruby searches bottom-to-top in the ancestor chain. We've debugged mysterious method collisions by misunderstanding this hierarchy in large Rails apps.
Symbols vs strings
On the surface, symbols and strings look interchangeable. They're not, and the distinction shapes performance and code clarity.
A string is mutable and creates a new object each time:
"name".object_id # => 70123456789000
"name".object_id # => 70123456789100 (different)
str = "name"
str << "_modified"
puts str # => "name_modified"A symbol is immutable and interned—Ruby caches it globally:
:name.object_id # => 70123456789000
:name.object_id # => 70123456789000 (same)This has real implications. Hash lookups with symbol keys are faster and use less memory than string keys:
hash_symbols = { name: "Alice", age: 30 }
hash_symbols[:name] # Fast lookup
hash_strings = { "name" => "Alice", "age" => 30 }
hash_strings["name"] # Slower, creates new string objectIn Rails, this matters. When we fetch query parameters, params[:user] works, but params["user"] also works because Rails converts them. With large JSON payloads and repeated access patterns, symbol usage reduces garbage collection pressure.
Use symbols for fixed keys (database columns, hash keys defined by your code), strings for user input or dynamic content:
# Symbols: fixed structure
config = { database: "postgres", pool_size: 10 }
# Strings: user input
user_data = JSON.parse(params[:json]) # keys are stringsHow method_missing works
When Ruby can't find a method on an object, it calls method_missing before raising a NoMethodError. This is powerful metaprogramming, but dangerous if misused.
The basic structure:
class DynamicAttributes
def initialize
@data = {}
end
def method_missing(method_name, *args, **kwargs)
if method_name.to_s.end_with?("=")
attr = method_name.to_s.chomp("=").to_sym
@data[attr] = args[0]
else
@data[method_name]
end
end
end
obj = DynamicAttributes.new
obj.name = "Alice"
puts obj.name # => "Alice"Rails uses method_missing extensively. Dynamic finders like User.find_by_email("user@example.com") were implemented this way (though newer Rails moved to more explicit patterns). ActiveRecord relations use it to build SQL dynamically.
A production example: we built a wrapper around an external API that had a complex, changing response structure:
class APIResponse
def initialize(data)
@data = data
end
def method_missing(method_name, *args)
@data[method_name.to_s] || @data[method_name.to_sym]
end
def respond_to_missing?(method_name, include_private = false)
@data.key?(method_name.to_s) || @data.key?(method_name.to_sym) || super
end
end
response = APIResponse.new({ user_id: 42, status: "active" })
puts response.user_id # => 42Always implement respond_to_missing? when using method_missing. Without it, introspection breaks: respond_to?(:user_id) returns false even though the call would work.
Caveats: method_missing adds overhead to every failed lookup. It makes stack traces harder to read and code harder to trace. Use it for clear DSLs or unavoidable dynamic behavior, not as a shortcut for metaprogramming.
Common mistakes
- Confusing proc and lambda return behavior: Returning from a proc exits the enclosing method, not just the proc. This catches experienced developers when moving callback code around.
- Using strings as hash keys in performance-critical code: Symbol keys are cached and faster. We've seen N+1 queries mask poor hash key choices until profiling revealed GC pressure.
- Relying on method_missing for public APIs: If you expect callers to use
obj.some_methodand it's dynamic, document it clearly or your API becomes a guessing game. - Forgetting module method resolution order: When two mixins define the same method, the last included module wins. Not understanding this leads to subtle overriding bugs.
- Assuming blocks are objects: You can't pass a block to a variable without wrapping it in a proc.
def foo(&block)converts it to a proc for you.
Key takeaways
- Blocks are syntax, procs are objects—lambdas are strict procs with proper return semantics.
- Modules provide mixins for horizontal code reuse; use
includefor instance methods andextendfor class methods. - Symbols are interned, immutable, and faster for hash keys and fixed structure; strings are for dynamic or user input.
- method_missing is powerful for DSLs but expensive and obscures code; always pair it with respond_to_missing?.
- Understanding Ruby's object model deepens your grasp of Rails internals and helps you write performant, maintainable code.






Comments
Loading comments…