/ Tags: RUBY 3 / Categories: RUBY

Metaprogramming with define_method — Writing Code That Writes Code in Ruby

Metaprogramming gets a reputation for being either magic or dangerous, depending on who you ask. Used carelessly, it produces code that’s impossible to trace and impossible to test. Used deliberately, it eliminates repetitive patterns that can’t be simplified any other way. define_method is the tool that sits at the center of Ruby’s metaprogramming story — it lets you create methods dynamically, capture closures, and generate entire families of methods from data. Understanding how it works, and when to use it, puts a genuinely useful technique in your toolkit.

What define_method Does


define_method creates an instance method on a class at runtime. The block you pass becomes the method body, and unlike def, it’s a closure — it captures variables from the surrounding scope.

Example:

class Greeter
  ["hello", "goodbye", "hey"].each do |word|
    define_method("say_#{word}") do |name|
      "#{word.capitalize}, #{name}!"
    end
  end
end

g = Greeter.new
g.say_hello("Ada")    # => "Hello, Ada!"
g.say_goodbye("Ada")  # => "Goodbye, Ada!"
g.say_hey("Ada")      # => "Hey, Ada!"

The three methods are identical in structure — only word differs. Without define_method, you’d write them three times and maintain three copies. With it, the pattern is expressed once.

Closures and Variable Capture


The critical difference between define_method and def is closure behavior. A def block creates a new scope and cannot see local variables from the surrounding context. define_method captures them.

Example:

# def — no closure, can't capture outer variables
multiplier = 3
class Calculator
  def multiply(n)
    n * multiplier   # => NameError: undefined local variable 'multiplier'
  end
end

# define_method — closure, captures outer variables
multiplier = 3
Calculator.class_eval do
  define_method(:multiply) do |n|
    n * multiplier  # Works — multiplier is captured from the outer scope
  end
end

Calculator.new.multiply(5)  # => 15

Example:

# This is the key pattern — data drives method generation
STATUSES = { pending: 0, active: 1, suspended: 2, deleted: 3 }.freeze

class User < ApplicationRecord
  STATUSES.each do |name, code|
    define_method("#{name}?") { status == code }
    define_method("mark_#{name}!") { update!(status: code) }
  end
end

user = User.new(status: 1)
user.active?      # => true
user.pending?     # => false
user.mark_suspended!  # runs: update!(status: 2)

class_eval and module_eval


class_eval (aliased as module_eval) evaluates a block in the context of a class, making it the right place to define methods when you’re outside the class definition.

Example:

# Define methods on a class from outside the class
String.class_eval do
  define_method(:palindrome?) do
    self == self.reverse
  end

  define_method(:word_count) do
    split.length
  end
end

"racecar".palindrome?         # => true
"hello world".word_count      # => 2

Example:

# Building a flexible attribute accessor generator
module TypedAttributes
  def typed_attr(name, type:)
    define_method(name) do
      instance_variable_get("@#{name}")
    end

    define_method("#{name}=") do |value|
      unless value.is_a?(type) || value.nil?
        raise TypeError, "#{name} must be a #{type}, got #{value.class}"
      end
      instance_variable_set("@#{name}", value)
    end
  end
end

class Product
  extend TypedAttributes

  typed_attr :name,  type: String
  typed_attr :price, type: Numeric
  typed_attr :stock, type: Integer
end

p = Product.new
p.name  = "Widget"   # fine
p.price = 9.99       # fine
p.stock = "five"     # => TypeError: stock must be a Integer, got String

Practical Patterns


1. Delegating to a Wrapped Object
class LazyProxy
  def initialize(&block)
    @loader = block
  end

  private

  def target
    @target ||= @loader.call
  end

  [:name, :email, :role, :active?].each do |method_name|
    define_method(method_name) do |*args, &block|
      target.public_send(method_name, *args, &block)
    end
  end
end

proxy = LazyProxy.new { User.find(1) }
proxy.name    # User is loaded on first access, then cached
proxy.email   # Uses cached user
2. Generating Predicates from a Constant
module Permissions
  LEVELS = %w[read write admin].freeze

  LEVELS.each do |level|
    define_method("can_#{level}?") do
      permission_level >= LEVELS.index(level)
    end
  end
end

class User
  include Permissions
  attr_reader :permission_level

  def initialize(level)
    @permission_level = level
  end
end

user = User.new(1)
user.can_read?   # => true  (1 >= 0)
user.can_write?  # => true  (1 >= 1)
user.can_admin?  # => false (1 < 2)
3. Memoization Factory
module Memoizable
  def memoize(*method_names)
    method_names.each do |name|
      original = instance_method(name)

      define_method(name) do
        var = "@#{name}_memo"
        unless instance_variable_defined?(var)
          instance_variable_set(var, original.bind_call(self))
        end
        instance_variable_get(var)
      end
    end
  end
end

class ReportGenerator
  extend Memoizable

  def expensive_query
    sleep(2)
    "expensive result"
  end

  memoize :expensive_query
end

r = ReportGenerator.new
r.expensive_query  # takes 2 seconds
r.expensive_query  # instant — cached

method_missing and respond_to_missing?


When define_method would require generating too many methods upfront, method_missing intercepts calls to undefined methods at runtime. Always pair it with respond_to_missing? so respond_to? stays accurate.

Example:

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

  def method_missing(name, *args)
    key = name.to_s.delete_suffix("?").to_sym
    if @data.key?(key)
      value = @data[key]
      name.to_s.end_with?("?") ? !value.nil? : value
    else
      super
    end
  end

  def respond_to_missing?(name, include_private = false)
    key = name.to_s.delete_suffix("?").to_sym
    @data.key?(key) || super
  end
end

config = DynamicConfig.new(host: "localhost", port: 5432, debug: nil)
config.host    # => "localhost"
config.port    # => 5432
config.debug?  # => false  (nil is falsy)
config.host?   # => true
config.respond_to?(:host)   # => true  (respond_to_missing? covers it)

Pro-Tip: Before reaching for define_method, ask whether the repetition you’re eliminating is actually a problem. Three similar methods are clear and searchable with grep. A define_method loop generating them is DRY but opaque — you can’t find say_hello with a text search, and stack traces point to the generator instead of the method. The right threshold: if you have five or more methods with identical structure that vary on a single value, and that list is driven by data that changes, define_method earns its place. Below that, explicit methods are clearer.

Conclusion


define_method and class_eval unlock a style of Ruby programming where code structure is driven by data — status predicates from a constants hash, typed accessors from a DSL, delegating methods from a list. The closure behavior makes it more powerful than string eval for most metaprogramming cases, and the traceability is better. The discipline is knowing when the abstraction saves more than it costs in readability and debuggability.

FAQs


Q1: What’s the difference between define_method and eval?
define_method takes a block — it’s a proper Ruby method call, and the block runs in the current scope with full closure semantics. eval parses and executes a string, which is slower, harder to debug, and more dangerous if the string contains untrusted input. Prefer define_method for all cases where string eval would otherwise be used.

Q2: Can define_method create class methods?
Yes. Use it inside class << self or use define_singleton_method. User.define_singleton_method(:find_by_token) { |t| where(token: t).first } adds a class method to User.

Q3: Are methods created with define_method slower than def methods?
There’s a tiny overhead because define_method creates a closure, while def creates a clean scope. In practice, the difference is unmeasurable for application code. Only in extremely hot loops (millions of calls per second) would this matter.

Q4: How do I add documentation to dynamically defined methods?
You can use YARD tags with a @!method directive to document generated methods without them being defined statically. This makes generated APIs visible in documentation tools even when they don’t appear as literal def calls.

Q5: What happens if define_method is called with a name that already exists?
It silently redefines the method. Ruby emits a warning only if the class has Warning[:performance] = true set. For intentional redefinition (patching a gem, testing), this is fine. For accidental redefinition, it’s a source of subtle bugs — careful naming conventions help.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More