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 |