Skip to content
AgileLogics
6 min read

Blocks as DSLs: instance_eval and configure

Prerequisites: Metaprogramming: send and define_method

A DSL (domain-specific language) is an API designed to read like a description rather than like code. Ruby's blocks make DSLs natural — you've used several already:

# RSpec
describe Cart do
  it "starts empty" do ... end
end

# Gemfile
group :test do
  gem "rspec"
end

Two techniques build almost all of them.

1. Yield a configuration object

The most common and least magical pattern — used by thousands of gems:

module Mailer
  Config = Struct.new(:host, :port, :from, keyword_init: true)

  def self.config
    @config ||= Config.new(host: "localhost", port: 25)
  end

  def self.configure
    yield config
  end
end

Mailer.configure do |c|
  c.host = "smtp.example.com"
  c.port = 587
  c.from = "hello@example.com"
end

p Mailer.config.host
p Mailer.config.port
"smtp.example.com"
587

@config ||= ... inside self.config stores the object on the module itself, creating it only the first time it's needed.

2. instance_eval — change self inside the block

instance_eval runs a block with self set to another object, so the block can call that object's methods without a receiver:

class Menu
  attr_reader :items

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

  def initialize = @items = []

  def item(name, price:) = @items << "#{name.ljust(10)} $#{price}"
end

menu = Menu.build do
  item "Espresso", price: 3
  item "Croissant", price: 4
end

puts menu.items
Espresso   $3
Croissant  $4

Inside the block, item means menu.item.

The trade-off

instance_eval blocks can't see the caller's instance variables or private methods — self has changed — which surprises people. A common compromise is to support both styles: if the block takes an argument (block.arity == 1), yield the object; otherwise, instance_eval.

Prefer pattern 1 unless the DSL really benefits from the cleaner syntax.