githublinkedinemail
← /articles

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.

4 minruby

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 ... end
  • validates :name, presence: true
  • has_many :posts
  • describe "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)self dentro 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 do class ... 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ó:

  1. Métodos de classe que aceitam argumentos
  2. Blocos avaliados em contexto via instance_eval
  3. 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.


Escrever uma DSL simples do zero em Ruby
Escrever uma DSL simples do zero em Ruby