Ruby Logo

Method Hooks & Callbacks

Master method_added, method_missing, respond_to_missing? and other Ruby hooks for advanced metaprogramming.

Home Ruby Method Hooks & Callbacks

Method Hooks & Callbacks

Why Master Method Hooks & Callbacks?

Method hooks and callbacks are Ruby's mechanism for intercepting and responding to method lifecycle events. They enable powerful metaprogramming patterns used in frameworks like Rails, provide debugging capabilities, and allow dynamic behavior modification.

Framework Building

Rails callbacks, validations, lifecycle hooks

Dynamic Proxies

API wrappers, delegation patterns, method forwarding

Debugging & Monitoring

Method tracing, performance monitoring, debugging tools

method_added Hook

The method_added hook is called whenever a new method is defined in a class or module. This enables automatic registration, validation, or decoration of methods as they're created.

Basic method_added Usage

class ApiEndpoint
  def self.method_added(method_name)
    puts "New API endpoint defined: #{method_name}"

    # Automatically register the endpoint
    endpoints << method_name unless method_name == :endpoints
  end

  def self.endpoints
    @endpoints ||= []
  end

  def get_users
    # Implementation for /users endpoint
  end

  def post_user
    # Implementation for POST /user
  end
end

# Output:
# New API endpoint defined: get_users
# New API endpoint defined: post_user

puts ApiEndpoint.endpoints
# => [:get_users, :post_user]

Advanced: Method Decoration with Timing

class TimedClass
  def self.method_added(method_name)
    return if @adding_method || method_name.to_s.start_with?('timed_')

    # Store original method
    original_method = instance_method(method_name)

    # Prevent infinite recursion
    @adding_method = true

    # Replace with timing wrapper
    define_method(method_name) do |*args, &block|
      start_time = Time.now
      result = original_method.bind(self).call(*args, &block)
      end_time = Time.now

      puts "#{method_name} executed in #{(end_time - start_time) * 1000}ms"
      result
    end

    @adding_method = false
  end

  def slow_operation
    sleep(0.1)
    "Done!"
  end

  def fast_operation
    42 * 2
  end
end

obj = TimedClass.new
obj.slow_operation  # slow_operation executed in 100.5ms
obj.fast_operation  # fast_operation executed in 0.02ms

⚠️ Important Considerations

  • Watch for infinite recursion when defining methods inside method_added
  • method_added is called for every method, including attr_accessor generated methods
  • Use flags or naming conventions to prevent unintended hooks
  • Performance impact: method_added runs during class definition, not method execution

method_missing Hook

method_missing is called when Ruby cannot find a method. It's the foundation for dynamic method handling, proxy objects, and flexible APIs. Always pair it with respond_to_missing? for proper behavior.

Dynamic Attribute Access

class DynamicConfig
  def initialize(data = {})
    @data = data
  end

  def method_missing(method_name, *args, &block)
    method_str = method_name.to_s

    if method_str.end_with?('=')
      # Setter method
      key = method_str.chomp('=').to_sym
      @data[key] = args.first
    elsif method_str.end_with?('?')
      # Predicate method
      key = method_str.chomp('?').to_sym
      !!@data[key]
    elsif @data.key?(method_name)
      # Getter method
      @data[method_name]
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s
    method_str.end_with?('=', '?') || @data.key?(method_name) || super
  end
end

config = DynamicConfig.new
config.database_url = "postgresql://localhost/myapp"
config.debug_mode = true

puts config.database_url    # => "postgresql://localhost/myapp"
puts config.debug_mode?     # => true
puts config.respond_to?(:debug_mode?)  # => true

API Client with Dynamic Methods

class ApiClient
  SUPPORTED_METHODS = %w[get post put delete patch].freeze

  def initialize(base_url)
    @base_url = base_url
  end

  def method_missing(method_name, *args, &block)
    method_str = method_name.to_s

    # Handle HTTP method + resource patterns (e.g., get_users, post_user)
    if match = method_str.match(/^(#{SUPPORTED_METHODS.join('|')})_(.+)$/)
      http_method = match[1]
      resource = match[2]

      send_request(http_method, resource, *args)
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s
    SUPPORTED_METHODS.any? { |method| method_str.start_with?("#{method}_") } || super
  end

  private

  def send_request(method, resource, params = {})
    url = "#{@base_url}/#{resource}"
    puts "#{method.upcase} #{url} with #{params}"
    # Actual HTTP request would go here
    { status: 200, data: "Response for #{method} #{resource}" }
  end
end

client = ApiClient.new("https://api.example.com")
client.get_users(limit: 10)           # GET https://api.example.com/users
client.post_user(name: "John")        # POST https://api.example.com/user
client.put_profile(id: 1, bio: "...")  # PUT https://api.example.com/profile

puts client.respond_to?(:get_posts)   # => true
puts client.respond_to?(:invalid_method)  # => false

respond_to_missing?

respond_to_missing? should always be implemented alongside method_missing. It ensures that respond_to? correctly reports whether an object can handle a method call, maintaining Ruby's introspection capabilities.

Proper Implementation Pattern

class SmartProxy
  def initialize(target)
    @target = target
  end

  def method_missing(method_name, *args, &block)
    if @target.respond_to?(method_name)
      puts "Proxying #{method_name} to target"
      @target.send(method_name, *args, &block)
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    @target.respond_to?(method_name, include_private) || super
  end
end

class Calculator
  def add(a, b)
    a + b
  end

  def multiply(a, b)
    a * b
  end
end

calc = Calculator.new
proxy = SmartProxy.new(calc)

puts proxy.respond_to?(:add)       # => true
puts proxy.respond_to?(:subtract)  # => false

proxy.add(5, 3)                    # Proxying add to target => 8
# proxy.subtract(5, 3)             # NoMethodError

✅ Best Practices

  • Always implement respond_to_missing? when using method_missing
  • Call super for unhandled methods to maintain proper error behavior
  • Be specific about which methods you'll handle to avoid unexpected behavior
  • Consider performance: method_missing is slower than regular method dispatch

Advanced Hook Patterns

Method Delegation with Validation

class ValidatedDelegate
  def initialize(target, allowed_methods = [])
    @target = target
    @allowed_methods = allowed_methods.map(&:to_sym)
  end

  def method_missing(method_name, *args, &block)
    unless @allowed_methods.include?(method_name)
      raise NoMethodError, "Method #{method_name} not allowed on this delegate"
    end

    unless @target.respond_to?(method_name)
      raise NoMethodError, "Target doesn't respond to #{method_name}"
    end

    @target.send(method_name, *args, &block)
  end

  def respond_to_missing?(method_name, include_private = false)
    @allowed_methods.include?(method_name) &&
      @target.respond_to?(method_name, include_private)
  end

  def allowed_methods
    @allowed_methods
  end
end

string = "Hello World"
delegate = ValidatedDelegate.new(string, [:upcase, :downcase, :length])

puts delegate.upcase        # => "HELLO WORLD"
puts delegate.length        # => 11
# delegate.reverse          # NoMethodError: Method reverse not allowed

Method Registry with Callbacks

module CallbackRegistry
  def self.included(base)
    base.extend(ClassMethods)
    base.class_eval do
      @callbacks = Hash.new { |h, k| h[k] = [] }
    end
  end

  module ClassMethods
    def method_added(method_name)
      super
      trigger_callbacks(:after_method_added, method_name)
    end

    def on_method_added(&block)
      @callbacks[:after_method_added] << block
    end

    def callbacks
      @callbacks
    end

    private

    def trigger_callbacks(event, *args)
      @callbacks[event].each { |callback| callback.call(*args) }
    end
  end
end

class MonitoredClass
  include CallbackRegistry

  on_method_added do |method_name|
    puts "Method #{method_name} was added to #{self.name}"
  end

  on_method_added do |method_name|
    # Could register with monitoring system, update documentation, etc.
    puts "Registering #{method_name} with monitoring system"
  end

  def business_method
    "Important business logic"
  end

  def another_method
    "More functionality"
  end
end

# Output:
# Method business_method was added to MonitoredClass
# Registering business_method with monitoring system
# Method another_method was added to MonitoredClass
# Registering another_method with monitoring system

Real-world Applications

Rails ActiveRecord

Uses method_missing for dynamic finders like find_by_name, association methods, and attribute accessors.

User.find_by_email("john@example.com")
User.find_by_name_and_age("John", 30)

Configuration DSLs

method_added hooks automatically register configuration methods and validate DSL syntax.

config.database :postgresql
config.cache :redis
config.background_jobs :sidekiq

API Clients

Dynamic method creation for REST endpoints, reducing boilerplate and enabling flexible API interactions.

client.get_users
client.post_order(item: "widget")
client.delete_session

Performance & Security Considerations

Performance Impact

  • method_missing is slower: Ruby must traverse the method lookup chain completely before calling it
  • Consider define_method: For repeated patterns, define actual methods instead of relying on method_missing
  • Cache method checks: Store respond_to? results if checking the same methods repeatedly
  • Profile carefully: method_missing can become a bottleneck in performance-critical code

Security Considerations

  • Input validation: Always validate method names and arguments in method_missing
  • Limit scope: Be explicit about which methods you'll handle dynamically
  • Avoid eval: Never use eval or instance_eval with user input in method hooks
  • Access control: Respect private/protected method boundaries

Safe method_missing Implementation

class SafeApiProxy
  ALLOWED_PATTERNS = /\A(get|post|put|delete)_[a-z_]+\z/

  def method_missing(method_name, *args, &block)
    method_str = method_name.to_s

    # Validate method name pattern
    unless method_str.match?(ALLOWED_PATTERNS)
      raise NoMethodError, "Unsafe method name: #{method_name}"
    end

    # Validate arguments
    args.each do |arg|
      unless arg.is_a?(Hash) || arg.is_a?(String) || arg.is_a?(Numeric)
        raise ArgumentError, "Unsafe argument type: #{arg.class}"
      end
    end

    # Safe to proceed
    execute_api_call(method_name, *args, &block)
  end

  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s.match?(ALLOWED_PATTERNS) || super
  end

  private

  def execute_api_call(method, *args)
    # Implementation here
  end
end

Common Pitfalls & Solutions

❌ Infinite Recursion in method_added

# BAD - causes infinite recursion
def self.method_added(name)
  define_method("wrapped_#{name}") { ... }
end
# GOOD - use a flag to prevent recursion
def self.method_added(name)
  return if @defining_wrapper
  @defining_wrapper = true
  define_method("wrapped_#{name}") { ... }
  @defining_wrapper = false
end

❌ Forgetting respond_to_missing?

# BAD - breaks introspection
def method_missing(name, *args)
  handle_dynamic_method(name, *args)
end
# GOOD - implement both methods
def method_missing(name, *args)
  handle_dynamic_method(name, *args)
end

def respond_to_missing?(name, include_private = false)
  can_handle_dynamic_method?(name) || super
end

Quick Navigation

Related Topics

Video Tutorial

Watch and learn method hooks & callbacks

Pro Tip: After reading through the content above, watch this video to reinforce your understanding and see the concepts in action!