CrackedRuby logo

CrackedRuby

Block-based DSLs

Comprehensive guide to creating and implementing domain-specific languages using Ruby's block syntax and metaprogramming capabilities.

Metaprogramming DSL Creation
5.7.3

Overview

Block-based DSLs (Domain-Specific Languages) in Ruby use blocks as the primary syntax for expressing domain concepts. Ruby's blocks, combined with metaprogramming features like instance_eval, define_method, and method_missing, create expressive APIs that read like natural language within specific problem domains.

Ruby implements block-based DSLs through several core mechanisms. The Proc and Block objects capture executable code, while instance_eval and class_eval change the execution context. The BasicObject class provides a clean slate for DSL implementations by removing inherited methods that might conflict with domain-specific method names.

class DatabaseConfig
  def initialize(&block)
    @settings = {}
    instance_eval(&block) if block_given?
  end
  
  def database(name)
    @settings[:database] = name
  end
  
  def host(hostname)
    @settings[:host] = hostname
  end
end

config = DatabaseConfig.new do
  database 'production'
  host 'db.example.com'
end

Block-based DSLs excel in configuration scenarios, testing frameworks, build tools, and markup generation. The syntax appears declarative while remaining fully executable Ruby code. Popular examples include RSpec's testing DSL, Sinatra's routing DSL, and Rails' migration syntax.

The execution model centers on context switching. When Ruby encounters instance_eval(&block), it shifts the block's execution context to the receiver object. Method calls within the block resolve against the receiver's methods rather than the surrounding scope. This context manipulation creates the illusion of a specialized language while maintaining Ruby's full power underneath.

class RouteBuilder
  def initialize
    @routes = []
  end
  
  def get(path, &handler)
    @routes << { method: :get, path: path, handler: handler }
  end
  
  def post(path, &handler)
    @routes << { method: :post, path: path, handler: handler }
  end
  
  def build(&block)
    instance_eval(&block)
    @routes
  end
end

routes = RouteBuilder.new.build do
  get '/users' do
    User.all.to_json
  end
  
  post '/users' do
    User.create(params).to_json
  end
end

Basic Usage

Creating block-based DSLs requires understanding Ruby's block evaluation mechanisms. The instance_eval method executes blocks within the context of a specific object, allowing method calls to resolve against that object's methods. This forms the foundation for most DSL implementations.

class JsonBuilder
  def initialize
    @data = {}
  end
  
  def build(&block)
    instance_eval(&block)
    @data
  end
  
  def field(name, value)
    @data[name] = value
  end
  
  def nested(name, &block)
    nested_builder = JsonBuilder.new
    @data[name] = nested_builder.build(&block)
  end
end

result = JsonBuilder.new.build do
  field 'name', 'John Doe'
  field 'age', 30
  nested 'address' do
    field 'street', '123 Main St'
    field 'city', 'Portland'
  end
end
# => { 'name' => 'John Doe', 'age' => 30, 'address' => { 'street' => '123 Main St', 'city' => 'Portland' } }

The method_missing hook handles dynamic method names in DSL contexts. This technique creates flexible APIs where method names themselves carry semantic meaning. Implementing method_missing requires careful consideration of method name patterns and argument handling.

class CssBuilder
  def initialize
    @rules = {}
  end
  
  def build(&block)
    instance_eval(&block)
    @rules
  end
  
  def method_missing(property, value)
    normalized_property = property.to_s.gsub('_', '-')
    @rules[normalized_property] = value
  end
  
  def respond_to_missing?(method_name, include_private = false)
    true
  end
end

css = CssBuilder.new.build do
  background_color '#ffffff'
  font_size '14px'
  margin_top '10px'
end
# => { 'background-color' => '#ffffff', 'font-size' => '14px', 'margin-top' => '10px' }

Block parameters provide another avenue for DSL design. Yielding the DSL object to the block allows for both direct method calls and explicit object manipulation. This pattern offers flexibility in how users interact with the DSL.

class FormBuilder
  attr_reader :fields
  
  def initialize
    @fields = []
  end
  
  def build(&block)
    yield(self)
    self
  end
  
  def text_field(name, options = {})
    @fields << { type: :text, name: name, options: options }
  end
  
  def submit_button(text)
    @fields << { type: :submit, text: text }
  end
end

form = FormBuilder.new.build do |f|
  f.text_field :email, required: true
  f.text_field :password, type: 'password'
  f.submit_button 'Login'
end

Scoping becomes critical when designing DSLs that need access to variables from the calling context. The choice between instance_eval and yielding parameters affects variable access patterns. instance_eval creates a closed execution context, while yielding maintains access to local variables.

class ConfigurationDSL
  def initialize
    @config = {}
  end
  
  # Using instance_eval - no access to local variables
  def configure_isolated(&block)
    instance_eval(&block)
  end
  
  # Using yield - maintains local variable access
  def configure_with_context(&block)
    yield(self)
  end
  
  def set(key, value)
    @config[key] = value
  end
end

database_url = 'postgres://localhost/myapp'
config = ConfigurationDSL.new

# This works with yield approach
config.configure_with_context do |c|
  c.set(:database_url, database_url)
end

# This fails with instance_eval approach due to variable scoping
# config.configure_isolated do
#   set(:database_url, database_url)  # NameError: undefined local variable
# end

Advanced Usage

Advanced DSL patterns often require sophisticated metaprogramming techniques. Class-level DSLs use class_eval and define_method to dynamically create methods during class definition. This approach works well for framework-style DSLs where the DSL becomes part of the class interface.

module Validatable
  def self.included(base)
    base.extend(ClassMethods)
  end
  
  module ClassMethods
    def validates(field, **options)
      validations[field] ||= []
      validations[field] << options
      
      define_method("validate_#{field}") do
        value = instance_variable_get("@#{field}")
        options.each do |rule, constraint|
          case rule
          when :presence
            return false if constraint && (value.nil? || value.empty?)
          when :length
            return false if value.length > constraint
          when :format
            return false unless value.match?(constraint)
          end
        end
        true
      end
    end
    
    def validations
      @validations ||= {}
    end
  end
end

class User
  include Validatable
  
  validates :email, presence: true, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  validates :name, presence: true, length: 50
  
  def initialize(email, name)
    @email = email
    @name = name
  end
end

Nested DSL contexts require careful state management and context restoration. Complex DSLs often need multiple levels of nesting, each with its own execution context and state. The key challenge involves maintaining parent context access while providing isolated child contexts.

class HtmlBuilder
  attr_reader :content
  
  def initialize(tag = nil)
    @tag = tag
    @attributes = {}
    @content = []
    @children = []
  end
  
  def build(&block)
    instance_eval(&block) if block_given?
    render
  end
  
  def method_missing(tag_name, *args, &block)
    options = args.last.is_a?(Hash) ? args.pop : {}
    text_content = args.first
    
    child = HtmlBuilder.new(tag_name)
    child.attributes(options) if options.any?
    child.text(text_content) if text_content
    child.instance_eval(&block) if block_given?
    
    @children << child
  end
  
  def respond_to_missing?(method_name, include_private = false)
    true
  end
  
  def attributes(attrs)
    @attributes.merge!(attrs)
  end
  
  def text(content)
    @content << content
  end
  
  private
  
  def render
    return @content.join if @tag.nil?
    
    attrs = @attributes.map { |k, v| "#{k}=\"#{v}\"" }.join(' ')
    attr_string = attrs.empty? ? '' : " #{attrs}"
    
    content = (@content + @children.map(&:render)).join
    
    if content.empty?
      "<#{@tag}#{attr_string}/>"
    else
      "<#{@tag}#{attr_string}>#{content}</#{@tag}>"
    end
  end
end

html = HtmlBuilder.new.build do
  div class: 'container' do
    h1 'Welcome'
    p 'This is a paragraph', class: 'intro'
    ul do
      li 'First item'
      li 'Second item'
    end
  end
end

Implementing DSL inheritance requires careful method resolution and state sharing. Child DSL contexts need access to parent methods while maintaining their own specialized behavior. This pattern appears commonly in testing frameworks and configuration systems.

class BaseDSL
  def initialize(parent = nil)
    @parent = parent
    @local_data = {}
  end
  
  def method_missing(method_name, *args, &block)
    if @parent && @parent.respond_to?(method_name)
      @parent.send(method_name, *args, &block)
    else
      super
    end
  end
  
  def respond_to_missing?(method_name, include_private = false)
    (@parent && @parent.respond_to?(method_name)) || super
  end
  
  def local_method(key, value)
    @local_data[key] = value
  end
end

class ExtendedDSL < BaseDSL
  def initialize(parent = nil)
    super
    @extended_data = {}
  end
  
  def extended_method(key, value)
    @extended_data[key] = value
  end
  
  def create_child(&block)
    child = ExtendedDSL.new(self)
    child.instance_eval(&block)
    child
  end
end

parent = ExtendedDSL.new
parent.local_method(:parent_key, 'parent_value')

child = parent.create_child do
  extended_method(:child_key, 'child_value')
  local_method(:inherited_key, 'works')  # Accesses parent method
end

Fluent interfaces combine with blocks to create highly readable DSLs. Method chaining allows for compact expressions while blocks provide structured nested contexts. This combination appears frequently in query builders and configuration APIs.

class QueryBuilder
  def initialize(table = nil)
    @table = table
    @conditions = []
    @joins = []
    @order = []
    @limit_value = nil
  end
  
  def from(table)
    @table = table
    self
  end
  
  def where(condition = nil, &block)
    if block_given?
      condition_builder = ConditionBuilder.new
      condition_builder.instance_eval(&block)
      @conditions << condition_builder.build
    else
      @conditions << condition
    end
    self
  end
  
  def join(table, &block)
    join_builder = JoinBuilder.new(table)
    join_builder.instance_eval(&block) if block_given?
    @joins << join_builder.build
    self
  end
  
  def order(column, direction = :asc)
    @order << { column: column, direction: direction }
    self
  end
  
  def limit(count)
    @limit_value = count
    self
  end
  
  def build
    query = "SELECT * FROM #{@table}"
    query += " #{@joins.join(' ')}" if @joins.any?
    query += " WHERE #{@conditions.join(' AND ')}" if @conditions.any?
    query += " ORDER BY #{@order.map { |o| "#{o[:column]} #{o[:direction]}" }.join(', ')}" if @order.any?
    query += " LIMIT #{@limit_value}" if @limit_value
    query
  end
end

class ConditionBuilder
  def initialize
    @conditions = []
  end
  
  def field(name)
    FieldCondition.new(name, @conditions)
  end
  
  def build
    @conditions.join(' AND ')
  end
end

class FieldCondition
  def initialize(field, conditions)
    @field = field
    @conditions = conditions
  end
  
  def equals(value)
    @conditions << "#{@field} = '#{value}'"
  end
  
  def greater_than(value)
    @conditions << "#{@field} > #{value}"
  end
end

query = QueryBuilder.new
  .from('users')
  .join('profiles') { on 'users.id = profiles.user_id' }
  .where { field('age').greater_than(18) }
  .where("status = 'active'")
  .order('name')
  .limit(10)
  .build

Common Pitfalls

Variable scoping represents the most frequent source of confusion in block-based DSLs. When using instance_eval, local variables from the calling context become inaccessible within the block. This behavior surprises developers who expect normal Ruby block semantics.

class ProblematicDSL
  def configure(&block)
    instance_eval(&block)
  end
  
  def setting(key, value)
    puts "#{key}: #{value}"
  end
end

# This fails unexpectedly
database_url = 'postgres://localhost'
ProblematicDSL.new.configure do
  setting(:url, database_url)  # NameError: undefined local variable
end

# Solution: Use block parameters or binding
class FixedDSL
  def configure(&block)
    if block.arity > 0
      yield(self)
    else
      instance_eval(&block)
    end
  end
  
  def setting(key, value)
    puts "#{key}: #{value}"
  end
end

# This works with block parameter
FixedDSL.new.configure do |config|
  config.setting(:url, database_url)
end

Method name collisions occur when DSL method names conflict with existing Ruby methods. This problem manifests subtly because Ruby resolves method calls against the current execution context. The issue becomes particularly problematic with common method names like test, name, or type.

class ConflictingDSL
  def build(&block)
    instance_eval(&block)
  end
  
  def test(description)
    puts "Test: #{description}"
  end
  
  def name(value)
    puts "Name: #{value}"
  end
end

# This causes unexpected behavior
ConflictingDSL.new.build do
  test('user validation')  # Works as expected
  name('John')            # May not work - conflicts with Object#name
  puts class              # Prints the DSL class, not the expected context
end

# Solution: Use BasicObject or explicit method isolation
class SafeDSL < BasicObject
  def initialize
    @data = {}
  end
  
  def build(&block)
    instance_eval(&block)
    @data
  end
  
  def method_missing(method_name, *args)
    @data[method_name] = args.length == 1 ? args.first : args
  end
end

Exception handling within DSL blocks creates debugging challenges. Stack traces from within instance_eval contexts can be difficult to interpret, and exceptions may not propagate as expected. Error messages often reference unfamiliar execution contexts rather than the original calling code.

class DebuggingDSL
  def process(&block)
    begin
      instance_eval(&block)
    rescue StandardError => e
      # Re-raise with better context
      raise "Error in DSL block: #{e.message}\nOriginal backtrace: #{e.backtrace.join("\n")}"
    end
  end
  
  def dangerous_operation
    raise "Something went wrong"
  end
end

# Problematic error handling
begin
  DebuggingDSL.new.process do
    dangerous_operation
  end
rescue => e
  puts e.message  # Often unclear about the actual error location
end

Performance implications of method_missing and instance_eval can be significant in tight loops or high-frequency usage scenarios. Ruby's method lookup mechanism works harder when method_missing is involved, and context switching with instance_eval adds overhead.

class PerformanceDSL
  def initialize
    @cache = {}
  end
  
  def build(&block)
    instance_eval(&block)
  end
  
  # Slow: method_missing for every call
  def method_missing(method_name, *args)
    puts "#{method_name}: #{args.join(', ')}"
  end
  
  # Better: cache frequently used methods
  def method_missing(method_name, *args)
    return @cache[method_name].call(*args) if @cache[method_name]
    
    define_singleton_method(method_name) do |*method_args|
      puts "#{method_name}: #{method_args.join(', ')}"
    end
    
    @cache[method_name] = method(method_name)
    send(method_name, *args)
  end
end

# Performance testing would show significant differences
require 'benchmark'

dsl = PerformanceDSL.new
Benchmark.bm do |x|
  x.report("method_missing") do
    1000.times do
      dsl.build { some_method('value') }
    end
  end
end

Memory leaks can occur when DSL instances retain references to blocks or create excessive singleton methods. Block closures capture their entire binding, potentially holding onto large objects longer than necessary. Dynamically defined methods accumulate on objects without proper cleanup.

class LeakyDSL
  def initialize
    @handlers = []
  end
  
  def on(event, &block)
    # This retains the entire block binding
    @handlers << { event: event, block: block }
  end
  
  def clear_handlers
    @handlers.clear  # Important for memory management
  end
end

# Better approach with explicit cleanup
class CleanDSL
  def initialize
    @handlers = {}
  end
  
  def on(event, &block)
    @handlers[event] = block
  end
  
  def off(event)
    @handlers.delete(event)
  end
  
  def clear
    @handlers.clear
  end
end

State management becomes complex when DSL instances are shared across multiple contexts or threads. Instance variables modified during block execution can lead to unexpected side effects, especially when the same DSL instance processes multiple blocks.

class StatefulDSL
  def initialize
    @results = []
  end
  
  def process(&block)
    # Problematic: shared state between calls
    instance_eval(&block)
    @results.dup
  end
  
  def add(item)
    @results << item
  end
end

# This demonstrates the state sharing problem
dsl = StatefulDSL.new
result1 = dsl.process { add('first') }   # ['first']
result2 = dsl.process { add('second') }  # ['first', 'second'] - unexpected!

# Solution: Isolate state per execution
class IsolatedDSL
  def process(&block)
    processor = Processor.new
    processor.instance_eval(&block)
    processor.results
  end
  
  class Processor
    attr_reader :results
    
    def initialize
      @results = []
    end
    
    def add(item)
      @results << item
    end
  end
end

Reference

Core DSL Implementation Classes

Class Purpose Key Methods Usage Pattern
BasicObject Clean slate for DSL contexts method_missing, respond_to_missing? Inherit when avoiding method conflicts
Object Standard DSL base with full Ruby API instance_eval, instance_exec Use when full Ruby functionality needed
Module Class-level DSL implementation included, extended, define_method Framework-style DSLs
Proc Block capture and manipulation call, arity, binding Advanced block handling

Block Evaluation Methods

Method Parameters Returns Context Behavior
instance_eval(&block) Block Block result Executes in receiver's context
instance_exec(*args, &block) Arguments, Block Block result Executes in receiver's context with args
class_eval(&block) Block Block result Executes in class context
module_eval(&block) Block Block result Executes in module context
yield(*args) Arguments Block result Maintains original context

Method Definition Patterns

Pattern Implementation Use Case Example
Static Methods def method_name Fixed DSL vocabulary Configuration keys
Dynamic Methods define_method Runtime method creation Validation rules
Method Missing method_missing Flexible method names CSS properties
Delegation method_missing with forwarding Parent-child relationships Nested contexts

Common DSL Configurations

# Basic instance_eval DSL
class BasicDSL
  def configure(&block)
    instance_eval(&block)
  end
  
  def setting(key, value)
    # Handle setting
  end
end

# Block parameter DSL
class ParameterDSL
  def configure(&block)
    yield(self)
  end
  
  def setting(key, value)
    # Handle setting
  end
end

# Hybrid approach
class HybridDSL
  def configure(&block)
    if block.arity > 0
      yield(self)
    else
      instance_eval(&block)
    end
  end
end

Error Handling Patterns

Error Type Detection Method Common Causes Resolution Strategy
NameError Variable access in instance_eval Local variable scoping Use block parameters or binding
NoMethodError Method missing without handler Undefined DSL methods Implement method_missing
ArgumentError Block arity mismatch Wrong yielding pattern Check block.arity
LocalJumpError Return/break in wrong context Control flow in blocks Use proper block boundaries

Performance Considerations

Technique Performance Impact Memory Impact Scalability
method_missing High overhead Low Poor for frequent calls
define_method Medium setup cost Medium Good after warmup
instance_eval Medium overhead Low Good
Cached methods Low overhead Higher Excellent

Thread Safety Guidelines

DSL Pattern Thread Safety Shared State Risk Mitigation Strategy
Instance variable mutation Unsafe High Use thread-local storage
Class variable access Unsafe Very High Avoid or synchronize
Method definition Unsafe Medium Define at load time
Immutable configuration Safe None Prefer immutable patterns

Memory Management

# Proper cleanup for long-lived DSL instances
class ManagedDSL
  def initialize
    @handlers = {}
    @cache = {}
  end
  
  def cleanup
    @handlers.clear
    @cache.clear
    # Clear any singleton methods if dynamically created
    eigenclass = class << self; self; end
    eigenclass.instance_methods(false).each do |method|
      eigenclass.send(:remove_method, method)
    end
  end
end

Testing DSL Implementation

Test Category Focus Area Key Assertions Tools
Context isolation Variable scoping Local variables inaccessible RSpec, Minitest
Method resolution Method missing behavior Correct method dispatch Method call tracking
Error propagation Exception handling Stack trace clarity Custom matchers
Performance Execution speed Benchmark comparisons Ruby prof, benchmark-ips

Common Anti-Patterns

Anti-Pattern Problem Solution Example Fix
Global state mutation Thread unsafe, unpredictable Local state only Instance variables instead of class variables
Excessive method_missing Performance degradation Cache or pre-define methods Use define_method after first call
Deep context nesting Memory and complexity Flatten structure Limit nesting depth
Block parameter confusion Inconsistent API Standardize approach Choose either instance_eval or yield consistently