githublinkedinemail
← /articles

Writing a simple DSL from scratch in Ruby

Rails, RSpec, Sidekiq, Devise — every famous gem is a DSL. And none of them is magic. They're simple metaprogramming tricks you can replicate in an afternoon.

4 minruby

Writing a simple DSL from scratch in Ruby

DSL is one of the most used and least understood words in the Ruby world.

Every famous gem is a DSL:

  • Rails.application.routes.draw do ... end
  • validates :name, presence: true
  • has_many :posts
  • describe "User" do ... end

And you know what they all have in common?

None of them is magic.

They're simple metaprogramming tricks you can replicate in an afternoon.


What a DSL is

Domain-Specific Language.

A small "language" focused on one domain, that looks like Ruby but is designed to express intent rather than instruction.

Compare:

# imperative
table = Table.new("users")
table.add_column("name", :string)
table.add_column("email", :string)
table.create

# DSL
create_table :users do |t|
  t.string :name
  t.string :email
end

Same thing.

The second one reads like a recipe.


Let's build one from scratch

Goal: a DSL to describe validations.

How we want to use it:

class User
  validate :name, presence: true, max_length: 50
  validate :email, presence: true, format: /@/
end

How does it work underneath?

No magic. Just methods.


Step 1: validate is just a class method

class User
  def self.validate(field, rules)
    @validations ||= {}
    @validations[field] = rules
  end

  def self.validations
    @validations || {}
  end
end

When you write validate :name, presence: true, you're calling the validate class method with arguments.

That's it.

The method stores into a class-level hash.


Step 2: using the validations

class User
  attr_accessor :name, :email

  def valid?
    self.class.validations.all? do |field, rules|
      rules.all? { |rule, value| check(field, rule, value) }
    end
  end

  def check(field, rule, value)
    actual = send(field)
    case rule
    when :presence   then !actual.nil? && actual != ""
    when :max_length then actual.to_s.length <= value
    when :format     then actual.to_s.match?(value)
    end
  end
end

Done. You've just written ActiveModel::Validations in miniature.


Step 3: make it prettier with a block

What if we wanted:

class User
  validate :name do
    presence
    max_length 50
  end
end

This requires one more trick: the block is evaluated in a special context.

class ValidationBuilder
  def initialize
    @rules = {}
  end

  def presence
    @rules[:presence] = true
  end

  def max_length(n)
    @rules[:max_length] = n
  end

  def format(regex)
    @rules[:format] = regex
  end

  def to_h
    @rules
  end
end

class User
  def self.validate(field, &block)
    builder = ValidationBuilder.new
    builder.instance_eval(&block) if block
    (@validations ||= {})[field] = builder.to_h
  end
end

The secret is in instance_eval(&block).

It runs the block as if self were the builder.

That's why presence up there isn't a local variable — it's a method call on builder.


instance_eval vs class_eval

DSLs live by this. Worth understanding.

  • instance_eval(&block)self inside the block becomes the receiver. Method calls go to it.
  • class_eval(&block) — used to open a class and define methods as if you were inside the class ... end.
String.class_eval do
  def shout = upcase + "!"
end

"hi".shout # => "HI!"

It's what Rails.application.routes.draw do ... end does. Runs the block in the right context, and the methods get, post, resources become internal commands.


Serious gotchas with DSL

DSL is power. Power becomes a trap.

1. Encapsulation becomes illusion.

validate :name do
  presence
  some_private_method  # this will work if it exists, when you didn't expect
end

instance_eval exposes EVERYTHING from the class. Private methods become callable in the block's context.

2. Debugging gets harder.

When something breaks inside a DSL, the stack trace points to instance_eval, not the line that seems wrong.

3. Performance.

Every DSL call is metaprogramming. That means: cache miss, full lookup, object allocation.

Not a problem in code that runs on init (routes, class validations).

Is a problem in a hot path.


When to build a DSL

  • Declarative configuration that gets read many times but written once (routes, schemas, validations).
  • Public API of a gem where reading clarity matters more than speed.
  • Reducing boilerplate REPEATED enough to justify the complexity.

And when NOT to:

  • Day-to-day business logic (use objects)
  • When 10 more lines would solve it (don't over-engineer)
  • When the abstraction is just to impress (you, six months from now, won't understand it)

The big shift

A Ruby DSL is just:

  1. Class methods that accept arguments
  2. Blocks evaluated in context via instance_eval
  3. State stored in class variables or "builder" instances

No magic.

Just a pattern.

When you understand this pattern, all of Ruby's "magical" gems become transparent.

Rails, RSpec, Sinatra, Sidekiq, FactoryBot, Devise — all use variations of it.


Conclusion

A well-written DSL reads like prose.

A badly-written DSL reads like a spell.

The difference is knowing the limits of the abstraction.

People who understand how to build DSLs understand when NOT to build them.

And that's the hardest part.


Writing a simple DSL from scratch in Ruby
Writing a simple DSL from scratch in Ruby