Escrever uma DSL simples do zero em Ruby
Rails, RSpec, Sidekiq, Devise — toda gem famosa é uma DSL. E nenhuma é mágica. São truques simples de metaprogramação que você replica em uma tarde.
Escrever uma DSL simples do zero em Ruby
DSL é uma das palavras mais usadas e menos entendidas no mundo Ruby.
Toda gem famosa é uma DSL:
Rails.application.routes.draw do ... endvalidates :name, presence: truehas_many :postsdescribe "User" do ... end
E sabe o que todas têm em comum?
Nenhuma é mágica.
São truques simples de metaprogramação que você consegue replicar em uma tarde.
O que é uma DSL
Domain-Specific Language.
Uma "linguagem" pequena, focada em um domínio, que parece Ruby mas é projetada pra expressar intenção ao invés de instrução.
Compare:
# imperativo
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
Mesma coisa.
A segunda lê como receita.
Vamos construir uma do zero
Objetivo: uma DSL pra descrever validações.
Como queremos usar:
class User
validate :name, presence: true, max_length: 50
validate :email, presence: true, format: /@/
end
Como funciona por baixo?
Não tem mágica. Tem método.
Passo 1: validate é só um método de classe
class User
def self.validate(field, rules)
@validations ||= {}
@validations[field] = rules
end
def self.validations
@validations || {}
end
end
Quando você escreve validate :name, presence: true, está chamando o método de classe validate com argumentos.
Só isso.
O método armazena num hash de classe.
Passo 2: usar as validações
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
Pronto. Você acaba de escrever ActiveModel::Validations em miniatura.
Passo 3: deixar mais bonito com bloco
E se quisermos:
class User
validate :name do
presence
max_length 50
end
end
Isso requer mais um truque: o bloco é avaliado num contexto especial.
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
O segredo está em instance_eval(&block).
Ele executa o bloco como se self fosse o builder.
Por isso presence ali em cima não é variável local — é chamada de método em builder.
instance_eval vs class_eval
DSLs vivem disso. Vale entender.
instance_eval(&block)—selfdentro do bloco vira o receptor. Métodos chamados vão pra ele.class_eval(&block)— usado pra abrir uma classe e definir métodos como se estivesse dentro doclass ... end.
String.class_eval do
def shout = upcase + "!"
end
"oi".shout # => "OI!"
É o que Rails.application.routes.draw do ... end faz. Roda o bloco no contexto certo, e os métodos get, post, resources viram comandos internos.
Cuidados sérios com DSL
DSL é poder. Poder vira armadilha.
1. Encapsulamento vira ilusão.
validate :name do
presence
some_private_method # vai funcionar se existir, sem você esperar
end
instance_eval exõe TUDO da classe. Métodos privados viram públicos no contexto do bloco.
2. Debug fica mais difícil.
Quando algo quebra dentro de DSL, o stack trace aponta pra instance_eval, não pra linha que parece estar errada.
3. Performance.
Toda chamada de DSL é metaprogramação. Significa: cache miss, lookup completo, alocação de objeto.
Não é problema em código que roda na inicialização (rotas, validações de classe).
É problema em hot path.
Quando criar DSL
- Configuração declarativa que vai ser lida muitas vezes mas escrita uma vez (rotas, schemas, validações).
- API pública de gem onde clareza de leitura vale mais que velocidade.
- Reduzir boilerplate REPETIDO o suficiente pra justificar a complexidade.
E quando NÃO criar:
- Lógica de negócio do dia a dia (use objetos)
- Quando 10 linhas a mais resolveriam (não over-engineer)
- Quando a abstração serve só pra impressionar (você mesmo daqui 6 meses não vai entender)
A grande virada de chave
DSL em Ruby é só:
- Métodos de classe que aceitam argumentos
- Blocos avaliados em contexto via
instance_eval - Estado guardado em variáveis de classe ou instâncias de "builder"
Não tem magia.
Tem padrão.
Quando você entende esse padrão, todas as gems "mágicas" do Ruby ficam transparentes.
Rails, RSpec, Sinatra, Sidekiq, FactoryBot, Devise — todas usam variações disso.
Conclusão
DSL bem escrita parece prosa.
DSL mal escrita parece feitiço.
A diferença está em saber o limite da abstração.
Quem entende construir DSL entende quando NÃO construir.
E essa é a parte mais difícil.
