Building DSLs in Ruby

· 6 min read · Updated March 27, 2026 · intermediate
ruby metaprogramming dsl instance_eval method-missing

A Domain-Specific Language is a mini-language focused on a particular problem. Rather than writing general-purpose code, you design syntax that reads naturally for your domain. 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.

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

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"

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 }

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"

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"
obj.username  # => "alice"
obj.respond_to?(:username)  # => true

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"]

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