Overview
Method Chaining DSLs in Ruby create readable, expressive interfaces by returning objects that support further method calls. Each method in the chain typically returns either the same object (for mutation) or a new object (for immutability), enabling developers to build complex configurations and operations through sequential method calls.
Ruby's method chaining DSL pattern relies on several core mechanisms. Methods return objects that respond to subsequent method calls, creating a fluent interface. The pattern often employs the Builder pattern, where each method call modifies internal state and returns self
or creates new instances with updated state.
The method_missing
hook enables dynamic method generation, while define_method
allows runtime method creation. These metaprogramming features support flexible DSL construction where method names and behavior can be determined at runtime based on domain requirements.
class QueryBuilder
def initialize
@conditions = []
@orders = []
end
def where(condition)
@conditions << condition
self
end
def order(field)
@orders << field
self
end
def to_sql
sql = "SELECT * FROM table"
sql += " WHERE #{@conditions.join(' AND ')}" unless @conditions.empty?
sql += " ORDER BY #{@orders.join(', ')}" unless @orders.empty?
sql
end
end
# Basic chaining
query = QueryBuilder.new.where("age > 18").where("status = 'active'").order("name")
query.to_sql
# => "SELECT * FROM table WHERE age > 18 AND status = 'active' ORDER BY name"
Common DSL patterns include configuration builders, query builders, and validation chains. Each pattern addresses specific domain requirements while maintaining the core principle of method chaining for readability and expressiveness.
Ruby's syntax supports DSL creation through block-based configuration, optional parentheses, and flexible method definitions. These language features enable DSLs that read like natural language while maintaining full programmatic functionality.
Basic Usage
Method chaining DSLs typically start with a builder class that accumulates state through method calls. Each method modifies the internal state and returns an object that supports further chaining. The pattern separates configuration from execution, allowing complex setups through readable method sequences.
class HttpClient
def initialize
@headers = {}
@params = {}
@method = :get
end
def get(path)
@path = path
@method = :get
self
end
def post(path)
@path = path
@method = :post
self
end
def header(name, value)
@headers[name] = value
self
end
def param(name, value)
@params[name] = value
self
end
def execute
# Simulate HTTP request execution
{
method: @method,
path: @path,
headers: @headers,
params: @params
}
end
end
# Chaining methods to build request
client = HttpClient.new
result = client
.get("/api/users")
.header("Authorization", "Bearer token123")
.param("page", 1)
.param("limit", 10)
.execute
# => { method: :get, path: "/api/users",
# headers: {"Authorization"=>"Bearer token123"},
# params: {"page"=>1, "limit"=>10} }
Block-based DSL configuration provides another common pattern. The DSL object yields itself to a block, enabling configuration through method calls within the block context. This pattern reduces repetition and creates clear configuration boundaries.
class DatabaseConfig
attr_reader :host, :port, :database, :credentials
def initialize
@credentials = {}
end
def host(value)
@host = value
self
end
def port(value)
@port = value
self
end
def database(value)
@database = value
self
end
def username(value)
@credentials[:username] = value
self
end
def password(value)
@credentials[:password] = value
self
end
def self.configure
config = new
yield(config) if block_given?
config
end
end
# Block-based configuration
db_config = DatabaseConfig.configure do |config|
config.host("localhost")
.port(5432)
.database("myapp")
.username("admin")
.password("secret")
end
Immutable chaining creates new objects for each method call rather than modifying existing state. This pattern prevents unintended side effects and supports functional programming approaches. Each method returns a new instance with updated state while leaving the original object unchanged.
class ImmutableQuery
def initialize(conditions = [], orders = [])
@conditions = conditions.dup
@orders = orders.dup
end
def where(condition)
self.class.new(@conditions + [condition], @orders)
end
def order(field)
self.class.new(@conditions, @orders + [field])
end
def to_sql
sql = "SELECT * FROM table"
sql += " WHERE #{@conditions.join(' AND ')}" unless @conditions.empty?
sql += " ORDER BY #{@orders.join(', ')}" unless @orders.empty?
sql
end
end
# Each method call returns new instance
query1 = ImmutableQuery.new
query2 = query1.where("age > 18")
query3 = query2.where("status = 'active'").order("name")
# Original queries remain unchanged
query1.to_sql # => "SELECT * FROM table"
query2.to_sql # => "SELECT * FROM table WHERE age > 18"
query3.to_sql # => "SELECT * FROM table WHERE age > 18 AND status = 'active' ORDER BY name"
Method chaining DSLs often implement terminating methods that execute the built configuration and return final results. These methods break the chain and provide the computed output based on the accumulated configuration state.
Advanced Usage
Dynamic method generation through method_missing
enables flexible DSL interfaces where method names determine behavior at runtime. This technique supports domain-specific method names without explicitly defining each method, creating more natural and expressive DSL syntax.
class DynamicQueryBuilder
def initialize
@conditions = {}
@joins = []
end
def method_missing(method_name, *args, &block)
if method_name.to_s.start_with?('find_by_')
attribute = method_name.to_s.sub('find_by_', '')
where(attribute, args.first)
elsif method_name.to_s.start_with?('join_')
table = method_name.to_s.sub('join_', '')
join(table)
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
method_name.to_s.start_with?('find_by_', 'join_') || super
end
def where(attribute, value)
@conditions[attribute] = value
self
end
def join(table)
@joins << table
self
end
def to_sql
sql = "SELECT * FROM main"
sql += " JOIN #{@joins.join(' JOIN ')}" unless @joins.empty?
unless @conditions.empty?
conditions_sql = @conditions.map { |k, v| "#{k} = '#{v}'" }.join(' AND ')
sql += " WHERE #{conditions_sql}"
end
sql
end
end
# Dynamic method calls based on naming conventions
query = DynamicQueryBuilder.new
.find_by_name('John')
.find_by_status('active')
.join_users
.join_profiles
query.to_sql
# => "SELECT * FROM main JOIN users JOIN profiles WHERE name = 'John' AND status = 'active'"
Metaprogramming with define_method
enables runtime method creation based on configuration or external data. This pattern supports DSLs that adapt their interface based on runtime conditions, schemas, or user-defined specifications.
class SchemaBasedBuilder
def initialize(schema)
@schema = schema
@values = {}
# Define methods based on schema
schema.each do |field, type|
define_singleton_method(field) do |value|
@values[field] = cast_value(value, type)
self
end
define_singleton_method("#{field}?") do
@values.key?(field)
end
end
end
def build
@values.dup
end
private
def cast_value(value, type)
case type
when :integer then value.to_i
when :string then value.to_s
when :boolean then !!value
when :array then Array(value)
else value
end
end
end
# Schema defines available methods
user_schema = {
name: :string,
age: :integer,
active: :boolean,
tags: :array
}
# Methods created dynamically from schema
builder = SchemaBasedBuilder.new(user_schema)
user = builder
.name("Alice")
.age("25")
.active(true)
.tags(["admin", "developer"])
.build
# => {"name"=>"Alice", "age"=>25, "active"=>true, "tags"=>["admin", "developer"]}
Nested DSL configuration enables hierarchical configuration structures where method calls create sub-builders for specific configuration sections. This pattern organizes complex configurations into logical groups while maintaining the fluent interface style.
class ConfigurationBuilder
def initialize
@config = {}
end
def database(&block)
@config[:database] = DatabaseSection.new.tap do |db|
db.instance_eval(&block) if block_given?
end.to_hash
self
end
def cache(&block)
@config[:cache] = CacheSection.new.tap do |cache|
cache.instance_eval(&block) if block_given?
end.to_hash
self
end
def to_hash
@config
end
class DatabaseSection
def initialize
@config = {}
end
def host(value)
@config[:host] = value
end
def port(value)
@config[:port] = value
end
def pool_size(value)
@config[:pool_size] = value
end
def timeout(value)
@config[:timeout] = value
end
def to_hash
@config
end
end
class CacheSection
def initialize
@config = {}
end
def type(value)
@config[:type] = value
end
def ttl(value)
@config[:ttl] = value
end
def max_size(value)
@config[:max_size] = value
end
def to_hash
@config
end
end
end
# Nested configuration with blocks
config = ConfigurationBuilder.new
.database do
host "localhost"
port 5432
pool_size 10
timeout 30
end
.cache do
type :redis
ttl 3600
max_size 1000
end
config.to_hash
# => {
# :database => {:host=>"localhost", :port=>5432, :pool_size=>10, :timeout=>30},
# :cache => {:type=>:redis, :ttl=>3600, :max_size=>1000}
# }
Conditional chaining enables method calls based on runtime conditions without breaking the chain. This pattern maintains fluent interface readability while supporting conditional logic within the DSL configuration.
class ConditionalBuilder
def initialize
@parts = []
end
def add(item)
@parts << item
self
end
def add_if(condition, item)
@parts << item if condition
self
end
def add_unless(condition, item)
@parts << item unless condition
self
end
def when(condition)
if condition
yield(self)
end
self
end
def to_array
@parts
end
end
# Conditional method chaining
debug_mode = true
user_role = "admin"
builder = ConditionalBuilder.new
.add("base_component")
.add_if(debug_mode, "debug_info")
.add_unless(user_role == "guest", "user_menu")
.when(user_role == "admin") { |b| b.add("admin_panel") }
builder.to_array
# => ["base_component", "debug_info", "user_menu", "admin_panel"]
Common Pitfalls
Method chaining DSLs can break unexpectedly when methods return nil
instead of chainable objects. This occurs when conditional logic returns different types or when methods fail to maintain the chain contract. Each method in a chainable interface must return an object that supports subsequent method calls.
class BrokenChainBuilder
def initialize
@items = []
end
def add(item)
@items << item
self
end
def add_valid(item)
# Breaks chain when item is invalid
return nil unless item && !item.empty?
@items << item
self
end
def finalize
@items
end
end
# Chain breaks on invalid input
builder = BrokenChainBuilder.new
result = builder
.add("valid")
.add_valid("") # Returns nil
.add("another") # NoMethodError: undefined method `add' for nil:NilClass
# Better approach: always return self
class FixedChainBuilder
def initialize
@items = []
end
def add(item)
@items << item
self
end
def add_valid(item)
@items << item if item && !item.empty?
self # Always return self
end
def finalize
@items
end
end
State mutation in shared DSL instances leads to unexpected behavior when the same builder instance is reused across multiple configurations. Each configuration attempt modifies the same internal state, causing configuration bleed between different usage contexts.
class SharedStateBuilder
def initialize
@options = {}
end
def set(key, value)
@options[key] = value
self
end
def build
@options.dup
end
end
# Shared instance causes state bleed
builder = SharedStateBuilder.new
config1 = builder.set(:theme, "dark").set(:size, "large").build
config2 = builder.set(:theme, "light").build # Still has :size from config1
config1 # => {:theme=>"dark", :size=>"large"}
config2 # => {:theme=>"light", :size=>"large"} # Unexpected :size
# Solution: Reset state or use factory pattern
class IsolatedStateBuilder
def self.build
new
end
def initialize
@options = {}
end
def set(key, value)
@options[key] = value
self
end
def to_hash
@options.dup
end
end
# Each build creates fresh instance
config1 = IsolatedStateBuilder.build.set(:theme, "dark").set(:size, "large").to_hash
config2 = IsolatedStateBuilder.build.set(:theme, "light").to_hash
Method name collisions occur when DSL method names conflict with existing Ruby methods or when multiple DSL concerns define methods with identical names. These collisions cause unexpected behavior or prevent access to intended functionality.
class CollidingMethodsBuilder
def initialize
@config = {}
end
# Collides with Object#class
def class(css_class)
@config[:class] = css_class
self
end
# Collides with Object#send
def send(message)
@config[:message] = message
self
end
def build
@config
end
end
# Method collisions cause problems
builder = CollidingMethodsBuilder.new
# builder.class("active") # Returns Class object, not builder
# builder.send("hello") # Calls Object#send, not DSL method
# Better naming avoids collisions
class SafeMethodsBuilder
def initialize
@config = {}
end
def css_class(value)
@config[:class] = value
self
end
def message(value)
@config[:message] = value
self
end
def build
@config
end
end
Block context evaluation changes method resolution and variable access, causing confusion when DSL blocks reference external variables or methods. The instance_eval
approach changes self
within the block, affecting method calls and variable access patterns.
class BlockContextBuilder
def initialize
@items = []
end
def configure(&block)
instance_eval(&block) # Changes self context
self
end
def add(item)
@items << item
end
def items
@items
end
end
# Context confusion with instance_eval
external_var = "external_value"
def external_method
"external_result"
end
builder = BlockContextBuilder.new.configure do
add("item1")
# add(external_var) # NameError: undefined local variable
# add(external_method) # NoMethodError: undefined method
end
# Alternative: yield self to maintain caller context
class ContextFriendlyBuilder
def initialize
@items = []
end
def configure
yield(self) if block_given?
self
end
def add(item)
@items << item
self
end
def items
@items
end
end
# Maintains access to external context
builder = ContextFriendlyBuilder.new.configure do |b|
b.add("item1")
b.add(external_var) # Works: accesses external variable
b.add(external_method) # Works: accesses external method
end
Infinite chaining loops occur when methods inadvertently create circular references or when terminating conditions are missing from recursive DSL patterns. These loops consume memory and cause stack overflow errors in complex configurations.
class InfiniteChainBuilder
def initialize(parent = nil)
@parent = parent
@children = []
end
def child(name)
child_builder = InfiniteChainBuilder.new(self)
@children << child_builder
child_builder
end
def parent
@parent # Can create infinite loops if misused
end
def root
current = self
# Dangerous: assumes finite parent chain
current = current.parent while current.parent
current
end
end
# Avoid circular references in DSL design
class SafeHierarchyBuilder
def initialize(parent = nil, depth = 0)
@parent = parent
@children = []
@depth = depth
raise "Maximum nesting depth exceeded" if @depth > 10
end
def child(name)
child_builder = SafeHierarchyBuilder.new(self, @depth + 1)
@children << child_builder
child_builder
end
def root
current = self
visited = Set.new
while current.instance_variable_get(:@parent)
break if visited.include?(current.object_id)
visited.add(current.object_id)
current = current.instance_variable_get(:@parent)
end
current
end
end
Reference
Core Method Chaining Patterns
Pattern | Implementation | Use Case |
---|---|---|
Mutable Chaining | Methods modify state and return self |
Simple builders, configuration objects |
Immutable Chaining | Methods return new instances with updated state | Functional programming, shared state avoidance |
Terminating Methods | Methods break chain and return computed results | Query execution, build finalization |
Conditional Chaining | Methods with conditional execution within chain | Dynamic configuration, optional features |
DSL Construction Methods
Method | Purpose | Returns | Example |
---|---|---|---|
method_missing |
Handle undefined method calls dynamically | Any | Dynamic method generation |
define_method |
Create methods at runtime | Symbol | Schema-based method creation |
instance_eval |
Evaluate block in object context | Block result | Block-based configuration |
class_eval |
Define methods in class context | Block result | Class-level DSL definition |
State Management Patterns
Approach | State Handling | Pros | Cons |
---|---|---|---|
Instance Variables | Store state in object instance | Simple, direct access | Mutable, shared state issues |
Immutable Objects | Create new objects for each change | Thread-safe, no side effects | Higher memory usage |
Builder Pattern | Accumulate state, build final object | Clear separation of concerns | More complex implementation |
Copy-on-Write | Copy state only when modified | Memory efficient for similar configurations | Complex state tracking |
Method Naming Conventions
Convention | Pattern | Example | Purpose |
---|---|---|---|
Fluent Interface | Verb-based method names | .where() , .order() , .limit() |
Action-oriented chaining |
Property Setting | Attribute-based names | .name() , .email() , .age() |
Configuration assignment |
Query Methods | Predicate methods ending in ? |
.valid?() , .empty?() , .ready?() |
State inspection |
Terminating Methods | Action verbs without chaining | .build() , .execute() , .finalize() |
Chain completion |
Common DSL Use Cases
Domain | Methods | Termination | Example Pattern |
---|---|---|---|
Query Building | where , order , limit , join |
execute , to_sql |
User.where(active: true).order(:name).limit(10) |
HTTP Clients | get , post , header , param |
execute , response |
client.get('/users').header('Auth', token).execute |
Configuration | set , enable , configure |
build , apply |
config.set(:host, 'localhost').enable(:ssl).build |
Validation | validates , presence , format |
valid? , errors |
validator.presence(:name).format(:email, regex).valid? |
Error Handling Patterns
Pattern | Implementation | When to Use |
---|---|---|
Null Object | Return null object that responds to all methods | Prevent nil errors in chains |
Early Validation | Validate parameters in each method | Catch errors close to source |
Deferred Validation | Validate on termination | Allow flexible configuration |
Error Accumulation | Collect errors throughout chain | Report all issues at once |
Thread Safety Considerations
Scenario | Safety Level | Mitigation |
---|---|---|
Immutable Chaining | Thread-safe | No additional synchronization needed |
Mutable Instance State | Not thread-safe | Use synchronization or per-thread instances |
Class-level State | Potentially unsafe | Avoid or synchronize access |
Shared Builder Instances | Not thread-safe | Create new instances per thread |
Performance Characteristics
Operation | Time Complexity | Memory Usage | Optimization |
---|---|---|---|
Method Chaining | O(n) per method | O(1) per chain link | Minimize method call overhead |
Immutable Chaining | O(n) per method | O(n) per instance | Use copy-on-write or structural sharing |
Dynamic Method Creation | O(1) cached, O(n) creation | O(1) per method | Cache generated methods |
Block Evaluation | O(n) block size | O(1) additional | Minimize block complexity |