CrackedRuby logo

CrackedRuby

Method Chaining DSLs

Method Chaining DSLs in Ruby provide fluent interfaces for building domain-specific languages through chainable method calls that return modified or new objects.

Metaprogramming DSL Creation
5.7.2

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