Building DSLs in Ruby
A Domain-Specific Language is a mini-language focused on a particular problem. Rather than writing general-purpose code, you design syntax that reads naturally for your domain. 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.
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
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"
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 }
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"
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"
obj.username # => "alice"
obj.respond_to?(:username) # => true
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"]
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