rubyguides

Ruby DSLs with instance_eval and method_missing

Ruby DSLs (Domain-Specific Languages) are mini-languages focused on a particular problem domain. If you are building internal DSLs in Ruby, the goal is not to replace Ruby itself. Rather, you design syntax that reads naturally for your domain and removes repetitive noise from the code that uses it. Ruby is exceptionally good at this because its flexible syntax lets you reshape the language to fit your problem.

There are two kinds of DSLs. External DSLs have their own parser — SQL and regular expressions are examples. Internal DSLs (also called embedded DSLs) live inside a host language like Ruby. This article focuses on internal DSLs, which is where Ruby really shines.

Intro context

The best DSLs solve a narrow problem, such as configuration, validation, or query building. If the syntax does not make a specific task easier to read, the DSL is probably too broad. Ruby is a strong fit for this style because you can change the receiver, intercept messages, and build chains that read like a sentence.

That flexibility is useful, but it also means you need restraint. A good DSL keeps the rules small, the valid inputs obvious, and the error messages clear. If the syntax feels magical in the wrong way, it will be harder to maintain than plain Ruby.

How instance_eval changes the game

The key technique behind most Ruby DSLs is instance_eval. It runs a block with self set to a specific object, so inside the block, method calls and instance variable references resolve against that object.

class Config
  attr_accessor :host, :port, :environment

  def self.build(&block)
    config = new
    config.instance_eval(&block)
    config
  end
end

config = Config.build do
  host "localhost"
  port 3000
  environment "development"
end

config.host        # => "localhost"
config.port        # => 3000
config.environment # => "development"

Inside the block, host, port, and environment are method calls on the Config instance. Since attr_accessor defines setter methods, host "localhost" works as a method call that assigns the value. No explicit receiver needed self is already the Config object.

Note the difference between instance_eval and instance_exec: instance_eval only changes self, while instance_exec also lets you pass arguments into the block. Use instance_exec when your DSL block needs parameters:

class RuleBuilder
  def self.for(type, &block)
    builder = new
    builder.instance_exec(type, &block)
    builder.rules
  end

  attr_reader :rules

  def initialize
    @rules = []
  end

  def require(field)
    @rules << ->(obj) { "#{field} is missing" if obj[field].nil? }
  end
end

RuleBuilder.for(:user) do |type|
  require(:name)
  require(:email)
end

The distinction between instance_eval and instance_exec is subtle but important. instance_eval only changes self, while instance_exec also forwards arguments to the block. Use instance_exec when your DSL block needs data from the calling context — for example, passing the configuration type or a reference to a parent object.

Method chaining with fluent interfaces

A fluent interface returns self from each method so you can chain calls. This creates readable, sentence-like code. The tap method is useful here ; it yields self to a block and then returns self, letting you inject side effects into a chain without breaking it.

class QueryBuilder
  def initialize
    @conditions = []
  end

  def where(condition)
    @conditions << condition
    self
  end

  def order(field)
    @order = field
    self
  end

  def limit(n)
    @limit = n
    self
  end

  def to_sql
    sql = "SELECT * FROM records"
    sql += " WHERE #{@conditions.join(' AND ')}" if @conditions.any?
    sql += " ORDER BY #{@order}" if @order
    sql += " LIMIT #{@limit}" if @limit
    sql
  end
end

query = QueryBuilder.new
  .where("active = true")
  .order("name")
  .limit(10)

query.to_sql
# => "SELECT * FROM records WHERE active = true ORDER BY name LIMIT 10"

A fluent interface reads naturally because each method call flows into the next. The key technique is returning self from every method, which chains calls together. This pattern appears in Ruby gems like ActiveRecord’s query interface and RSpec’s expectation syntax.

Setters that return the assigned value (the default Ruby behavior) break chaining. tap rescues this:

class Settings
  attr_accessor :host, :port
end

# Setter returns the value, not self:
settings = Settings.new
settings.host = "localhost"  # => "localhost", not settings

# tap fixes it:
settings.tap { |s| s.host = "localhost" }.tap { |s| s.port = 3000 }

Building a Configuration DSL

Configuration files are a natural fit for DSLs. Instead of a hash or YAML, you get a Ruby syntax that reads clearly and is easy to extend.

class ServerConfig
  attr_accessor :host, :port, :max_connections, :timeout, :ssl_enabled

  def self.load(&block)
    config = new
    config.instance_eval(&block)
    config
  end

  def to_h
    {
      host: @host,
      port: @port,
      max_connections: @max_connections,
      timeout: @timeout,
      ssl_enabled: @ssl_enabled
    }
  end
end

config = ServerConfig.load do
  host "api.example.com"
  port 443
  max_connections 1000
  timeout 30
  ssl_enabled true
end

config.to_h
# => { host: "api.example.com", port: 443, max_connections: 1000, timeout: 30, ssl_enabled: true }

A configuration DSL like this replaces YAML or JSON files with plain Ruby syntax. The advantage is that the configuration can include logic, reference constants, and benefit from syntax highlighting and autocompletion in any Ruby editor. The to_h method at the end converts the DSL result back into a plain hash for the rest of the application to consume.

Adding nested blocks requires a recursive pattern where nested blocks create sub-configuration objects:

class DatabaseConfig
  attr_accessor :adapter, :host, :port

  def self.load(&block)
    config = new
    config.instance_eval(&block)
    config
  end

  def database(&block)
    @database ||= DatabaseSettings.new
    @database.instance_eval(&block) if block
    @database
  end
end

class DatabaseSettings
  attr_accessor :name, :pool

  def to_h
    { name: @name, pool: @pool }
  end
end

config = DatabaseConfig.load do
  adapter "postgresql"
  host "db.example.com"
  database do
    name "myapp_production"
    pool 25
  end
end

config.database.name  # => "myapp_production"

Nested DSL blocks use a recursive pattern: the parent calls instance_eval on a child object, and the child’s own attr_accessor methods become the DSL commands inside the nested block. This is how Rails configuration files and RSpec describe blocks achieve their readable, structured syntax without requiring a separate parser.

Dynamic Methods with method_missing

Sometimes you want DSL syntax that doesn’t map to predefined methods. method_missing intercepts calls to undefined methods, letting you handle arbitrary method names dynamically.

class HtmlBuilder
  def initialize
    @elements = []
  end

  def method_missing(tag_name, content = nil, **attributes, &block)
    attrs = attributes.map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
    if block
      inner = yield
      @elements << "<#{tag_name} #{attrs}>#{inner}</#{tag_name}>"
    else
      @elements << "<#{tag_name} #{attrs}>#{content}</#{tag_name}>"
    end
    self
  end

  def to_html
    @elements.join("\n")
  end
end

html = HtmlBuilder.new
result = html.div(class: "container") do
  h1("Welcome")
  p("This is a paragraph.")
end
result.to_html
# => "<div class=\"container\"><h1>Welcome</h1><p>This is a paragraph.</p></div>"

When you use method_missing, always override respond_to_missing? so introspection works correctly:

class DynamicAttribute
  def method_missing(method_name, *args, &block)
    if method_name.to_s.end_with?("=")
      attr_name = method_name.to_s.chomp("=")
      instance_variable_set("@#{attr_name}", args.first)
    else
      instance_variable_get("@#{method_name}")
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    true
  end
end

obj = DynamicAttribute.new
obj.username = "alice"

That last step is what keeps the object honest. respond_to_missing? lets libraries and tools discover the dynamic API instead of guessing. Without it, code that checks respond_to? may behave strangely even though the DSL seems to work in the happy path. Always pair method_missing with respond_to_missing? — the two methods together form a complete dynamic dispatch contract.

Building a safer DSL

method_missing is useful, but it should not be the only tool in your box. A safer pattern is to expose a small set of explicit methods and keep dynamic behavior only for the places where it really reduces noise. That gives you readable syntax without turning typos into silent bugs.

class ReportBuilder
  def initialize
    @rows = []
  end

  def section(name, &block)
    @rows << { type: :section, name: name, body: block && block.call }
  end

  def note(text)
    @rows << { type: :note, text: text }
  end

  def to_a
    @rows
  end
end

report = ReportBuilder.new
report.section("Summary") do
  "The first quarter is on track."
end
report.note("Add a chart before publishing.")
report.to_a

This style is easier to test because the public API is small and predictable. If the DSL grows, you can add validation inside these methods and keep the dynamic entry points focused.

When a DSL is the right choice

A DSL is useful when the repeated shape of the code matters more than the individual commands. Configuration files, test helpers, policy rules, and query builders are common examples. In those cases, the DSL removes boilerplate and makes the intent easier to scan.

The tradeoff is that a DSL also adds another layer of conventions. Keep the syntax tight, document the accepted patterns, and make sure the object still behaves like normal Ruby when callers need to inspect it.

How to keep the syntax maintainable

A DSL should stay small enough that one person can hold the whole mental model in their head. The moment you need a long reference guide just to explain the syntax, the abstraction is probably too broad. That is why many Ruby DSLs work best when they cover one narrow slice of behavior, such as validation rules or report formatting.

Testing helps keep that boundary clear. Write a few examples that show the exact calls you expect users to make, then assert on the resulting data structure or output. Those tests tell you whether the DSL still reads well after the next change, and they also make it easier to spot methods that have drifted too far from the original goal.

It also helps to keep the escape hatches obvious. If the DSL has a plain Ruby alternative, document it. Sometimes the clearer choice is to skip the DSL entirely and call methods directly, especially when the logic becomes more conditional than declarative.

When you do keep the DSL, prefer names that mirror the domain. That makes the code feel less like framework magic and more like a focused tool that speaks the language of the problem.

If you want to compare this style with another metaprogramming technique, the next article on ruby-class-eval-instance-eval shows how to work with code that defines itself at runtime.

A validation DSL in practice

Here’s a complete DSL for validating data ; similar to what ActiveModel::Validations does under the hood:

class ValidationSchema
  attr_reader :errors

  def initialize
    @rules = []
    @errors = []
  end

  def self.validate(&block)
    schema = new
    schema.instance_eval(&block)
    schema
  end

  def required(field)
    @rules << ->(data) {
      if data[field].nil? || data[field].to_s.strip.empty?
        "Field '#{field}' is required"
      end
    }
  end

  def format(field, pattern, message)
    @rules << ->(data) {
      unless data[field].to_s =~ pattern
        message
      end
    }
  end

  def validate(data)
    @errors = @rules.map { |rule| rule.call(data) }.compact
    @errors.empty?
  end
end

schema = ValidationSchema.validate do
  required(:email)
  format(:email, URI::MailTo::EMAIL_REGEXP, "Invalid email format")
  required(:name)
end

data = { email: "user@example.com", name: "" }
schema.validate(data)
schema.errors  # => ["Field 'name' is required"]

This validation DSL shows the full pattern: a class that accepts a block, evaluates it with instance_eval, and stores rules as lambdas for later execution. The required and format methods add validation rules to an internal array, and validate runs them all against a data hash. This is the same pattern that powers ActiveModel::Validations in Rails.

Mixing In DSL Behavior with included

The included callback fires when a module is mixed into a class. You can use it to automatically register DSL behavior:

module Configurable
  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def configure(&block)
      @config ||= {}
      config = OpenStruct.new
      config.instance_eval(&block)
      @config = config.to_h
    end

    def config
      @config || {}
    end
  end
end

class Application
  include Configurable

  configure do
    app_name "MyApp"
    version "1.0.0"
  end
end

Application.config
# => { app_name: "MyApp", version: "1.0.0" }

extend inside included gives you class methods, while include gives you instance methods. Rails uses this pattern extensively ; has_many, validates, and before_save are all DSL methods added through callbacks when ActiveRecord modules are included.

See Also