CrackedRuby logo

CrackedRuby

prepend for Method Interception

A comprehensive guide to using prepend for intercepting and extending method behavior in Ruby modules and classes.

Metaprogramming Monkey Patching
5.9.2

Overview

The prepend method inserts a module into the method lookup chain before the including class or module, creating a mechanism for method interception. Unlike include, which places modules after the current class in the lookup chain, prepend places modules before the class, allowing the module's methods to intercept calls to methods with the same name in the class.

Ruby's method lookup chain determines which method implementation executes when a method is called. With prepend, the module's methods execute first, and can call super to invoke the original class method. This creates a wrapper pattern where the prepended module acts as middleware around the original method implementation.

The primary use cases for prepend include logging method calls, performance monitoring, input validation, caching, authentication, and adding cross-cutting concerns without modifying the original class definition.

module Logging
  def save
    puts "Before save"
    result = super
    puts "After save: #{result}"
    result
  end
end

class User
  prepend Logging
  
  def save
    puts "Saving user"
    true
  end
end

User.new.save
# Before save
# Saving user
# After save: true
# => true

The ancestors method reveals the method lookup order. After prepending, the module appears before the class in the ancestor chain:

User.ancestors
# => [Logging, User, Object, Kernel, BasicObject]

This ordering means when save is called on a User instance, Ruby first checks Logging#save, which then calls super to invoke User#save.

Basic Usage

Method interception with prepend follows a standard pattern: define methods in a module with the same names as the target class methods, perform pre-processing, call super to execute the original method, perform post-processing, and return the result.

module Validator
  def create(attributes)
    raise ArgumentError, "Invalid attributes" if attributes.empty?
    super(attributes.transform_keys(&:to_s))
  end
end

class Product
  prepend Validator
  
  def create(attributes)
    @attributes = attributes
    "Product created with #{@attributes}"
  end
end

product = Product.new
product.create(name: "Widget", price: 10.99)
# => "Product created with {\"name\"=>\"Widget\", \"price\"=>10.99}"

product.create({})
# ArgumentError: Invalid attributes

Multiple modules can be prepended to the same class. Ruby processes them in reverse order of prepending, with the last prepended module appearing first in the ancestor chain:

module Timer
  def process
    start_time = Time.now
    result = super
    end_time = Time.now
    puts "Execution time: #{end_time - start_time} seconds"
    result
  end
end

module Logger
  def process
    puts "Starting process"
    result = super
    puts "Process completed"
    result
  end
end

class DataProcessor
  prepend Timer
  prepend Logger  # This will be called first
  
  def process
    sleep(0.1)
    "Data processed"
  end
end

DataProcessor.new.process
# Starting process
# Data processed
# Process completed
# Execution time: 0.100123 seconds
# => "Data processed"

The ancestor chain shows the order:

DataProcessor.ancestors[0..2]
# => [Logger, Timer, DataProcessor]

Prepending works with inheritance hierarchies. The prepended module affects only the class it's prepended to, not parent or child classes:

module Auditing
  def delete
    puts "Audit: #{self.class} delete called"
    super
  end
end

class BaseModel
  def delete
    "Base delete"
  end
end

class User < BaseModel
  prepend Auditing
  
  def delete
    "User delete"
  end
end

class Admin < User
  def delete
    "Admin delete"
  end
end

User.new.delete
# Audit: User delete called
# => "User delete"

Admin.new.delete
# => "Admin delete"  # No audit logging

Advanced Usage

Complex interception patterns emerge when combining multiple modules, conditional logic, and metaprogramming techniques. Stacking interceptors creates layered middleware where each module adds specific functionality:

module Caching
  def find(id)
    cache_key = "#{self.class.name.downcase}_#{id}"
    if @cache && @cache[cache_key]
      puts "Cache hit for #{cache_key}"
      return @cache[cache_key]
    end
    
    result = super
    @cache ||= {}
    @cache[cache_key] = result
    puts "Cache miss for #{cache_key}, cached result"
    result
  end
end

module RateLimiting
  def find(id)
    @request_count ||= 0
    @request_count += 1
    
    if @request_count > 5
      raise "Rate limit exceeded"
    end
    
    puts "Rate limit check passed (#{@request_count}/5)"
    super
  end
end

module DatabaseMetrics
  def find(id)
    start_time = Time.now
    result = super
    query_time = Time.now - start_time
    
    @metrics ||= []
    @metrics << { query_time: query_time, id: id }
    puts "Query completed in #{query_time.round(4)}s"
    result
  end
end

class UserRepository
  prepend Caching
  prepend RateLimiting
  prepend DatabaseMetrics
  
  def find(id)
    sleep(0.02)  # Simulate database query
    { id: id, name: "User #{id}", email: "user#{id}@example.com" }
  end
end

repo = UserRepository.new
user = repo.find(1)
# Rate limit check passed (1/5)
# Query completed in 0.0201s
# Cache miss for userrepository_1, cached result

user = repo.find(1)
# Rate limit check passed (2/5)
# Cache hit for userrepository_1

Conditional interception allows modules to selectively intercept methods based on runtime conditions:

module ConditionalLogging
  def self.prepended(base)
    base.extend(ClassMethods)
  end
  
  module ClassMethods
    def logged_methods(*methods)
      @logged_methods = methods
    end
    
    def should_log?(method_name)
      @logged_methods&.include?(method_name)
    end
  end
  
  def method_missing(method_name, *args, **kwargs, &block)
    if self.class.should_log?(method_name)
      puts "Calling #{method_name} with args: #{args.inspect}"
      result = super
      puts "#{method_name} returned: #{result.inspect}"
      result
    else
      super
    end
  end
  
  def respond_to_missing?(method_name, include_private = false)
    self.class.should_log?(method_name) || super
  end
end

class ApiClient
  prepend ConditionalLogging
  logged_methods :fetch_user, :create_user
  
  def fetch_user(id)
    { id: id, name: "User #{id}" }
  end
  
  def create_user(data)
    { id: rand(1000), **data }
  end
  
  def internal_method
    "internal operation"
  end
end

client = ApiClient.new
client.fetch_user(123)
# Calling fetch_user with args: [123]
# fetch_user returned: {:id=>123, :name=>"User 123"}

client.internal_method
# => "internal operation"  # No logging

Dynamic module generation creates interceptors programmatically based on configuration:

module InterceptorFactory
  def self.create_validator(rules)
    Module.new do
      rules.each do |method_name, validations|
        define_method(method_name) do |*args, **kwargs|
          validations.each do |validation|
            case validation[:type]
            when :presence
              param_index = validation[:param]
              raise ArgumentError, "#{validation[:param]} cannot be nil" if args[param_index].nil?
            when :type
              param_index = validation[:param]
              expected_type = validation[:class]
              unless args[param_index].is_a?(expected_type)
                raise TypeError, "Expected #{expected_type}, got #{args[param_index].class}"
              end
            end
          end
          super(*args, **kwargs)
        end
      end
    end
  end
end

validation_rules = {
  create: [
    { type: :presence, param: 0 },
    { type: :type, param: 0, class: Hash }
  ],
  update: [
    { type: :presence, param: 0 },
    { type: :presence, param: 1 },
    { type: :type, param: 1, class: Hash }
  ]
}

validator = InterceptorFactory.create_validator(validation_rules)

class UserService
  prepend validator
  
  def create(data)
    "Created user with #{data}"
  end
  
  def update(id, data)
    "Updated user #{id} with #{data}"
  end
end

service = UserService.new
service.create({ name: "John" })
# => "Created user with {\"name\"=>\"John\"}"

service.create(nil)
# ArgumentError: 0 cannot be nil

Common Pitfalls

The super keyword behavior in prepended modules differs from inheritance. In prepended modules, super calls the next method in the lookup chain, which may be the original class method or another prepended module. Omitting super breaks the chain and prevents the original method from executing:

module BrokenInterceptor
  def process
    puts "Before processing"
    # Missing super call - original method never executes
    "intercepted result"
  end
end

module WorkingInterceptor
  def process
    puts "Before processing"
    result = super
    puts "After processing"
    result
  end
end

class Worker
  def process
    "original result"
  end
end

class BrokenWorker < Worker
  prepend BrokenInterceptor
end

class WorkingWorker < Worker
  prepend WorkingInterceptor
end

BrokenWorker.new.process
# Before processing
# => "intercepted result"  # Original method never called

WorkingWorker.new.process
# Before processing
# original result
# After processing
# => "original result"

Method signature mismatches between the intercepting module and the target class cause argument errors. The intercepting method must accept the same parameters or use flexible parameter handling:

module IncorrectSignature
  def calculate(x)  # Original method takes x, y
    puts "Calculating with #{x}"
    super
  end
end

module FlexibleSignature
  def calculate(*args, **kwargs)
    puts "Calculating with args: #{args}, kwargs: #{kwargs}"
    super
  end
end

class Calculator
  def calculate(x, y, operation: :add)
    case operation
    when :add then x + y
    when :multiply then x * y
    end
  end
end

class BrokenCalculator < Calculator
  prepend IncorrectSignature
end

class FlexibleCalculator < Calculator
  prepend FlexibleSignature
end

# BrokenCalculator.new.calculate(5, 3)
# ArgumentError: wrong number of arguments (given 2, expected 1)

FlexibleCalculator.new.calculate(5, 3, operation: :multiply)
# Calculating with args: [5, 3], kwargs: {:operation=>:multiply}
# => 15

Module prepending order creates unexpected behavior when multiple modules define the same method. The last prepended module appears first in the method lookup chain:

module FirstModule
  def greet
    puts "First module"
    super
  end
end

module SecondModule  
  def greet
    puts "Second module"
    super
  end
end

class Greeter
  prepend FirstModule
  prepend SecondModule  # This will be called first
  
  def greet
    puts "Original method"
  end
end

Greeter.new.greet
# Second module
# First module
# Original method

Greeter.ancestors[0..2]
# => [SecondModule, FirstModule, Greeter]

Prepending modules that don't call super creates silent method replacement instead of interception:

module ReplacementModule
  def important_method
    "replaced implementation"
    # No super call - original method is completely replaced
  end
end

module InterceptionModule
  def important_method
    puts "Intercepting call"
    result = super
    puts "Original returned: #{result}"
    result
  end
end

class ImportantClass
  def important_method
    puts "Critical business logic"
    "original result"
  end
end

class ReplacedClass < ImportantClass
  prepend ReplacementModule
end

class InterceptedClass < ImportantClass  
  prepend InterceptionModule
end

ReplacedClass.new.important_method
# => "replaced implementation"  # Critical logic never runs

InterceptedClass.new.important_method
# Intercepting call
# Critical business logic
# Original returned: original result
# => "original result"

Infinite recursion occurs when prepended modules call methods that trigger the same interception:

module RecursiveModule
  def process_data(data)
    if data.is_a?(String)
      # This calls process_data again, causing infinite recursion
      process_data(data.split(","))
    else
      super
    end
  end
end

class DataProcessor
  prepend RecursiveModule
  
  def process_data(data)
    "Processed: #{data.inspect}"
  end
end

# DataProcessor.new.process_data("a,b,c")
# SystemStackError: stack level too deep

The correct approach uses different method names or direct class method calls:

module SafeModule
  def process_data(data)
    if data.is_a?(String)
      # Call a different method or the class method directly
      super(data.split(","))
    else
      super
    end
  end
end

Reference

Core Methods

Method Parameters Returns Description
prepend(module) module (Module) self Prepends module to the current class or module
prepended(base) base (Class/Module) nil Hook called when module is prepended to base
ancestors None Array<Class/Module> Returns method lookup chain with prepended modules first

Module Definition Patterns

Pattern Syntax Use Case
Simple Interception def method_name; super; end Basic method wrapping
Pre/Post Processing pre_logic; result = super; post_logic; result Adding behavior before/after
Conditional Interception condition ? super : alternate_logic Selective method execution
Parameter Transformation super(transform(args)) Modifying method arguments

Prepending Hooks

module HookedModule
  def self.prepended(base)
    # Called when module is prepended
    # base is the class/module that prepended this module
    base.extend(ClassMethods) if defined?(ClassMethods)
  end
end

Method Lookup Chain Behavior

Scenario Ancestor Order Method Resolution
No prepending [Class, Parent, Object] Standard inheritance
Single prepend [Module, Class, Parent] Module method first
Multiple prepends [Last, Previous, ..., Class] Reverse prepend order
Inheritance with prepend [Module, Child, Parent] Module only affects target class

Super Call Variations

Super Syntax Behavior Arguments Passed
super Calls next method with same arguments Original arguments
super() Calls next method with no arguments None
super(args) Calls next method with specified arguments Specified arguments
super(*args, **kwargs) Calls next method with splat arguments Splat arguments

Common Method Signature Patterns

# Exact signature matching
def method_name(param1, param2, keyword: nil)
  super
end

# Flexible parameter handling
def method_name(*args, **kwargs, &block)
  super
end

# Hybrid approach
def method_name(required_param, *args, **kwargs)
  super
end

Error Types and Causes

Error Cause Solution
ArgumentError Parameter count mismatch Use flexible signatures or match exactly
SystemStackError Infinite recursion in interceptor Avoid calling same method in module
NoMethodError Missing super or wrong method name Ensure method exists and super is called
TypeError Incorrect return type transformation Preserve or correctly transform return values

Module Ordering Debugging

# Check ancestor chain
ClassName.ancestors

# Find method source
ClassName.instance_method(:method_name).source_location

# List all method definitions
ClassName.ancestors.map { |a| a.instance_methods(false) if a.method_defined?(:method_name) }

Performance Considerations

  • Each prepended module adds a step in method lookup
  • Multiple interceptors create nested call stacks
  • Conditional logic in interceptors affects all method calls
  • Method resolution traverses ancestor chain in order