Building DSLs with blocks

Ruby · Advanced · 14 min

Much of the Ruby ecosystem (Rails routes, RSpec, Gemfiles, Rake) is a block-based DSL; knowing the trick demystifies them.

An internal DSL is plain Ruby shaped to read like a configuration language. Rails routes, RSpec, Rake tasks, Gemfiles and Sinatra apps are all DSLs. They rely on three features you already know: optional parentheses, blocks, and changing self.

The key move is instance_eval(&block) (or instance_exec(args, &block)): it runs the caller's block with self set to a builder object, so bare method calls inside the block go to the builder:

Router.draw do
  get "/users", to: "users#index"
end

Here get is a method on the builder, not on the code that wrote the block.

The trade-off is that inside the block, self is no longer the caller's object, so the caller's instance variables and methods disappear (local variables still work, since the block is a closure). For that reason many libraries offer a second form that yields the builder explicitly, config do |c| c.port = 80 end, which keeps self unchanged. Supporting both is easy: check block.arity.

Design tips:

  • Keep the DSL small and make each method do one obvious thing.
  • Store what the block declares and act on it later; don't do heavy work during evaluation.
  • Validate eagerly and raise clear errors naming the DSL keyword.
  • Use Module#refine (refinements, activated with using) rather than monkey patching if the DSL needs extra methods on core classes such as 5.minutes.

Example

class RouteSet
  Route = Data.define(:verb, :path, :target)

  def self.draw(&block)
    set = new
    block.arity == 1 ? block.call(set) : set.instance_eval(&block)
    set.routes
  end
  attr_reader :routes
  def initialize = @routes = []
  %w[get post].each do |verb|
    define_method(verb) { |path, to:| @routes << Route.new(verb.upcase, path, to) }
  end
  def resources(name) = [get("/#{name}", to: "#{name}#index"), post("/#{name}", to: "#{name}#create")]
end

prefix = "/api"
routes = RouteSet.draw do
  get "#{prefix}/health", to: "health#show"   # locals still visible
  resources :users
end
routes.each { |r| puts "#{r.verb} #{r.path} -> #{r.target}" }
# GET /api/health -> health#show
# GET /users -> users#index
# POST /users -> users#create

Remember

  • instance_eval runs a block with self set to a builder object
  • Inside such blocks, locals still work but the caller's methods and ivars don't
  • Offer a yield-the-builder form when callers need their own self

Watch out for

  • Referencing @ivars of the calling object inside an instance_eval block; they belong to the builder there.

Check yourself

  1. Inside Router.draw { get "/x" } implemented with instance_eval, which object receives get?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.ruby-lang.org/en/3.4/BasicObject.html#method-i-instance_eval

Builds on

Unlocks

Nothing builds on this yet.