Ruby Metaprogramming: Writing Code That Writes Code (With Examples)

You know that feeling when you’re writing Ruby and you think “there has to be a more elegant way to do this”? Usually, there is. And it probably involves metaprogramming.

After working with Ruby for years, I’ve learned that metaprogramming isn’t just about showing off (though it definitely can impress your teammates). It’s about writing code that’s more maintainable, more expressive, and frankly, more fun.

Today I want to walk you through some real metaprogramming techniques that you can actually use in your day-to-day Ruby work. No abstract examples here – just practical stuff that will make your code better.

What Exactly Is Metaprogramming?

Metaprogramming is code that manipulates code. In Ruby, this means your program can modify itself at runtime – creating methods, classes, and even changing existing behavior on the fly.

Think of it like this: normal programming is like building with LEGO blocks. Metaprogramming is like building a machine that builds LEGO structures for you.

Example #1: Dynamic Attribute Accessors

Let’s say you’re building a configuration class and you’re tired of writing the same getter/setter pattern over and over:

# The boring way
class Config
  def database_host
    @database_host
  end
  
  def database_host=(value)
    @database_host = value
  end
  
  def database_port
    @database_port
  end
  
  def database_port=(value)
    @database_port = value
  end
  
  # ... and so on for 20 more attributes
end

Ugh. Instead, let’s use define_method to create these dynamically:

class Config
  ATTRIBUTES = [:database_host, :database_port, :api_key, :timeout, :retries]
  
  ATTRIBUTES.each do |attr|
    define_method(attr) do
      instance_variable_get("@#{attr}")
    end
    
    define_method("#{attr}=") do |value|
      instance_variable_set("@#{attr}", value)
    end
  end
end

config = Config.new
config.database_host = "localhost"
puts config.database_host # => "localhost"

Now when you need to add a new configuration option, you just add it to the ATTRIBUTES array. One line instead of six.

Example #2: Building a Simple Query DSL

Here’s something I actually built for a project. We needed a simple way to build database queries without writing SQL strings everywhere:

class QueryBuilder
  def initialize(table)
    @table = table
    @conditions = []
    @order = nil
  end
  
  def method_missing(method_name, *args)
    if method_name.to_s.end_with?('_eq')
      field = method_name.to_s.gsub('_eq', '')
      @conditions << "#{field} = '#{args.first}'"
      self
    elsif method_name.to_s.start_with?('order_by_')
      field = method_name.to_s.gsub('order_by_', '')
      @order = field
      self
    else
      super
    end
  end
  
  def to_sql
    sql = "SELECT * FROM #{@table}"
    sql += " WHERE #{@conditions.join(' AND ')}" unless @conditions.empty?
    sql += " ORDER BY #{@order}" if @order
    sql
  end
end

# Now you can write queries like this:
query = QueryBuilder.new('users')
  .name_eq('John')
  .status_eq('active')
  .order_by_created_at

puts query.to_sql
# => "SELECT * FROM users WHERE name = 'John' AND status = 'active' ORDER BY created_at"

The magic happens in method_missing. When you call a method that doesn’t exist, Ruby gives you a chance to handle it dynamically. We’re checking the method name pattern and building our query conditions on the fly.

Example #3: Class-Level Configuration

This one’s really useful for gems and libraries. Let’s create a class that can configure itself:

module Configurable
  def self.included(base)
    base.extend(ClassMethods)
  end
  
  module ClassMethods
    def configurable(*attributes)
      attributes.each do |attr|
        # Create a class variable to store the config
        class_variable_set("@@#{attr}", nil)
        
        # Class-level setter
        define_singleton_method("#{attr}=") do |value|
          class_variable_set("@@#{attr}", value)
        end
        
        # Class-level getter
        define_singleton_method(attr) do
          class_variable_get("@@#{attr}")
        end
        
        # Instance-level getter
        define_method(attr) do
          self.class.send(attr)
        end
      end
    end
  end
end

class EmailService
  include Configurable
  configurable :smtp_host, :smtp_port, :username
  
  def send_email
    puts "Connecting to #{smtp_host}:#{smtp_port} as #{username}"
  end
end

# Configuration at the class level
EmailService.smtp_host = "smtp.gmail.com"
EmailService.smtp_port = 587
EmailService.username = "myapp@example.com"

# Available in instances
service = EmailService.new
service.send_email
# => "Connecting to smtp.gmail.com:587 as myapp@example.com"

This pattern lets you create configurable classes without repeating the same configuration code everywhere. I’ve used variations of this in several Rails apps.

Example #4: Validation DSL

Here’s a practical validation system that reads almost like English:

module Validatable
  def self.included(base)
    base.extend(ClassMethods)
    base.class_eval do
      attr_accessor :errors
      
      def initialize
        @errors = []
        super if defined?(super)
      end
    end
  end
  
  module ClassMethods
    def validates(field, options = {})
      @validations ||= []
      @validations << { field: field, options: options }
      
      # Create a validation method
      define_method("validate_#{field}") do
        value = send(field)
        
        if options[:presence] && (value.nil? || value.to_s.strip.empty?)
          errors << "#{field} can't be blank"
        end
        
        if options[:length] && value.to_s.length < options[:length][:minimum]
          errors << "#{field} must be at least #{options[:length][:minimum]} characters"
        end
        
        if options[:format] && value.to_s !~ options[:format]
          errors << "#{field} format is invalid"
        end
      end
    end
    
    def validations
      @validations || []
    end
  end
  
  def valid?
    @errors = []
    self.class.validations.each do |validation|
      send("validate_#{validation[:field]}")
    end
    errors.empty?
  end
end

class User
  include Validatable
  attr_accessor :name, :email, :password
  
  validates :name, presence: true, length: { minimum: 2 }
  validates :email, presence: true, format: /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :password, presence: true, length: { minimum: 8 }
  
  def initialize(name: nil, email: nil, password: nil)
    @name = name
    @email = email
    @password = password
    super()
  end
end

user = User.new(name: "J", email: "invalid-email", password: "123")
puts user.valid? # => false
puts user.errors
# => ["name must be at least 2 characters", "email format is invalid", "password must be at least 8 characters"]

This validation DSL creates validation methods dynamically based on your rules. It’s clean, readable, and extensible.

When NOT to Use Metaprogramming

Look, I love metaprogramming, but it’s not always the answer. Here’s when to pump the brakes:

Performance-critical code: Metaprogramming often has a runtime cost. If you’re processing thousands of records per second, the overhead matters.

Simple, one-off cases: If you’re only creating two similar methods, just write them explicitly. Metaprogramming for the sake of it is worse than duplication.

Team readability: If your team isn’t comfortable with metaprogramming, stick to simpler patterns. Code that nobody understands is bad code.

Debugging Metaprogrammed Code

Metaprogramming can make debugging tricky. Here are some techniques I use:

# Add some introspection to your classes
class Config
  def self.dynamic_methods
    methods.grep(/^(database_|api_|timeout)/)
  end
  
  def inspect_attributes
    instance_variables.map do |var|
      "#{var}: #{instance_variable_get(var)}"
    end.join(", ")
  end
end

puts Config.dynamic_methods
# Shows you what methods were created dynamically

config = Config.new
config.database_host = "localhost"
puts config.inspect_attributes
# => "@database_host: localhost"

Advanced Technique: Module Builder Pattern

Here’s a more advanced pattern I’ve used in production apps. It creates modules dynamically based on configuration:

class FeatureFlag
  def self.create_module(features)
    Module.new do
      features.each do |feature, enabled|
        define_method("#{feature}_enabled?") do
          enabled
        end
        
        if enabled
          define_method("with_#{feature}") do |&block|
            block.call if block_given?
          end
        else
          define_method("with_#{feature}") do |&block|
            # Do nothing if feature is disabled
          end
        end
      end
    end
  end
end

# Create a feature module
features = {
  analytics: true,
  beta_ui: false,
  new_algorithm: true
}

FeatureModule = FeatureFlag.create_module(features)

class UserController
  include FeatureModule
  
  def show
    # This code will run
    with_analytics do
      track_page_view("user_profile")
    end
    
    # This code will be skipped
    with_beta_ui do
      render_new_ui
    end
    
    puts analytics_enabled? # => true
    puts beta_ui_enabled?   # => false
  end
end

This pattern lets you toggle features at the module level, and the methods get created or skipped based on your configuration. It’s particularly useful for A/B testing or gradual feature rollouts.

Wrapping Up

Metaprogramming in Ruby isn’t magic – it’s just code that writes code. The key is knowing when to use it and when to keep things simple.

Start with the basic techniques like define_method and method_missing. Once you’re comfortable with those, you can explore more advanced patterns like the ones we covered here.

Remember: the best metaprogramming is invisible to the people using your code. If you’ve done it right, your API will feel natural and your implementation will be maintainable.