Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 083 / 100

Ruby interview questions: a hands-on guide for engineers

Core language questions.

Expona Admin7 min read

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:

ruby
[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.

ruby
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)   # => 1

A lambda is stricter. It's also a proc, but behaves more like a proper function:

ruby
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 arguments

The return behavior differs too. In a proc, return exits the enclosing method. In a lambda, it returns only from the lambda:

ruby
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:

ruby
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:01

Include adds module methods as instance methods. Extend adds them as class methods:

ruby
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 call

Modules also provide namespacing and can contain constants:

ruby
module Payments
  STRIPE_API_VERSION = "2023-10-16"
  
  class Processor
    def initialize
      @version = STRIPE_API_VERSION
    end
  end
end

Payments::Processor.new

Method 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:

ruby
"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:

ruby
: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:

ruby
hash_symbols = { name: "Alice", age: 30 }
hash_symbols[:name]  # Fast lookup

hash_strings = { "name" => "Alice", "age" => 30 }
hash_strings["name"]  # Slower, creates new string object

In 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:

ruby
# Symbols: fixed structure
config = { database: "postgres", pool_size: 10 }

# Strings: user input
user_data = JSON.parse(params[:json])  # keys are strings

How 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:

ruby
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:

ruby
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  # => 42

Always 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_method and 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 include for instance methods and extend for 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles