Ruby DSLs with instance_eval and method_missing
Ruby DSLs (Domain-Specific Languages) are mini-languages focused on a particular problem domain. If you are building internal DSLs in Ruby, the goal is not to replace Ruby itself. Rather, you design syntax that reads naturally for your domain and removes repetitive noise from the code that uses it. 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.
Intro context
The best DSLs solve a narrow problem, such as configuration, validation, or query building. If the syntax does not make a specific task easier to read, the DSL is probably too broad. Ruby is a strong fit for this style because you can change the receiver, intercept messages, and build chains that read like a sentence.
That flexibility is useful, but it also means you need restraint. A good DSL keeps the rules small, the valid inputs obvious, and the error messages clear. If the syntax feels magical in the wrong way, it will be harder to maintain than plain Ruby.
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
The distinction between instance_eval and instance_exec is subtle but important. instance_eval only changes self, while instance_exec also forwards arguments to the block. Use instance_exec when your DSL block needs data from the calling context — for example, passing the configuration type or a reference to a parent object.
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"
A fluent interface reads naturally because each method call flows into the next. The key technique is returning self from every method, which chains calls together. This pattern appears in Ruby gems like ActiveRecord’s query interface and RSpec’s expectation syntax.
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 }
A configuration DSL like this replaces YAML or JSON files with plain Ruby syntax. The advantage is that the configuration can include logic, reference constants, and benefit from syntax highlighting and autocompletion in any Ruby editor. The to_h method at the end converts the DSL result back into a plain hash for the rest of the application to consume.
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"
Nested DSL blocks use a recursive pattern: the parent calls instance_eval on a child object, and the child’s own attr_accessor methods become the DSL commands inside the nested block. This is how Rails configuration files and RSpec describe blocks achieve their readable, structured syntax without requiring a separate parser.
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"
That last step is what keeps the object honest. respond_to_missing? lets libraries and tools discover the dynamic API instead of guessing. Without it, code that checks respond_to? may behave strangely even though the DSL seems to work in the happy path. Always pair method_missing with respond_to_missing? — the two methods together form a complete dynamic dispatch contract.
Building a safer DSL
method_missing is useful, but it should not be the only tool in your box. A safer pattern is to expose a small set of explicit methods and keep dynamic behavior only for the places where it really reduces noise. That gives you readable syntax without turning typos into silent bugs.
class ReportBuilder
def initialize
@rows = []
end
def section(name, &block)
@rows << { type: :section, name: name, body: block && block.call }
end
def note(text)
@rows << { type: :note, text: text }
end
def to_a
@rows
end
end
report = ReportBuilder.new
report.section("Summary") do
"The first quarter is on track."
end
report.note("Add a chart before publishing.")
report.to_a
This style is easier to test because the public API is small and predictable. If the DSL grows, you can add validation inside these methods and keep the dynamic entry points focused.
When a DSL is the right choice
A DSL is useful when the repeated shape of the code matters more than the individual commands. Configuration files, test helpers, policy rules, and query builders are common examples. In those cases, the DSL removes boilerplate and makes the intent easier to scan.
The tradeoff is that a DSL also adds another layer of conventions. Keep the syntax tight, document the accepted patterns, and make sure the object still behaves like normal Ruby when callers need to inspect it.
How to keep the syntax maintainable
A DSL should stay small enough that one person can hold the whole mental model in their head. The moment you need a long reference guide just to explain the syntax, the abstraction is probably too broad. That is why many Ruby DSLs work best when they cover one narrow slice of behavior, such as validation rules or report formatting.
Testing helps keep that boundary clear. Write a few examples that show the exact calls you expect users to make, then assert on the resulting data structure or output. Those tests tell you whether the DSL still reads well after the next change, and they also make it easier to spot methods that have drifted too far from the original goal.
It also helps to keep the escape hatches obvious. If the DSL has a plain Ruby alternative, document it. Sometimes the clearer choice is to skip the DSL entirely and call methods directly, especially when the logic becomes more conditional than declarative.
When you do keep the DSL, prefer names that mirror the domain. That makes the code feel less like framework magic and more like a focused tool that speaks the language of the problem.
Forward link
If you want to compare this style with another metaprogramming technique, the next article on ruby-class-eval-instance-eval shows how to work with code that defines itself at runtime.
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"]
This validation DSL shows the full pattern: a class that accepts a block, evaluates it with instance_eval, and stores rules as lambdas for later execution. The required and format methods add validation rules to an internal array, and validate runs them all against a data hash. This is the same pattern that powers ActiveModel::Validations in Rails.
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
- instance_eval and class_eval ; Going deeper into context switching in Ruby
- method_missing Deep Dive ; How to intercept and handle undefined method calls
- Blocks, Procs, and Lambdas ; The foundation that makes DSL blocks work