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.
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 ... endvalidates :name, presence: truehas_many :postsdescribe "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)—selfinside 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 theclass ... 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:
- Class methods that accept arguments
- Blocks evaluated in context via
instance_eval - 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.
