Ruby Domain-Specific Languages — Building Fluent Interfaces and Internal DSLs Explained
In this tutorial, you will learn about Ruby Domain. We cover key concepts, practical examples, and best practices to help you master this topic.
Ruby Domain-Specific Languages (DSLs) create fluent, readable interfaces using blocks for scoping, method_missing for dynamic dispatch, instance_eval for context switching, and method chaining for natural-language-like APIs.
What You'll Learn
- Block-based DSLs
- Instance_eval patterns
- Method chaining DSLs
- Building configuration DSLs
Why It Matters
DSLs make code read like natural language. Rails routes, migrations, and validations are DSLs. Durga Antivirus Pro uses DSLs for scan rule configuration. Doda Browser uses DSLs for extension definitions and build scripts.
Real-World Use
Rake tasks, Capistrano deployment, RSpec tests, FactoryBot definitions, and Rails routes are all Ruby DSLs. Any time you want code to read like a specification, you want a DSL.
flowchart LR
A["DSL"] --> B["Blocks"]
B --> C["instance_eval"]
C --> D["Chaining"]
D --> E["Macros"]
A:::current --> B
style A fill:#2563eb,stroke:#2563eb,color:#fff
style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b
Block-Based DSL
The simplest DSL uses blocks for scoping:
class EmailBuilder
def initialize
@headers = {}
@body = ""
end
def from(address)
@headers[:from] = address
end
def to(address)
@headers[:to] = address
end
def subject(text)
@headers[:subject] = text
end
def body(text)
@body = text
end
def build
puts "From: #{@headers[:from]}"
puts "To: #{@headers[:to]}"
puts "Subject: #{@headers[:subject]}"
puts "---"
puts @body
end
end
# DSL usage
email = EmailBuilder.new
email.instance_eval do
from "alice@example.com"
to "bob@example.com"
subject "Hello from DSL"
body "This email was built using a Ruby DSL!"
end
email.build
With yield(self)
class EmailBuilder
def initialize(&block)
@headers = {}
block.call(self) if block
end
def from(address) = @headers[:from] = address
def to(address) = @headers[:to] = address
def subject(text) = @headers[:subject] = text
def body(text) = @body = text
def send!
puts "Sending email..."
puts "From: #{@headers[:from]}"
puts "To: #{@headers[:to]}"
puts "Subject: #{@headers[:subject]}"
end
end
email = EmailBuilder.new do |e|
e.from "alice@example.com"
e.to "bob@example.com"
e.subject "Hello"
e.body "Content"
end
email.send!
instance_eval DSL (Self-Scoping)
class ConfigDSL
def self.configure(&block)
config = new
config.instance_eval(&block)
config
end
def database(adapter, **options)
@database = { adapter: adapter, **options }
end
def cache(type, **options)
@cache = { type: type, **options }
end
def middleware(*names)
@middleware ||= []
@middleware.concat(names)
end
def to_h
{ database: @database, cache: @cache, middleware: @middleware }
end
end
# Beautiful DSL usage
config = ConfigDSL.configure do
database :postgres, host: "localhost", port: 5432
cache :redis, url: "redis://localhost:6379"
middleware :session, :logger, :cors
end
puts config.to_h.inspect
# {:database=>{:adapter=>:postgres, :host=>"localhost", :port=>5432}, :cache=>{:type=>:redis, :url=>"redis://localhost:6379"}, :middleware=>[:session, :logger, :cors]}
Method Chaining DSL
class Query
def initialize(source)
@source = source
@filters = []
@sorts = []
@limit = nil
end
def where(**conditions)
@filters << conditions
self
end
def order(field, direction = :asc)
@sorts << { field: field, direction: direction }
self
end
def limit(count)
@limit = count
self
end
def execute
sql = "SELECT * FROM #{@source}"
sql += " WHERE #{format_filters}" unless @filters.empty?
sql += " ORDER BY #{format_sorts}" unless @sorts.empty?
sql += " LIMIT #{@limit}" if @limit
puts sql
end
private
def format_filters
@filters.flat_map { |f| f.map { |k, v| "#{k} = '#{v}'" } }.join(" AND ")
end
def format_sorts
@sorts.map { |s| "#{s[:field]} #{s[:direction]}" }.join(", ")
end
end
Query.new("users")
.where(active: true)
.where(role: "admin")
.order(:name, :asc)
.limit(10)
.execute
# SELECT * FROM users WHERE active = 'true' AND role = 'admin' ORDER BY name asc LIMIT 10
Macro-Style DSL
Similar to Rails' validates, belongs_to, and scope:
class ModelDSL
class << self
def fields
@fields ||= {}
end
def field(name, type, **options)
fields[name] = { type: type, **options }
define_method(name) { instance_variable_get("@#{name}") }
define_method("#{name}=") { |v| instance_variable_set("@#{name}", v) }
define_method("#{name}?") { !!send(name) } if options[:boolean]
end
def validates(*names, **options)
@validations ||= []
@validations << [names, options]
end
def has_many(name, **options)
define_method(name) do
klass = (options[:class] || name.to_s.classify).to_s
Object.const_get(klass).where("#{self.class.name.downcase}_id" => id)
end
end
end
def save
self.class.instance_variable_get(:@validations)&.each do |names, options|
names.each do |name|
value = send(name)
if options[:presence] && (value.nil? || value.to_s.strip.empty?)
raise "Validation failed: #{name} can't be blank"
end
if options[:length]&.key?(:minimum) && value.to_s.length < options[:length][:minimum]
raise "Validation failed: #{name} too short"
end
end
end
true
end
end
class Article < ModelDSL
field :title, :string
field :body, :text
field :published, :boolean, boolean: true
validates :title, presence: true, length: { minimum: 5 }
validates :body, presence: true
has_many :comments
end
article = Article.new
article.title = "Hello DSL"
article.body = "Building DSLs in Ruby is fun!"
article.save
puts article.title? # NoMethodError (not boolean)
puts article.published? # false (boolean field)
article.published = true
puts article.published? # true
Nested Block DSL
class AppBuilder
def initialize
@components = {}
end
def component(name, &block)
builder = ComponentBuilder.new(name)
builder.instance_eval(&block) if block
@components[name] = builder.to_h
end
def to_h
@components
end
end
class ComponentBuilder
def initialize(name)
@name = name
@settings = {}
@dependencies = []
end
def setting(key, value)
@settings[key] = value
end
def depends_on(*components)
@dependencies.concat(components)
end
def to_h
{ name: @name, settings: @settings, dependencies: @dependencies }
end
end
# Usage
app = AppBuilder.new
app.component :auth do
setting :provider, :oauth2
setting :session_ttl, 3600
end
app.component :api do
setting :rate_limit, 100
depends_on :auth, :cache
end
puts JSON.pretty_generate(app.to_h)
Common Mistakes
1. Polluting the Global Namespace
# Bad — defines methods on Object
def database(name)
@db = name
end
# Good — scoped to DSL class
class Config
def database(name)
@db = name
end
end
2. Block Arguments Confusion
# Different behavior
# block.call(self) vs instance_eval(&block)
# yield(self) — explicit receiver required
Config.new { |c| c.setting :key, "value" }
# instance_eval — receiver is self
Config.new { setting :key, "value" }
3. Not Supporting Nesting
class FlatDSL
# Fluent but doesn't nest well
def setting(key, value)
@settings[key] = value
end
end
# Better — nested DSL for structure
class NestedDSL
def group(name, &block)
@groups ||= {}
builder = GroupBuilder.new
builder.instance_eval(&block)
@groups[name] = builder.to_h
end
end
4. Over-Engineering Simple Cases
# Overkill for a simple config
class ConfigDSL
def method_missing(name, *args)
@config[name] = args.first
end
end
# Simpler
config = { database: "postgres", port: 5432 }
5. Not Providing Escape Hatches
# DSL should allow raw access when needed
class Config
attr_reader :settings # Escape hatch
def setting(key, value)
@settings[key] = value
end
end
Practice Questions
1. What's the difference between block.call(self) and instance_eval?
block.call(self) passes the DSL object as block argument. instance_eval(&block) sets self to the DSL object inside the block. First form requires explicit receiver; second form allows bare method calls.
2. What is method chaining and why is it useful?
Returning self from each method to allow sequential calls: query.where(...).order(...).limit(...). Creates fluent, readable pipelines.
3. How do you handle nesting in DSLs?
Create sub-builders for nested structures. Each level gets its own Builder class with instance_eval for the nested block.
4. When should you NOT use a DSL?
When a simple hash, array, or method call suffices. DSLs add complexity. Use them only when the readability gain justifies the abstraction.
Challenge: Build a DSL for defining cron-like scheduled tasks that supports schedule expressions, task descriptions, and error handlers.
Solution
class Scheduler
class << self
def schedule(&block)
sched = new
sched.instance_eval(&block)
sched.tasks
end
end
def tasks
@tasks ||= []
end
def every(expression, description: nil, &block)
tasks << ScheduledTask.new(expression, description, block)
end
end
class ScheduledTask
attr_reader :expression, :description
def initialize(expression, description, block)
@expression = expression
@description = description
@block = block
end
def on_error(&handler)
@error_handler = handler
end
def execute
@block.call
rescue => e
@error_handler&.call(e)
end
end
# DSL usage
Scheduler.schedule do
every "5 * * * *", description: "Health check" do
puts "Checking system health..."
end
every "0 0 * * *", description: "Daily report" do
puts "Generating daily report..."
end.on_error do |error|
puts "Report failed: #{error.message}"
end
every "*/15 * * * *", description: "Sync data" do
puts "Syncing data..."
end
end
FAQ
{{< faq question="What is the difference between internal and external DSLs?" >}} Internal DSLs use the host language's syntax (Ruby DSLs). External DSLs have custom parsers (SQL, regex). Ruby DSLs are typically internal, leveraging Ruby's flexible syntax. {{< /faq >}}
{{< faq question="Should instance_eval DSLs allow access to outer scope?" >}}
Generally no. instance_eval deliberately changes scope. If outer variables are needed, pass them as block arguments or use closure binding.
{{< /faq >}}
{{< faq question="How do I make DSL methods required vs optional?" >}} Use validation in the build/finalize method. Check required fields and raise descriptive errors. Document required vs optional in comments or README. {{< /faq >}}
{{< faq question="Can DSLs be tested?" >}} Yes. Test the builder class's output (to_h, build, to_s). Test that the DSL parses correctly. Test edge cases like missing required fields. {{< /faq >}}
{{< faq question="How do I document a DSL?" >> Provide usage examples first (people learn DSLs by example). Document each method with its purpose, parameters, and default values. Show the expected output. {{< /faq >}}
Try It Yourself
# dsl_demo.rb
class TaskRunner
def initialize(&block)
@steps = []
instance_eval(&block) if block
end
def step(name, &block)
@steps << { name: name, action: block }
end
def before(&block)
@before = block
end
def after(&block)
@after = block
end
def run
@before&.call
@steps.each_with_index do |step, i|
puts "[#{i + 1}/#{@steps.size}] #{step[:name]}"
step[:action].call
end
@after&.call
puts "Done!"
end
end
TaskRunner.new do
before { puts "Starting deployment..." }
step "Pull latest code" do
puts " git pull origin main"
end
step "Install dependencies" do
puts " bundle install"
puts " npm install"
end
step "Run migrations" do
puts " rails db:migrate"
end
step "Restart server" do
puts " touch tmp/restart.txt"
end
after { puts "Deployment complete!" }
end.run
What's Next
Now that you understand DSLs, dive deeper into send and define_method for dynamic method manipulation.
| Topic | Description | Link |
|---|---|---|
| Ruby send/define_method | Dynamic dispatch, method creation | {{< ref "35-send-define-method" >}} |
| Ruby method_missing | Ghost methods, dynamic proxies | {{< ref "36-method-missing" >}} |
| Python Descriptors | Compare Python's DSL patterns | Python |
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro