Skip to content

Ruby Domain-Specific Languages — Building Fluent Interfaces and Internal DSLs Explained

DodaTech Updated 2026-06-28 8 min read

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