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 withgrep. Adefine_methodloop generating them is DRY but opaque — you can’t findsay_hellowith 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_methodearns 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.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀