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.