Camadas Declarativas: Um Padrão Flexível para Ruby

Em Ruby , os blocos são uma das ferramentas mais elegantes e expressivas à nossa disposição. Eles permitem que métodos recebam comportamento como argumento e ajudam a manter o código conciso e legível. De estruturas como eachmapFile.openaté bibliotecas como RSpec e Rails , os blocos são essenciais para o estilo de programação Ruby .

Qualquer pessoa familiarizada com Ruby certamente já se deparou com um bloco em algum momento. Mas antes de prosseguir, uma rápida explicação sobre o que exatamente é um bloco e por que ele é tão útil no ecossistema Ruby . Sinta-se à vontade para pular para o próximo capítulo se você já se sentir confortável em usá-lo.

O que é um bloco

Um bloco é um pedaço de código que pode ser passado como parâmetro e executado em um contexto diferente daquele em que foi declarado. Em Ruby, podemos invocá-lo usando o yieldmétodo e até mesmo passar argumentos para ele. Se o bloco for opcional, o block_given?método nos permite verificar com segurança se um bloco foi fornecido.

Também podemos capturar explicitamente o bloco usando &block, o que permite que ele seja tratado como um Procobjeto, facilitando sua reutilização ou encaminhamento para outros métodos.

Vejamos alguns exemplos abaixo:

def  greetings
puts "Oi!"
yield if block_given?
puts "Tchau!"
end

greetings do
puts "Como vai você?"
end

# =>
# Oi!
# Como vai você?
# Tchau!

def execute_two_times ( &block )
block.call
block.call
end

execute_two_times do
puts "Chamando o bloco!"
end

# =>
# Chamando o bloco!
# Chamando o bloco!

Com esses exemplos simples, podemos ver como Ruby pode ser altamente expressivo, abrindo caminho para alguns recursos bem DRY (Don’t Repeat Yourself). Ao longo dos anos trabalhando com Ruby, fiz uso extensivo de blocos em helpersservicesconcerns… E a lista continua.

Camada Declarativa

Com o tempo, usando o Rails , sempre me impressionei com a elegância com que a gem ActsAsTenant permite definir o tenant com base no domínio ou subdomínio. Com uma única linha, declaramos a lógica que vincula a solicitação ao tenant correto :

definir_locatário_atual_por_subdomínio_ou_domínio( :conta , :subdomínio , :domínio )

Este método é chamado diretamente dentro do controller, lado a lado com métodos clássicos como before_actionou helper_method, e implementa um comportamento altamente específico — mas de forma tão fluida, tão perfeitamente integrado ao ambiente Rails , que quase parece um comando nativo do próprio framework. É uma daquelas construções que exemplifica perfeitamente o que veio a ser conhecido como o Estilo Rails : simples, legível e incrivelmente poderoso.

Essa sutileza me inspira há muito tempo. Sempre tive a esperança de um dia criar algo que tivesse essa mesma elegância — uma maneira de encapsular comportamentos complexos por meio de uma interface declarativa, direta e natural.

Primeira tentativa

Minha primeira tentativa de criar algo tão elegante quanto a definição de tenant surgiu da necessidade de filtrar algumas consultas por meio de parâmetros de URL , e fazê-lo com comportamento condicional e expressividade. A ideia principal era permitir que os escopos fossem ativados automaticamente quando determinados parâmetros fossem usados. Deveria ser usado da seguinte forma:

classe  MyController < ApplicationController
add_scope :type_a
add_scope :type_b
add_scope :type_c , if: -> { condição? }

# ...
fim

Se a URL contiver os termos-chave type_atype_btype_ce as condições correspondentes (se houver) forem avaliadas como verdadeiras, o escopo apropriado será aplicado automaticamente à consulta. Para atingir esse comportamento, implementei um concernque estende os métodos de classe de qualquer modelo que o inclua:

módulo MyConcern 
estender ActiveSupport::Concern

def self .included(base)
base.class_eval do
estender ClassMethods
class_attribute :scopes
self .scopes = []
fim
fim

módulo ClassMethods
def add_scope ( escopo, **opções )
escopos << { escopo: , **opções }
fim
fim

# Implementação do recurso de escopo que é irrelevante por enquanto...
fim

A implementação acima cria um concernque, quando incluído por uma classe (representada pelo baseparâmetro), adiciona comportamentos a essa classe via class_eval. A linha extend ClassMethodssignifica que o ClassMethodsmódulo fornecerá métodos de classe — em outras palavras, métodos que podem ser chamados diretamente na classe, em vez de em suas instâncias.

O nome ClassMethodsé apenas uma convenção amplamente utilizada na comunidade Rails ; qualquer outro nome também funcionaria. Na verdade, ao estender ActiveSupport::Concern, podemos simplificar essa definição usando o class_methodsbloco, que encapsula métodos de classe de uma forma mais elegante e concisa.

Isso concerndefine uma lista chamada scopese um método add_scope, que adiciona elementos a essa lista de forma simples e declarativa. Este atributo reside no escopo da classe , o que traz uma vantagem importante em contextos como controladores Rails .

Em uma aplicação web, cada requisição HTTP (como uma chamada REST) ​​resulta na criação de uma nova instância do controlador — ou seja, os objetos que manipulam as requisições não compartilham o estado entre si . Isso significa que quaisquer dados armazenados em variáveis ​​ou métodos de instância são transitórios , existindo apenas durante a execução daquela requisição específica.

Atributos de classe (como self.scopes), por outro lado, residem no escopo da própria classe , sendo compartilhados entre todas as instâncias. Isso permite que configurações ou declarações feitas por meio dela add_scopesejam persistentemente registradas e acessíveis por qualquer instância futura do controlador.

Segunda Tentativa

Minha segunda tentativa surgiu da necessidade de atribuir dinamicamente comportamento estruturado a diferentes classes ActiveModel de forma elegante (de preferência). O uso pretendido seria algo assim:

Classe MyModel < MyRootModel 
define_slots do tipo
de slot : :a , requer: "MySubClassOfTypeA" tipo de slot : :a tipo de slot : :b fim fim


A ideia aqui é que o modelo tenha três slots: dois do tipo ae um do tipo b, e um dos slots do tipo adeve necessariamente conter um objeto da classe MySubClassOfTypeA.

Meu objetivo era obter um hash estruturado com os slots disponíveis para cada modelo , e a implementação foi a seguinte:

classe  MyRootModel 
classe << auto
def define_slots ( &block )
@slot_rules = []

dsl = Classe .new do
def inicializar ( slots )
@slots = slots
fim

def slot ( **kwargs )
@slots << kwargs
fim
fim

dsl.new( @slot_rules ).instance_eval(&block)
fim
fim
fim

Esta implementação demonstra um uso mais sofisticado de blocos. O método define_slotsrecebe um bloco e o executa dentro do contexto de uma classe interna, criada exclusivamente para interpretar aquele domínio específico. O comando slotpor si só não tem significado no escopo externo — ele não pertence a ActiveRecordou a MyRootModel. Mas, quando interpretado dentro de uma instância dessa classe anônima, ele ganha contexto e propósito. Assim, um simples bloco se transforma em uma linguagem declarativa específica do domínio, com seu próprio vocabulário. E a melhor parte: estender essa “linguagem” é tão simples quanto adicionar um novo método à classe DSL.

Com essa abordagem, qualquer modelo que herde de MyRootModelpode definir seus slots usando define_slotse acessar as regras declaradas via @slot_rules. Em algum momento, refatorei essa lógica — extraindo a classe anônima para seu próprio arquivo e dando uma estrutura mais robusta aos slotargumentos (substituindo o uso direto de **kwargs) — mas isso está além do escopo atual. A versão apresentada aqui já é suficiente para demonstrar a ideia central: como o uso consciente de blocos pode permitir pedaços elegantes e reutilizáveis, perfeitamente alinhados com a expressividade do Ruby.

Terceira Tentativa

Minha terceira e mais recente tentativa também foi a que mais me entusiasmou. No meu trabalho na Acsiv, os dados necessários para um recurso às vezes variam entre os contextos. Um bom exemplo é o cadastro de indivíduos. Dependendo da associação, são necessários atributos diferentes.

Por lei, o registro do requerente de um ato notarial deve incluir nome, e-mail e número de telefone, enquanto o registro de funcionário não exige informações de contato. A situação fica ainda mais curiosa quando falamos de certidões:

  • para uma certidão de nascimento , precisamos da data de nascimento;
  • para uma certidão de óbito , precisamos da data do óbito.

Portanto, temos um modelque está associado a muitos outros, e a validação de seus atributos depende de qual deles está relacionado . A solução óbvia seria definir as validações dentro do modelpróprio objeto, mas, nesse caso, ele precisaria saber a que está relacionado, e o código ficaria mais ou menos assim:

valida :email , presença:  true , se: -> { personable.is_a?(Request) } 
valida :date_of_birth , presença: true , se: -> { personable.is_a?(Birth) }
valida :date_of_death , presença: true , se: -> { personable.is_a?(Death) }

Diga olá ao alto acoplamento e à baixa coesão 😊

E como bons seguidores do Código Limpo do Tio Bob , sabemos que não é assim que deveria ser:

“Código limpo é código com alta coesão e baixo acoplamento.”

A solução:validates_association

Para resolver o problema da forma mais elegante possível e sem violar os princípios de design, validates_associationsurgiu a ideia de criar. É muito fácil: permitir que a classe que define a relação ( BirthDeath, etc.) também defina as validações de associação , exatamente como faria com validates, mas dentro de um bloco personalizado. Exemplo:

classe  Nascimento
tem_um :pessoa
valida_associação :pessoa faz
valida :data_de_nascimento , presença: verdadeiro
fim
fim

classe Morte
tem_um :pessoa
valida_associação :pessoa faz
valida :data_de_morte , presença: verdadeiro
fim
fim

Veja abaixo como podemos criar o método:

módulo AssociationValidatable 
estender ActiveSupport::Concern

class_methods do
def validates_association ( associação, &bloco )
reflection = reflect_on_association(associação)
klass = reflection.klass
foreign_key = reflection.type

dsl = Class .new do
def initialize ( klass, foreign_key, referência )
@klass = klass
@foreign_key = foreign_key
@reference = referência
fim

def validates ( *atributos, **opções )
fk = @foreign_key
ref = @reference

condição = -> { atributos[fk] == ref }
opções[ :if ] = condição

@klass .validates(*atributos, **opções)
fim
fim

dsl.new(klass, foreign_key, nome).instance_eval(&bloco)
fim
fim
fim

Com essa implementação, a ideia de permitir que a classe que define o relacionamento também defina as validações dessa associação — desde que a associação aponte para ela — torna-se realidade. Observe que aqui usamos class_methods, como explicado anteriormente. Por meio dele, modelsos include AssociationValidatableobtêm acesso ao validates_associationmétodo. Esse método, por sua vez, instancia uma classe anônima (como vimos na segunda tentativa), que define um validatesmétodo com a mesma assinatura da ActiveModelversão original — e, por fim, delega a chamada a ele.

Você pode ter notado as variáveis fk​​e refe se perguntado por que elas são usadas. Isso é necessário devido ao modo como os closures (lambdas/procs) funcionam em Ruby . Variáveis ​​de instância como @foreign_key@referencesão avaliadas no contexto do objeto onde o lambda é executado, não onde foi definido . Em outras palavras, o bloco -> { attributes[@foreign_key] == @reference }seria executado dentro da instância de associação — e, como essa instância não possui as variáveis @foreign_key​​e @reference, o resultado seria sempre nil. Ao atribuir esses valores a variáveis ​​locais ( fkref), garantimos que seus valores sejam capturados corretamente pelo closure , já que lambdas em Ruby capturam o escopo local no momento da definição.

Não se esqueça de reiniciar o servidor! Essas alterações afetam a definição da classe, que só é carregada durante a inicialização.

Dito isto, ainda podemos melhorar nossa implementação de várias maneiras, como:

  • Criação de açúcar sintático para simplificar código repetitivo;
  • Permitir que a associação valide suas próprias associações ;
  • Tornando as validações condicionais , se necessário.

Dentre elas, a criação do açúcar sintático é a mais simples — então vamos começar com isso.

A ideia aqui é permitir que você use uma validação predefinida de forma clara e concisa, como neste exemplo:

validates_association :pessoa , como:  :solicitante

O primeiro passo é definir uma predefinição chamada :requester. Declarei o bloco dentro da associação modelPersonneste caso.

classe  Pessoa 
VALIDATION_PRESETS = {
solicitante: proc do
valida :doc_cpf_cnpj , presença: true
valida :email , presença: true
valida :phone_number , presença: true
fim
}
fim

No pedaço acima, VALIDATION_PRESETShá apenas uma constante padrão do Ruby , o que significa que podemos acessá-la com Person::VALIDATION_PRESETS.

Agora, vamos adaptar nosso código para que ele possa responder a um as:parâmetro e encontrar a predefinição correta:

módulo AssociationValidatable 
extend ActiveSupport::Concern

class_methods do
def validates_association ( association, as: nil , &block )
reflection = reflect_on_association(association)
klass = reflection.klass
foreign_key = reflection.type
preset = options[ :as ]

dsl = Class .new do
def initialize ( klass, foreign_key, reference )
@klass = klass
@foreign_key = foreign_key
@reference = reference
end

def validates ( *attrs, **options )
fk = @foreign_key
ref = @reference

condition = -> { attributes[fk] == ref }
options[ :if ] = condition

@klass .validates(*attrs, **options)
end
end

block | |= klass:: VALIDATION_PRESETS [preset]

raise ArgumentError, "Nem bloco nem predefinição foram fornecidos." if block. nil ?

dsl.new(klass, chave_estrangeira, nome).instance_eval(&bloco)
fim
fim
fim

Com essa mudança, validates_associationagora suporta:

  • um tradicional block, com validações declaradas em linha;
  • uma as:opção que aponta para uma predefinição previamente definida no associado model.

Esta pequena adição aumenta significativamente a flexibilidade e ajuda a manter o modelsambiente mais limpo e coeso.

Agora, vamos aos pontos restantes.

Imagine que Birthestá relacionado a Person, e Person, por sua vez, está relacionado a Address. Queremos permitir Birtha validação não apenas Persondos campos de , mas também daqueles de Addressquando existe um relacionamento entre eles.

O que nos leva à seguinte questão: como validamos uma associação de terceiro nível?

Com essa abordagem, é bem simples! Precisamos apenas que nossa classe anônima tenha seu próprio validates_associationmétodo — um que saiba como acessar a associação interna, validar sua estrutura e permitir a especificação de condições personalizadas.

módulo AssociationValidatable 
estender ActiveSupport::Concern

class_methods do
def validates_association ( associação, **opções, &bloco )
reflection = reflect_on_association(associação)
klass = reflection.klass
foreign_key = reflection.type

preset = options[ :as ]
if_condition = options[ :if ]
unless _condition = options[ :unless ]

dsl = Class .new do
def initialize ( klass, foreign_key, referência, if_condition, unless _condition )
@klass = klass
@foreign_key = foreign_key
@reference = referência
@if_condition = if_condition | | -> { true }
@unless_condition = unless _condition | | -> { false }
fim

def valida ( *attrs, **opções )
fk = @foreign_key
ref = @reference
if_condition = @if_condition
unless _condition = @unless_condition
condição = -> { atributos[fk] == ref && instance_exec(&if_condition) }

se opções[ :if ]
existente = opções[ :if ]
opções[ :if ] = -> { instância_exec(&existente) && instância_exec(&condição) }
senão
opções[ :if ] = condição
fim

se opções[ :unless ]
existente = opções[ :unless ]
opções[ :unless ] = -> { instância_exec(&existente) | | instance_exec(& unless _condition) }
else
options[ :unless ] = unless _condition
end

@klass .validates(*attrs, **options)
end

def validates_association ( associação, **opções, &bloco )
reflection = @klass .reflect_on_association(associação)
fk = @foreign_key
ref = @reference
preset = options[ :as ]
unless _condition = options[ :unless ]

if_condition = if options[ :if ]
existing = options[ :if ]
-> { instance_exec(&existing) && public_send(reflection.options[ :as ]).public_send(fk) == ref }
else
-> { public_send(reflection.options[ :as ]).public_send(fk) == ref }
end

@klass .validates_association(association, preset: , if: if_condition, unless: unless _condition, &block)
end
end

block | |= klass:: VALIDATION_PRESETS [preset]

raise ArgumentError, "Nem bloco nem predefinição foram fornecidos." if block. nil ?

dsl.new(klass, chave_estrangeira, nome, condição_se, a menos que_condição).instance_eval(&bloco)
fim
fim
fim

O mais importante é entender que fechamentos (lambdas/procs) são executados no contexto da modelvalidação, portanto, precisamos garantir que o relacionamento entre todos os objetos na cadeia esteja definido corretamente. Em nosso exemplo, Addressdeve ser associado a Person, e este Person, por sua vez, deve ser associado corretamente a Birthpara que as validações definidas em Birthsejam aplicadas às associações aninhadas.

Com isso em mente, agora podemos usá-lo conforme mostrado nos exemplos a seguir:

  validates_association :pessoa  do
validates :data_de_nascimento , presença: verdadeiro

validates_association :endereço do
validates :rua , presença: verdadeiro
fim
fim
  validates_association :pessoa  faz
valida :data_de_nascimento , presença: verdadeiro , se: -> {condição?}

validates_association :endereço faz
valida :rua , presença: verdadeiro , a menos que: -> {outra_condição?}
fim
fim
  valida_associação :pessoa , se: -> {condição?} faz
valida :data_de_nascimento , presença: verdadeiro

valida_associação :endereço , a menos que: -> {outra_condição?} faz
valida :rua , presença: verdadeiro
fim
fim

O que chamo de Camada Declarativa é o resultado final ao qual cheguei após tentar extrair todo o potencial dos blocos . Nós os utilizamos para organizar o comportamento de forma clara e modular, focando mais na declaração do que na lógica em si. Em vez de definir regras ou lógica diretamente dentro dos métodos, encapsulamos essas responsabilidades em camadas bem definidas que podem ser facilmente compostas, reutilizadas e testadas.

Explorar camadas declarativas revela um dos aspectos mais poderosos do Ruby : a capacidade de transformar código em linguagem. Em vez de impor estruturas rígidas ou acoplamentos complexos, criamos espaços onde a semântica de domínio assume o controle. O resultado são interfaces legíveis e extensíveis que se adaptam ao problema que estamos modelando — e não o contrário. Essas camadas podem ser consideradas pequenas DSLs , permitindo que você crie seus próprios contextos. No final, o que antes parecia “apenas um bloco com um limite” torna-se uma base sólida para projetos mais expressivos e sustentáveis.

Escrito por: Fillipe Palhares