Ruby Logo

Class Macros & DSL Building

Learn define_method, class_eval, instance_eval and build powerful Domain-Specific Languages.

Home Ruby Class Macros & DSL Building

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.

Quick Navigation

Related Topics

Video Tutorial

Watch and learn class macros & dsl building

Pro Tip: After reading through the content above, watch this video to reinforce your understanding and see the concepts in action!