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.