skip to content

DSL Construction

Ruby DSLs run a block against a hidden receiver or yield a builder, and class macros are plain methods called on self in a class body. Interviewers ask how to keep one debuggable.

on this pageshow

explore

questions

4

In a Ruby block DSL, what changes when the library runs your block with instance_eval instead of yielding a builder object to it?

level: middleimportance: must knowfreq 50%

answer

  1. who is self inside the block
  2. locals still visible, @ivars are not
  3. caller helpers: NameError
  4. builder style: r.threshold 90
  5. block.arity picks the style

basics

~20 s

With instance_eval, self inside the block becomes the DSL object, so bare calls like threshold 90 reach it, but the caller's instance variables and helper methods do not. Yielding a builder keeps self and makes calls explicit: r.threshold 90.

solid answer

~40 s

A Ruby block DSL can run the user's block in two ways. **Receiver style**: the library calls `rule.instance_eval(&block)`, so `self` inside the block is the rule object and bare words like `threshold 90` are method calls on it — the cleanest syntax. The cost is that everything that used to resolve against the caller's `self` now resolves against the rule: `@oncall` reads the rule's (unset, so `nil`) instance variable, and a helper method of the caller raises `NameError`. Local variables still work, because a block is a closure. **Builder style**: the library does `yield rule`, the block takes a parameter, and calls are explicit (`r.threshold 90`); `self`, instance variables and helpers stay the caller's. A library can support both by checking `block.arity`: one parameter means builder style, none means `instance_eval`.

code

ruby · 18 lines
ruby
class Config
  def initialize
    @oncall = "ops"
  end

  def pager = "pager-team"

  def build
    Alerts.alert("cpu") { threshold 90; notify @oncall }
    # instance_eval: @oncall is the rule's -> notify(nil)

    Alerts.alert("cpu") { notify pager }
    # NameError: undefined local variable or method 'pager'

    Alerts.alert("cpu") { |r| r.notify @oncall }
    # builder style: self is still Config -> notify("ops")
  end
end

go deeper

for a junior

Recall the two shapes: bare calls on a hidden receiver versus calls on a block parameter.

for a middle

Explain that instance_eval switches self, so instance variables and helper methods resolve against the DSL object while locals still work.

for a senior

Diagnose the silent nil and the confusing NameError in user code, and design the arity-based hybrid plus final validation.

for a principal

Choose the style from who writes the blocks and where, and weigh cleaner syntax against the support cost of surprising self.

## Two shapes for the same DSL Imagine a monitoring tool whose users declare alert rules in Ruby: ```ruby Alerts.alert "cpu_high" do threshold 90 notify "ops" end ``` The library creates an `AlertRule` object and must let the block configure it. It has two options. 1. **Receiver style (evaluate on the object).** `rule.instance_eval(&block)` runs the block with `self` switched to `rule`. A bare `threshold 90` is a call to `rule.threshold(90)`. 2. **Builder style (yield the object).** `yield rule` passes the rule in as a block parameter, and the user writes `r.threshold 90`. `self` inside the block is whatever it was where the block was written. ## What the block can see | Inside the block | Receiver style (`instance_eval`) | Builder style (`yield`) | |---|---|---| | Local variables of the caller | visible (closure) | visible (closure) | | Caller's `@instance_variables` | **replaced** by the rule's, usually `nil` | visible | | Caller's helper methods | **not reachable**: `NameError` or `NoMethodError` | reachable | | DSL methods | bare: `threshold 90` | explicit: `r.threshold 90` | | Private methods of the rule | callable | not callable without `send` | The first row surprises people least; the next two cause most bugs. ## The two classic failures - **Silent `nil`.** A method on a `Config` object sets `@oncall = "ops"` and then writes `notify @oncall` inside the block. Under `instance_eval`, `@oncall` is looked up on the rule, which has none, so `notify` receives `nil` and no error is raised. - **A confusing `NameError`.** `notify pager`, where `pager` is a helper method of `Config`, fails with `undefined local variable or method 'pager'` for an instance of `AlertRule` — the message names the DSL object, not the user's class, so the user cannot see why. The standard workaround is to copy what is needed into a local variable before the block (`who = pager`), because locals are captured by the closure. ## Supporting both styles A library can let the user choose by looking at the block's parameters: ```ruby def self.alert(name, &block) rule = AlertRule.new(name) block.arity == 1 ? yield(rule) : rule.instance_eval(&block) rule end ``` `proc { }.arity` is `0` and `proc { |r| }.arity` is `1`, so users who need their own `self` add a parameter and get builder style. ## Choosing - **Receiver style** suits short, declarative files owned by the DSL, such as a rules file loaded by the tool, where the block rarely needs the caller's state. - **Builder style** suits DSLs used inside application objects, where blocks call helpers and read instance variables. - **Offer both** when users are mixed, and document the arity rule. - Whichever you pick, **fail loudly**: validate the finished rule and raise if required settings are missing, so a silent `nil` becomes a clear error. ## Debugging a receiver-style block When a block in a receiver-style DSL misbehaves, a few checks settle it quickly: - Print `self` inside the block: it shows the DSL object, which explains every missing instance variable. - Read the class named in a `NameError`: if it is the DSL's class rather than yours, the call was resolved against the DSL object. - Grep the library for `instance_eval`, `instance_exec` or `yield` near where it takes the block, to learn which style it uses. - When the library must also pass values in, it can call `rule.instance_exec(context, &block)`, which switches `self` like `instance_eval` but hands arguments to the block (the evaluation methods themselves are their own topic). ## What the interviewer wants The expected answer names the `self` switch as the single cause, lists what the block loses (instance variables and methods of the caller) and keeps (locals), and knows the arity-based hybrid.

  • Why does a local variable still work inside an instance_eval block when an instance variable does not?
    A block captures the local variables of the scope where it was written, and `instance_eval` does not change that binding of locals. Instance variables are looked up on `self`, which `instance_eval` switches to the DSL object, so they resolve against the wrong object.
  • How can a library let users choose between the two styles?
    Check the block's arity: a block with one parameter is called with the builder (`yield(rule)`), a block with none is run with `rule.instance_eval(&block)`. Users who need their own helpers simply add a block parameter.

saying these in an interview costs you the question

  • instance_eval also hides the caller's local variables from the block.
  • Under instance_eval, the caller's @ivars stay visible inside the block.
  • Yielding a builder changes self to the builder object.
  • An unset @ivar read inside instance_eval raises NameError.
open as a page

In Ruby, what is a class macro such as `alert_on :cpu, above: 90` in a class body, and when does it run?

level: juniorimportance: should knowfreq 40%

basics

~20 s

A class macro is an ordinary method called on the class: inside a class body self is the class, so alert_on :cpu, above: 90 calls a class-level method. It runs once, as the class body executes, recording settings or defining methods.

open as a page

In a Ruby DSL that accepts any keyword through method_missing, why does a typo like sevrity go unnoticed, and how would you make DSL errors readable?

level: seniorimportance: should knowfreq 35%

basics

~20 s

A catch-all method_missing stores every unknown call, so sevrity :high becomes a setting nobody reads. Define keywords as real methods so typos raise NoMethodError with a did_you_mean hint, validate each rule after its block, and point errors at the user's line.

open as a page

When building a Ruby DSL, what does an RSpec-style shape, where each group block becomes the body of a new class, give you over Rake-style methods added to main?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

Rake extends the top-level main object with private DSL methods: flat files work, but helper defs land on Object. RSpec runs each group block as a Class.new subclass body via module_exec, so helpers are methods, nesting is inheritance and examples get fresh instances.

open as a page