CrackedRuby logo

CrackedRuby

DSL Design Patterns

DSL Design Patterns in Ruby provide techniques for creating domain-specific languages through metaprogramming, method chaining, and dynamic evaluation.

Metaprogramming DSL Creation
5.7.1

Overview

Domain-specific languages in Ruby create specialized syntax for specific problem domains. Ruby's dynamic nature makes it particularly suited for DSL construction through several core mechanisms: method interception via method_missing, dynamic method definition with define_method, instance evaluation with instance_eval, and class-level method definition.

The primary classes involved in DSL construction include BasicObject for clean slate environments, Module for namespace management, and Class for behavioral definition. Ruby's block syntax provides natural boundaries for DSL contexts, while binding objects capture lexical scope for evaluation contexts.

DSL patterns fall into three main categories: external DSLs that parse custom syntax, internal DSLs that extend Ruby syntax, and embedded DSLs that operate within Ruby's grammatical constraints. Internal DSLs represent the most common approach, leveraging Ruby's flexible syntax to create domain-specific vocabularies.

# Basic DSL structure using instance_eval
class ConfigDSL
  def initialize(&block)
    @config = {}
    instance_eval(&block) if block_given?
  end
  
  def database(name)
    @config[:database] = name
  end
end

config = ConfigDSL.new do
  database "production_db"
end
# => ConfigDSL instance with configured database

The fundamental mechanism relies on Ruby's ability to intercept method calls and dynamically modify object behavior. When a DSL block executes, Ruby evaluates method calls within a controlled context, enabling domain-specific syntax while maintaining Ruby's underlying semantics.

# Method chaining pattern for fluent interfaces
class QueryBuilder
  def initialize
    @conditions = []
  end
  
  def where(condition)
    @conditions << condition
    self
  end
  
  def order(field)
    @order = field
    self
  end
  
  def to_sql
    "SELECT * FROM users WHERE #{@conditions.join(' AND ')} ORDER BY #{@order}"
  end
end

query = QueryBuilder.new.where("age > 18").where("active = true").order("name")
query.to_sql
# => "SELECT * FROM users WHERE age > 18 AND active = true ORDER BY name"

Basic Usage

Creating a basic DSL requires establishing a context for method evaluation and defining the vocabulary of available operations. The simplest approach uses instance_eval to execute a block within an object's context, making the object's methods available as DSL commands.

class TaskRunner
  def initialize
    @tasks = []
  end
  
  def task(name, &block)
    @tasks << { name: name, block: block }
  end
  
  def run_all
    @tasks.each do |task|
      puts "Running #{task[:name]}"
      task[:block].call
    end
  end
  
  def self.define(&block)
    runner = new
    runner.instance_eval(&block)
    runner
  end
end

runner = TaskRunner.define do
  task "setup" do
    puts "Setting up environment"
  end
  
  task "deploy" do
    puts "Deploying application"
  end
end

runner.run_all
# Running setup
# Setting up environment
# Running deploy  
# Deploying application

Method missing provides dynamic method handling for DSLs that need to respond to arbitrary method calls. This pattern works particularly well for configuration DSLs where the method names correspond to configuration keys.

class Configuration
  def initialize
    @settings = {}
  end
  
  def method_missing(method, *args)
    if args.length == 1
      @settings[method] = args.first
    elsif args.empty?
      @settings[method]
    else
      super
    end
  end
  
  def respond_to_missing?(method, include_private = false)
    true
  end
  
  def configure(&block)
    instance_eval(&block)
    self
  end
end

config = Configuration.new.configure do
  host "localhost"
  port 3000
  ssl_enabled true
  timeout 30
end

config.host    # => "localhost"
config.port    # => 3000

Class-level DSL methods enable configuration at the class definition level. This pattern appears frequently in testing frameworks and ORM libraries where class methods define behavior for instances.

class Validator
  def self.validates(field, **options)
    @validations ||= {}
    @validations[field] = options
  end
  
  def self.validations
    @validations || {}
  end
  
  def initialize(data)
    @data = data
  end
  
  def valid?
    self.class.validations.all? do |field, rules|
      value = @data[field]
      rules.all? { |rule, param| validate_rule(value, rule, param) }
    end
  end
  
  private
  
  def validate_rule(value, rule, param)
    case rule
    when :presence
      param ? !value.nil? && value != "" : true
    when :length
      value.to_s.length <= param
    else
      true
    end
  end
end

class UserValidator < Validator
  validates :name, presence: true, length: 50
  validates :email, presence: true, length: 100
end

validator = UserValidator.new(name: "John", email: "john@example.com")
validator.valid?  # => true

Block-based DSLs create nested contexts for hierarchical configuration. The DSL object maintains state while blocks establish scope boundaries for different configuration sections.

class RouteMapper
  def initialize
    @routes = {}
  end
  
  def namespace(prefix, &block)
    old_prefix = @current_prefix
    @current_prefix = [@current_prefix, prefix].compact.join("/")
    instance_eval(&block)
    @current_prefix = old_prefix
  end
  
  def get(path, to:)
    full_path = [@current_prefix, path].compact.join("/")
    @routes[full_path] = { method: :get, controller: to }
  end
  
  def post(path, to:)
    full_path = [@current_prefix, path].compact.join("/")
    @routes[full_path] = { method: :post, controller: to }
  end
  
  def routes
    @routes
  end
end

mapper = RouteMapper.new
mapper.instance_eval do
  namespace "api" do
    namespace "v1" do
      get "users", to: "users#index"
      post "users", to: "users#create"
    end
  end
  
  get "health", to: "health#check"
end

mapper.routes
# => {
#   "api/v1/users" => { method: :get, controller: "users#index" },
#   "api/v1/users" => { method: :post, controller: "users#create" },
#   "health" => { method: :get, controller: "health#check" }
# }

Advanced Usage

Advanced DSL patterns involve sophisticated metaprogramming techniques that create more flexible and powerful domain-specific languages. Dynamic method generation allows DSLs to create methods based on configuration or external data sources.

class APIClient
  def initialize(base_url)
    @base_url = base_url
    @endpoints = {}
  end
  
  def endpoint(name, path, method: :get, **options)
    @endpoints[name] = { path: path, method: method, options: options }
    
    define_singleton_method(name) do |**params|
      url = @base_url + interpolate_path(@endpoints[name][:path], params)
      
      case @endpoints[name][:method]
      when :get
        perform_get(url, params)
      when :post
        perform_post(url, params)
      when :put
        perform_put(url, params)
      when :delete
        perform_delete(url, params)
      end
    end
    
    if @endpoints[name][:options][:collection]
      collection_name = "#{name}_collection"
      define_singleton_method(collection_name) do |**params|
        send(name, **params).map { |item| OpenStruct.new(item) }
      end
    end
  end
  
  private
  
  def interpolate_path(path, params)
    result = path.dup
    params.each do |key, value|
      result.gsub!(":#{key}", value.to_s)
    end
    result
  end
  
  def perform_get(url, params)
    # Simulate HTTP GET
    { status: "success", url: url, params: params }
  end
  
  def perform_post(url, params)
    # Simulate HTTP POST
    { status: "created", url: url, params: params }
  end
  
  def perform_put(url, params)
    # Simulate HTTP PUT
    { status: "updated", url: url, params: params }
  end
  
  def perform_delete(url, params)
    # Simulate HTTP DELETE
    { status: "deleted", url: url, params: params }
  end
end

client = APIClient.new("https://api.example.com")
client.instance_eval do
  endpoint :user, "/users/:id", method: :get
  endpoint :users, "/users", method: :get, collection: true
  endpoint :create_user, "/users", method: :post
  endpoint :update_user, "/users/:id", method: :put
  endpoint :delete_user, "/users/:id", method: :delete
end

result = client.user(id: 123)
# => { status: "success", url: "https://api.example.com/users/123", params: { id: 123 } }

users = client.users_collection
# Returns collection of OpenStruct objects

Context isolation prevents DSL method pollution by using BasicObject as a base class, which provides a minimal method set and reduces namespace conflicts.

class CleanDSL < BasicObject
  def initialize
    @commands = []
  end
  
  def method_missing(method, *args, &block)
    if block
      nested_dsl = self.class.new
      nested_dsl.instance_eval(&block)
      @commands << { method: method, args: args, nested: nested_dsl.commands }
    else
      @commands << { method: method, args: args }
    end
    self
  end
  
  def commands
    @commands
  end
  
  def inspect
    "#<CleanDSL commands=#{@commands.inspect}>"
  end
end

dsl = CleanDSL.new
result = dsl.instance_eval do
  server "web-1" do
    port 80
    ssl_port 443
    location "/api" do
      proxy_pass "backend:3000"
      timeout 30
    end
  end
  
  server "web-2" do
    port 8080
  end
end

result.commands
# => [
#   {
#     method: :server,
#     args: ["web-1"],
#     nested: [
#       { method: :port, args: [80] },
#       { method: :ssl_port, args: [443] },
#       { 
#         method: :location,
#         args: ["/api"],
#         nested: [
#           { method: :proxy_pass, args: ["backend:3000"] },
#           { method: :timeout, args: [30] }
#         ]
#       }
#     ]
#   },
#   {
#     method: :server,
#     args: ["web-2"],
#     nested: [
#       { method: :port, args: [8080] }
#     ]
#   }
# ]

Scope preservation maintains access to surrounding variables while executing DSL blocks. This requires careful management of binding contexts to balance DSL functionality with variable accessibility.

class TemplateEngine
  def initialize(template_string)
    @template = template_string
    @helpers = {}
  end
  
  def helper(name, &block)
    @helpers[name] = block
  end
  
  def render(context = {})
    renderer = Renderer.new(@helpers, context)
    @template.gsub(/\{\{(.+?)\}\}/) do |match|
      expression = $1.strip
      renderer.evaluate(expression)
    end
  end
  
  class Renderer
    def initialize(helpers, context)
      @helpers = helpers
      @context = context
    end
    
    def evaluate(expression)
      # Create a binding with access to context variables and helpers
      binding_context = Object.new
      
      @context.each do |key, value|
        binding_context.define_singleton_method(key) { value }
      end
      
      @helpers.each do |name, block|
        binding_context.define_singleton_method(name, &block)
      end
      
      binding_context.instance_eval(expression).to_s
    rescue => e
      "[Error: #{e.message}]"
    end
  end
end

template = TemplateEngine.new("Hello {{name}}, today is {{format_date(date)}}!")

template.helper :format_date do |date|
  date.strftime("%B %d, %Y")
end

output = template.render(name: "Alice", date: Date.new(2023, 12, 25))
# => "Hello Alice, today is December 25, 2023!"

Lazy evaluation patterns defer computation until values are actually needed, which proves essential for DSLs that build complex execution graphs or dependency chains.

class Pipeline
  def initialize
    @stages = []
  end
  
  def stage(name, &block)
    @stages << Stage.new(name, block)
    self
  end
  
  def execute(input)
    @stages.reduce(input) do |data, stage|
      stage.call(data)
    end
  end
  
  class Stage
    def initialize(name, block)
      @name = name
      @block = block
      @executed = false
      @result = nil
    end
    
    def call(input)
      return @result if @executed
      
      puts "Executing stage: #{@name}"
      @result = @block.call(input)
      @executed = true
      @result
    end
  end
  
  def self.build(&block)
    pipeline = new
    pipeline.instance_eval(&block)
    pipeline
  end
end

data_pipeline = Pipeline.build do
  stage "parse" do |input|
    input.split(",").map(&:strip)
  end
  
  stage "filter" do |input|
    input.reject(&:empty?)
  end
  
  stage "transform" do |input|
    input.map(&:upcase)
  end
  
  stage "aggregate" do |input|
    input.join(" | ")
  end
end

result = data_pipeline.execute("apple, banana, , cherry,  ")
# Executing stage: parse
# Executing stage: filter  
# Executing stage: transform
# Executing stage: aggregate
# => "APPLE | BANANA | CHERRY"

Common Pitfalls

Variable scoping represents one of the most frequent DSL implementation issues. When using instance_eval, local variables from the calling context become inaccessible within the DSL block, often causing unexpected NameError exceptions.

# Problematic DSL that loses variable access
class BadDSL
  def configure(&block)
    instance_eval(&block)
  end
  
  def setting(key, value)
    puts "Setting #{key} = #{value}"
  end
end

database_name = "production_db"

# This will raise NameError: undefined local variable `database_name`
# BadDSL.new.configure do
#   setting :database, database_name
# end

# Solution: Use yield instead of instance_eval when variable access is needed
class GoodDSL
  def configure(&block)
    yield(self)
  end
  
  def setting(key, value)
    puts "Setting #{key} = #{value}"
  end
end

database_name = "production_db"

GoodDSL.new.configure do |config|
  config.setting :database, database_name
end
# Setting database = production_db

Method collision occurs when DSL methods conflict with existing Ruby methods or when multiple DSL contexts define the same method names. This problem becomes particularly acute with method_missing implementations that are too permissive.

# Dangerous method_missing implementation
class ProblematicDSL
  def method_missing(method, *args)
    @config ||= {}
    @config[method] = args.first
  end
end

dsl = ProblematicDSL.new
dsl.name "test"          # Works as expected
dsl.class "MyClass"      # Overwrites Object#class method!
puts dsl.class           # Returns "MyClass" instead of class object

# Safe method_missing with explicit whitelist
class SafeDSL
  ALLOWED_METHODS = [:name, :host, :port, :timeout].freeze
  
  def method_missing(method, *args)
    if ALLOWED_METHODS.include?(method)
      @config ||= {}
      @config[method] = args.first
    else
      super
    end
  end
  
  def respond_to_missing?(method, include_private = false)
    ALLOWED_METHODS.include?(method) || super
  end
end

safe_dsl = SafeDSL.new
safe_dsl.name "test"     # Works
safe_dsl.host "localhost" # Works
# safe_dsl.class "MyClass" # Raises NoMethodError - safer behavior

Block context confusion arises when DSL users expect different evaluation contexts than what the DSL provides. This commonly happens when mixing instance_eval, class_eval, and regular block calls.

# Confusing context switching
class ContextConfusingDSL
  def initialize
    @items = []
  end
  
  def item(name, &block)
    # This creates confusing context switches
    item_obj = Item.new(name)
    if block
      # Sometimes uses instance_eval
      item_obj.instance_eval(&block) if name.start_with?("config")
      # Sometimes uses yield
      yield(item_obj) unless name.start_with?("config")
    end
    @items << item_obj
  end
  
  class Item
    def initialize(name)
      @name = name
      @properties = {}
    end
    
    def property(key, value)
      @properties[key] = value
    end
  end
end

# Clear, consistent context handling
class ClearDSL  
  def initialize
    @items = []
  end
  
  def item(name, &block)
    item_obj = Item.new(name)
    # Always use the same evaluation strategy
    item_obj.configure(&block) if block
    @items << item_obj
  end
  
  class Item
    def initialize(name)
      @name = name  
      @properties = {}
    end
    
    def configure(&block)
      instance_eval(&block)
    end
    
    def property(key, value)
      @properties[key] = value
    end
  end
end

clear_dsl = ClearDSL.new
clear_dsl.item "server" do
  property :host, "localhost"
  property :port, 8080
end

Memory leaks can occur in DSLs that dynamically generate methods without proper cleanup, especially when processing large amounts of configuration data or when DSL instances persist longer than expected.

# Memory leak prone DSL
class LeakyDSL
  def initialize
    @dynamic_methods = []
  end
  
  def define_accessor(name, value)
    method_name = "get_#{name}"
    # This creates method objects that aren't cleaned up
    define_singleton_method(method_name) { value }
    @dynamic_methods << method_name
  end
  
  # Missing cleanup mechanism leads to method accumulation
end

# Memory-conscious DSL with cleanup
class CleanDSL
  def initialize
    @dynamic_methods = []
    @config = {}
  end
  
  def define_accessor(name, value)
    # Store in hash instead of creating methods
    @config[name] = value
  end
  
  def get(name)
    @config[name]  
  end
  
  def clear
    # Explicit cleanup method
    @config.clear
    @dynamic_methods.each do |method_name|
      singleton_class.send(:remove_method, method_name) if respond_to?(method_name)
    end
    @dynamic_methods.clear
  end
end

Thread safety issues emerge when DSL objects maintain mutable shared state, particularly with class-level DSL methods that modify class variables or constants.

# Thread-unsafe DSL
class UnsafeDSL
  @@config = {}
  
  def self.setting(key, value)
    # Race condition: multiple threads can interfere
    @@config[key] = value
  end
  
  def self.config
    @@config
  end
end

# Thread-safe DSL using instance-level state
class SafeDSL
  def initialize
    @config = {}
    @mutex = Mutex.new
  end
  
  def setting(key, value)
    @mutex.synchronize do
      @config[key] = value
    end
  end
  
  def config
    @mutex.synchronize do
      @config.dup
    end
  end
end

Reference

Core DSL Methods

Method Parameters Returns Description
instance_eval(&block) block (Proc) Object Evaluates block in receiver's context
class_eval(&block) block (Proc) Object Evaluates block in class context
define_method(name, &block) name (Symbol), block (Proc) Symbol Dynamically defines instance method
define_singleton_method(name, &block) name (Symbol), block (Proc) Symbol Dynamically defines singleton method
method_missing(method, *args, &block) method (Symbol), args (Array), block (Proc) Object Intercepts undefined method calls
respond_to_missing?(method, include_private) method (Symbol), include_private (Boolean) Boolean Indicates method_missing handling

Evaluation Contexts

Context Type Method Variable Access Method Access Use Case
Instance instance_eval No local vars Receiver methods Configuration DSLs
Class class_eval No local vars Class methods Class definition DSLs
Block yield Local vars Caller methods Callback DSLs
Binding eval(string, binding) Binding vars Binding methods Template DSLs

Common DSL Patterns

Pattern Implementation Complexity Thread Safety Memory Usage
Method Missing method_missing override Low Instance-safe Low
Dynamic Methods define_method calls Medium Requires mutex Medium
Block Evaluation instance_eval with blocks Low Instance-safe Low
Clean Slate BasicObject inheritance High Instance-safe Low
Fluent Interface Method chaining with self Low Instance-safe Low
Nested Contexts Recursive DSL objects High Instance-safe Medium

Error Handling Patterns

Error Type Detection Method Recovery Strategy Prevention
NameError Method existence check Graceful fallback Whitelist methods
NoMethodError respond_to? check Default behavior Proper respond_to_missing?
ArgumentError Parameter validation Parameter defaults Arity checking
Context Loss Variable accessibility test Block parameter passing Use yield over instance_eval
Memory Leak Method count monitoring Explicit cleanup Avoid dynamic method creation

Performance Characteristics

Operation Time Complexity Memory Impact Optimization
method_missing call O(1) Low Cache method definitions
define_method call O(1) Medium Limit dynamic methods
instance_eval call O(1) Low Reuse context objects
Method lookup O(log n) Low Use direct method calls
Block evaluation O(1) Low Avoid nested evaluations

Thread Safety Guidelines

Scenario Safety Level Recommended Approach Synchronization
Instance variables Safe Per-instance state None required
Class variables Unsafe Instance variables Mutex required
Global state Unsafe Avoid or synchronize Mutex required
Method definition Context-dependent Instance methods preferred Class-level mutex
Constant modification Unsafe Immutable constants None possible

Memory Management

Resource Type Lifecycle Cleanup Strategy Monitoring
Dynamic methods Object lifetime remove_method calls Method count tracking
Instance variables Object lifetime Explicit nil assignment Object size monitoring
Block references Block scope Avoid circular references WeakRef usage
Class state Class lifetime Reset class variables Class inspection
Binding objects Evaluation lifetime Scope limitation Binding count tracking