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 |