Class Macros & DSL Building
Why Master Class Macros & DSL Building?
Class macros and Domain-Specific Languages (DSLs) are powerful metaprogramming techniques that enable expressive, readable code. They form the backbone of popular Ruby frameworks and libraries, allowing developers to write code that feels natural and domain-specific.
Framework Development
Rails, RSpec, Sinatra - all built with class macros
Code Expressiveness
Write self-documenting, declarative code
DRY Principle
Eliminate repetitive boilerplate code
define_method - Dynamic Method Creation
define_method creates methods dynamically at runtime. Unlike regular def statements, it can access local variables from the surrounding scope and enables powerful metaprogramming patterns.
Basic define_method Usage
class Calculator
# Define basic arithmetic methods dynamically
[:add, :subtract, :multiply, :divide].each do |operation|
define_method(operation) do |a, b|
case operation
when :add then a + b
when :subtract then a - b
when :multiply then a * b
when :divide then b != 0 ? a / b : raise(ZeroDivisionError)
end
end
end
end
calc = Calculator.new
puts calc.add(5, 3) # => 8
puts calc.multiply(4, 7) # => 28
puts calc.divide(10, 2) # => 5
Attribute Accessor Macro
module CustomAttr
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def typed_attr(name, type)
# Define getter
define_method(name) do
instance_variable_get("@#{name}")
end
# Define setter with type checking
define_method("#{name}=") do |value|
unless value.is_a?(type)
raise TypeError, "Expected #{type}, got #{value.class}"
end
instance_variable_set("@#{name}", value)
end
# Define predicate method for boolean types
if type == TrueClass || type == FalseClass
define_method("#{name}?") do
!!instance_variable_get("@#{name}")
end
end
end
end
end
class User
include CustomAttr
typed_attr :name, String
typed_attr :age, Integer
typed_attr :active, TrueClass
def initialize(name, age)
self.name = name
self.age = age
self.active = true
end
end
user = User.new("Alice", 30)
puts user.name # => "Alice"
puts user.active? # => true
# user.age = "thirty" # TypeError: Expected Integer, got String
Delegation Pattern with define_method
module Delegator
def delegate(*methods, to:)
methods.each do |method_name|
define_method(method_name) do |*args, &block|
target = instance_variable_get("@#{to}")
target.send(method_name, *args, &block)
end
end
end
end
class OrderProcessor
extend Delegator
def initialize(payment_gateway, notification_service)
@payment_gateway = payment_gateway
@notification_service = notification_service
end
# Delegate payment methods to payment gateway
delegate :charge, :refund, :verify_payment, to: :payment_gateway
# Delegate notification methods to notification service
delegate :send_email, :send_sms, to: :notification_service
def process_order(order)
charge(order.amount)
send_email(order.customer_email, "Order confirmed")
end
end
class PaymentGateway
def charge(amount)
puts "Charging $#{amount}"
end
def refund(amount)
puts "Refunding $#{amount}"
end
def verify_payment(payment_id)
puts "Verifying payment #{payment_id}"
end
end
class NotificationService
def send_email(email, message)
puts "Sending email to #{email}: #{message}"
end
def send_sms(phone, message)
puts "Sending SMS to #{phone}: #{message}"
end
end
gateway = PaymentGateway.new
notifications = NotificationService.new
processor = OrderProcessor.new(gateway, notifications)
processor.charge(99.99) # Delegated to payment gateway
processor.send_email("customer@example.com", "Thank you!") # Delegated to notifications
class_eval & instance_eval - Dynamic Code Evaluation
class_eval and instance_eval allow dynamic code evaluation in different contexts. They're essential for advanced metaprogramming but should be used carefully to avoid security and maintenance issues.
class_eval for Dynamic Class Definition
class ModelBuilder
def self.build_model(name, attributes)
# Create a new class dynamically
model_class = Class.new do
# Use class_eval to define methods in the class context
class_eval do
attr_reader :attributes
define_method :initialize do |attrs = {}|
@attributes = {}
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
end
# Create accessors for each attribute
attributes.each do |attr_name, attr_type|
define_method attr_name do
@attributes[attr_name]
end
define_method "#{attr_name}=" do |value|
# Type validation
unless value.is_a?(attr_type) || value.nil?
raise TypeError, "#{attr_name} must be a #{attr_type}"
end
@attributes[attr_name] = value
end
# Validation method
define_method "valid_#{attr_name}?" do
value = @attributes[attr_name]
!value.nil? && value.is_a?(attr_type)
end
end
define_method :valid? do
attributes.keys.all? { |attr| send("valid_#{attr}?") }
end
define_method :to_h do
@attributes.dup
end
end
end
# Set the class name
Object.const_set(name, model_class)
model_class
end
end
# Build a User model dynamically
User = ModelBuilder.build_model('User', {
name: String,
email: String,
age: Integer
})
user = User.new(name: "John", email: "john@example.com", age: 30)
puts user.name # => "John"
puts user.valid? # => true
puts user.to_h # => {:name=>"John", :email=>"john@example.com", :age=>30}
instance_eval for Object Extension
class ConfigBuilder
def initialize
@config = {}
end
def build(&block)
# Use instance_eval to evaluate the block in the context of this object
instance_eval(&block) if block_given?
@config
end
# Define configuration methods dynamically
def method_missing(method_name, *args, &block)
if block_given?
# Nested configuration
nested_config = ConfigBuilder.new
@config[method_name] = nested_config.build(&block)
elsif args.length == 1
# Simple key-value assignment
@config[method_name] = args.first
elsif args.empty?
# Getter
@config[method_name]
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
true
end
end
# Usage - creates a mini DSL
config = ConfigBuilder.new.build do
app_name "MyAwesomeApp"
version "1.0.0"
debug true
database do
host "localhost"
port 5432
name "myapp_production"
pool do
size 10
timeout 30
end
end
cache do
provider "redis"
url "redis://localhost:6379"
end
end
puts config
# => {
# :app_name=>"MyAwesomeApp",
# :version=>"1.0.0",
# :debug=>true,
# :database=>{:host=>"localhost", :port=>5432, :name=>"myapp_production",
# :pool=>{:size=>10, :timeout=>30}},
# :cache=>{:provider=>"redis", :url=>"redis://localhost:6379"}
# }
Safe String Evaluation with Binding
class TemplateProcessor
def initialize(context = {})
@context = context
end
def process_template(template)
# Create a clean binding with only the context variables
clean_binding = create_clean_binding(@context)
# Process template with variable substitution
processed = template.gsub(/\{\{(\w+)\}\}/) do |match|
var_name = $1
if @context.key?(var_name.to_sym)
@context[var_name.to_sym]
else
match # Leave unchanged if variable not found
end
end
# Process embedded Ruby code (be very careful with this!)
processed.gsub(/\<\%(.+?)\%\>/) do |match|
code = $1.strip
# Only allow safe operations
if safe_code?(code)
eval(code, clean_binding)
else
""
end
end
end
private
def create_clean_binding(context)
# Create a minimal binding with only the context variables
Object.new.instance_eval do
context.each { |k, v| define_singleton_method(k) { v } }
binding
end
end
def safe_code?(code)
# Very basic safety check - in production, use a proper sandbox
forbidden_patterns = [
/system|exec|`|eval|load|require/,
/File|Dir|IO/,
/const_get|const_set|class_eval|instance_eval/
]
forbidden_patterns.none? { |pattern| code.match?(pattern) }
end
end
processor = TemplateProcessor.new(
name: "Alice",
score: 95,
items: ["apple", "banana", "cherry"]
)
template = <<~TEMPLATE
Hello {{name}}!
Your score is {{score}}.
<% if score > 90 %>
Congratulations on your excellent performance!
<% end %>
You have <% items.length %> items in your cart.
TEMPLATE
result = processor.process_template(template)
puts result
# => Hello Alice!
# Your score is 95.
# Congratulations on your excellent performance!
# You have 3 items in your cart.
Building Domain-Specific Languages (DSLs)
HTTP API Client DSL
class ApiClient
def initialize(base_url)
@base_url = base_url
@endpoints = {}
end
def endpoint(name, &block)
endpoint_builder = EndpointBuilder.new
endpoint_builder.instance_eval(&block)
@endpoints[name] = endpoint_builder.build
end
def method_missing(method_name, *args, &block)
if @endpoints.key?(method_name)
execute_endpoint(method_name, *args)
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
@endpoints.key?(method_name) || super
end
private
def execute_endpoint(name, params = {})
config = @endpoints[name]
url = @base_url + config[:path]
# Replace path parameters
config[:params].each do |param|
if params[param]
url = url.gsub(":#{param}", params[param].to_s)
params.delete(param)
end
end
puts "#{config[:method].upcase} #{url}"
puts "Headers: #{config[:headers]}" if config[:headers].any?
puts "Body: #{params}" if params.any? && config[:method] != :get
# In real implementation, make actual HTTP request
{ status: 200, data: "Response from #{name}" }
end
class EndpointBuilder
def initialize
@config = {
method: :get,
path: '',
params: [],
headers: {}
}
end
def method(http_method)
@config[:method] = http_method
end
def path(endpoint_path)
@config[:path] = endpoint_path
# Extract path parameters
@config[:params] = endpoint_path.scan(/:(\w+)/).flatten.map(&:to_sym)
end
def headers(header_hash)
@config[:headers].merge!(header_hash)
end
def requires(*required_params)
@config[:required_params] = required_params
end
def build
@config
end
end
end
# Define API using the DSL
client = ApiClient.new("https://api.example.com")
client.endpoint :get_user do
method :get
path "/users/:id"
headers "Accept" => "application/json"
end
client.endpoint :create_user do
method :post
path "/users"
headers "Content-Type" => "application/json"
requires :name, :email
end
client.endpoint :update_user do
method :put
path "/users/:id"
headers "Content-Type" => "application/json"
end
# Use the API
client.get_user(id: 123)
# => GET https://api.example.com/users/123
# Headers: {"Accept"=>"application/json"}
client.create_user(name: "John", email: "john@example.com")
# => POST https://api.example.com/users
# Headers: {"Content-Type"=>"application/json"}
# Body: {:name=>"John", :email=>"john@example.com"}
Business Rules DSL
class RuleEngine
def initialize
@rules = []
end
def rule(name, &block)
rule_builder = RuleBuilder.new(name)
rule_builder.instance_eval(&block)
@rules << rule_builder.build
end
def evaluate(context)
results = {}
@rules.each do |rule|
begin
result = rule[:condition].call(context)
results[rule[:name]] = result
if result
rule[:actions].each { |action| action.call(context) }
end
rescue => e
results[rule[:name]] = { error: e.message }
end
end
results
end
class RuleBuilder
def initialize(name)
@name = name
@condition = proc { true }
@actions = []
end
def when(&condition_block)
@condition = condition_block
end
def then(&action_block)
@actions << action_block
end
def build
{
name: @name,
condition: @condition,
actions: @actions
}
end
end
end
# Define business rules using the DSL
rules = RuleEngine.new
rules.rule :discount_eligibility do
when { |ctx| ctx[:total_spent] > 1000 && ctx[:customer_tier] == :premium }
then { |ctx| ctx[:discount] = 0.15 }
then { |ctx| puts "Applied 15% premium customer discount" }
end
rules.rule :free_shipping do
when { |ctx| ctx[:order_total] > 50 }
then { |ctx| ctx[:shipping_cost] = 0 }
then { |ctx| puts "Free shipping applied" }
end
rules.rule :loyalty_points do
when { |ctx| ctx[:customer_tier] != :guest }
then { |ctx| ctx[:loyalty_points] = (ctx[:order_total] * 0.1).round }
then { |ctx| puts "Earned #{ctx[:loyalty_points]} loyalty points" }
end
# Evaluate rules for a customer order
order_context = {
customer_tier: :premium,
total_spent: 1500,
order_total: 75,
discount: 0,
shipping_cost: 10,
loyalty_points: 0
}
results = rules.evaluate(order_context)
puts "\nRule Results: #{results}"
puts "Final Context: #{order_context}"
# Output:
# Applied 15% premium customer discount
# Free shipping applied
# Earned 7 loyalty points
#
# Rule Results: {:discount_eligibility=>true, :free_shipping=>true, :loyalty_points=>true}
# Final Context: {:customer_tier=>:premium, :total_spent=>1500, :order_total=>75, :discount=>0.15, :shipping_cost=>0, :loyalty_points=>7}
Advanced Macro Patterns
Validation Macro System
module Validations
def self.included(base)
base.extend(ClassMethods)
base.class_eval do
attr_reader :errors
def initialize(*args)
@errors = []
super
end
end
end
module ClassMethods
def validates(attribute, **options)
validations[attribute] ||= []
validations[attribute] << options
# Define validation method if not exists
unless method_defined?(:valid?)
define_method :valid? do
@errors.clear
self.class.validations.each do |attr, rules|
value = send(attr)
rules.each { |rule| validate_rule(attr, value, rule) }
end
@errors.empty?
end
end
# Define attribute-specific validation method
define_method "valid_#{attribute}?" do
temp_errors = []
value = send(attribute)
self.class.validations[attribute].each do |rule|
validate_rule(attribute, value, rule, temp_errors)
end
temp_errors.empty?
end
end
def validations
@validations ||= {}
end
end
private
def validate_rule(attribute, value, rule, error_list = @errors)
rule.each do |validator, constraint|
case validator
when :presence
if constraint && (value.nil? || value.to_s.strip.empty?)
error_list << "#{attribute} can't be blank"
end
when :length
if value.respond_to?(:length)
if constraint[:minimum] && value.length < constraint[:minimum]
error_list << "#{attribute} is too short (minimum #{constraint[:minimum]} characters)"
end
if constraint[:maximum] && value.length > constraint[:maximum]
error_list << "#{attribute} is too long (maximum #{constraint[:maximum]} characters)"
end
end
when :format
if value && !value.match?(constraint)
error_list << "#{attribute} is invalid format"
end
when :inclusion
if value && !constraint.include?(value)
error_list << "#{attribute} is not included in the list"
end
when :custom
begin
constraint.call(value) unless value.nil?
rescue => e
error_list << "#{attribute} #{e.message}"
end
end
end
end
end
class User
include Validations
attr_accessor :name, :email, :age, :role
validates :name, presence: true, length: { minimum: 2, maximum: 50 }
validates :email, presence: true, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :age, custom: ->(age) { raise "must be between 13 and 120" unless age.between?(13, 120) }
validates :role, inclusion: %w[admin user guest]
def initialize(name: nil, email: nil, age: nil, role: 'user')
self.name = name
self.email = email
self.age = age
self.role = role
super()
end
end
# Valid user
user1 = User.new(name: "Alice", email: "alice@example.com", age: 25)
puts user1.valid? # => true
# Invalid user
user2 = User.new(name: "A", email: "invalid-email", age: 150, role: "superuser")
puts user2.valid? # => false
puts user2.errors
# => ["name is too short (minimum 2 characters)",
# "email is invalid format",
# "age must be between 13 and 120",
# "role is not included in the list"]
# Check specific attribute
puts user2.valid_email? # => false
Event System with Macro-defined Handlers
module EventSystem
def self.included(base)
base.extend(ClassMethods)
base.class_eval do
def initialize(*args)
@event_handlers = self.class.event_handlers.dup
super
end
end
end
module ClassMethods
def on(event_name, method_name = nil, &block)
event_handlers[event_name] ||= []
if block_given?
# Anonymous handler
event_handlers[event_name] << block
elsif method_name
# Named method handler
event_handlers[event_name] << method_name
else
raise ArgumentError, "Must provide either method name or block"
end
end
def event_handlers
@event_handlers ||= Hash.new { |h, k| h[k] = [] }
end
end
def trigger(event_name, *args)
handlers = @event_handlers[event_name] || []
results = []
handlers.each do |handler|
begin
result = case handler
when Symbol
send(handler, *args)
when Proc
instance_exec(*args, &handler)
else
handler.call(*args)
end
results << { handler: handler, result: result, success: true }
rescue => e
results << { handler: handler, error: e.message, success: false }
end
end
results
end
end
class OrderProcessor
include EventSystem
# Define event handlers using the macro
on :order_created do |order|
puts "📧 Sending confirmation email for order #{order[:id]}"
"Email sent"
end
on :order_created, :update_inventory
on :order_created do |order|
if order[:amount] > 100
puts "🎉 High-value order detected: $#{order[:amount]}"
end
end
on :order_shipped, :send_tracking_info
on :order_shipped, :update_customer_points
def process_order(order_data)
puts "Processing order #{order_data[:id]}..."
# Simulate order processing
order_data[:status] = :confirmed
# Trigger order created event
trigger(:order_created, order_data)
# Simulate shipping
order_data[:status] = :shipped
order_data[:tracking_number] = "TRACK#{rand(1000000)}"
# Trigger order shipped event
trigger(:order_shipped, order_data)
end
private
def update_inventory(order)
puts "📦 Updating inventory for order #{order[:id]}"
"Inventory updated"
end
def send_tracking_info(order)
puts "🚚 Sending tracking info: #{order[:tracking_number]}"
"Tracking info sent"
end
def update_customer_points(order)
points = (order[:amount] * 0.01).round
puts "⭐ Customer earned #{points} points"
"Points updated"
end
end
processor = OrderProcessor.new
order = { id: 12345, amount: 150, customer_id: 67890 }
processor.process_order(order)
# Output:
# Processing order 12345...
# 📧 Sending confirmation email for order 12345
# 📦 Updating inventory for order 12345
# 🎉 High-value order detected: $150
# 🚚 Sending tracking info: TRACK438572
# ⭐ Customer earned 1 points
Best Practices & Guidelines
✅ Design Principles
- Expressiveness over cleverness: DSLs should make code more readable, not more complex
- Fail fast: Validate DSL syntax and parameters early with clear error messages
- Documentation: Provide clear examples and explain the DSL's purpose and limitations
- Backward compatibility: Consider versioning for DSL changes
⚠️ Performance Considerations
- Method caching: Cache dynamically defined methods when possible
- Evaluation context: Be mindful of scope and binding in eval contexts
- Memory usage: Consider memory implications of storing closures and contexts
- Benchmark: Profile metaprogramming-heavy code paths
🔒 Security Guidelines
- Never eval user input: Always sanitize and validate any dynamic code
- Restricted contexts: Use clean bindings and limit available methods
- Input validation: Validate all DSL parameters and method names
- Sandboxing: Consider using safe evaluation libraries for complex DSLs
Real-world Framework Examples
Rails ActiveRecord
class User < ActiveRecord::Base
validates :email, presence: true
has_many :posts
scope :active, -> { where(active: true) }
before_save :normalize_email
end
Uses class macros for associations, validations, callbacks, and scopes.
RSpec Testing DSL
describe User do
let(:user) { create(:user) }
it "validates email presence" do
user.email = nil
expect(user).not_to be_valid
end
end
Creates expressive test syntax using instance_eval and method_missing.
Sinatra Routing DSL
get '/users/:id' do
user = User.find(params[:id])
user.to_json
end
post '/users' do
User.create(params).to_json
end
Uses class_eval to define HTTP verb methods that register route handlers.