Value Objects no Ruby on Rails
Value Object é um padrão que visa transformar valores primitivos em objetos ricos de domínio. Ele é uma forma de distribuir a complexidade de classes com muitas responsabilidades de forma organizada e encapsulada. Vamos ver uma das formas de implementar Value Objects fornecidas pelo Active Record do Rails com o…
Value Object é um padrão que visa transformar valores primitivos em objetos ricos de domínio. Ele é uma forma de distribuir a complexidade de classes com muitas responsabilidades de forma organizada e encapsulada. Vamos ver uma das formas de implementar Value Objects fornecidas pelo Active Record do Rails com o método composed_of
.
Exemplo: Trabalhando com Preço
Value Objects são objetos que se identificam pelo seu conteúdo, e não por um identificador específico.
Vamos supor que estejamos no contexto onde um item de um pedido tenha um valor unitário e pode ser aplicado o desconto, ao invés de aplicar toda a lógica do desconto, validações e cambio de moeda na classe de Pedido, podemos encapsular esse comportamento numa classe específica para o valor unitário.
class ValorUnitario
attr_reader :valor_unitario, :desconto_unitario, :moeda
def initialize(valor_unitario, desconto_unitario, moeda)
@valor_unitario = valor_unitario
@desconto_unitario = desconto_unitario
@moeda = moeda
end
def aplicar_desconto(desconto_unitario)
valor_com_desconto = valor_unitario - desconto_unitario
self.class.new(valor_com_desconto, desconto_unitario, moeda)
end
end
No código acima a classe que representa o valor unitário só tem seus valores definidos via construtor, caso seja necessária uma alteração nos valores, uma nova classe deve ser inicializada conforme ocorre no método `aplicar_desconto`.
Para usar o value object em models do Rails, podemos usar o método composed_of
, onde passamos o nome da classe como symbol
, e o framework irá procurar automaticamente a classe ValorUnitario
, caso queira definir um nome diferente do da classe, é possível definir explicitamente, como composed_of :preco_unitario, class_name: "ValorUnitario"
composed_of :valor_unitario, mapping: { valor_unitario: :valor_unitario,
desconto_unitario: desconto_unitario,
moeda: :moeda }
Também é possível usar as validações do Rails nos objetos de valor e validá-los nos models, para isso será necessário incluir o ActiveModel::Validations
.
class ValorUnitario
include ActiveModel::Validations
attr_reader :valor_unitario, :desconto_unitario, :moeda
validates :valor_unitario, :desconto_unitario, numericality: { greater_than_or_equal_to: 0 }
E no modelo de Pedido utilizar o `validates_associated`:
class Pedido < ApplicationRecord
composed_of :valor_unitario, mapping: { preco_unitario: :valor_unitario,
desconto_unitario: :desconto_unitario,
moeda: :moeda }
validates_associated :valor_unitario
end
Com isso, ao tentar salvar um pedido sem valor unitário preenchido vamos receber as mensagens de erro:
pedido = Pedido.new
=> #<Pedido:0x00007065afb691c8 id: nil, valor_unitario: nil, qtde: nil, desconto_unitario: nil, moeda: nil, created_at: nil, updated_at: nil>
(dev)> pedido.save
=> false
(dev)> pedido.errors.messages
=> {:valor_unitario=>["is invalid"]}
(dev)> pedido.valor_unitario.errors.messages
=> {:valor_unitario=>["is not a number"], :desconto_unitario=>["is not a number"]}
Imutabilidade
Idealmente, um Value Object deve ser imutável, ou seja, seus valores não podem ser alterados após serem instanciados. Caso seja feita uma alteração em um dos seus valores, deverá ser criado um novo objeto. Com isso é importante não criar atributos com writer
públicos, somente attr_readers
. Ao utilizar o composed_of
o Active Record congela automaticamente o objeto, então caso um writer seja exposto, ao fazer uma alteração ocorrerá um RunTimeError
.
Comparações
Para comparações entre objetos de valor em Ruby é recomendado fazer o include e a sobrescrita dos métodos do mixin Comparable
como os métodos ==
e <=>
.
class ValorUnitario
include Comparable
include ActiveModel::Validations
attr_reader :valor_unitario, :desconto_unitario, :moeda
validates :valor_unitario, :desconto_unitario, numericality: { greater_than_or_equal_to: 0 }
def initialize(valor_unitario, desconto_unitario, moeda)
@valor_unitario = valor_unitario
@desconto_unitario = desconto_unitario
@moeda = moeda
end
def aplicar_desconto(desconto_unitario)
valor_com_desconto = valor_unitario - desconto_unitario
self.class.new(valor_com_desconto, desconto_unitario, moeda)
end
def ==(other)
valor_unitario == other.valor_unitario
end
def <=>(other)
valor_unitario <=> other.valor_unitario
end
end
Buscas em Queries
As queries do Active Record também funcionam com os Value Objects, um exemplo de busca possível é:
teste-rails8(dev)> Pedido.where(valor_unitario: ValorUnitario.new(10.0, 0, "BRL"))
Pedido Load (0.2ms) SELECT "pedidos".* FROM "pedidos" WHERE "pedidos"."preco_unitario" = 10.0 AND "pedidos"."desconto_unitario" = 0.0 AND "pedidos"."moeda" = 'BRL' /* loading for pp */ LIMIT 11 /*application='TesteRails8'*/
=>
[#<Pedido:0x00007f510a14f660
id: 1,
preco_unitario: 0.1e2,
qtde: nil,
desconto_unitario: 0.0,
moeda: "BRL",
created_at: "2024-10-14 04:26:25.606715000 +0000",
updated_at: "2024-10-14 04:26:25.606715000 +0000">]
Quando utilizar Value Objects?
É possível usar o padrão com atributos comuns, como email e nome. Uma entidade / modelo pode ter vários Value Objects, é recomendado para distribuir a complexidade diminuindo os “fat models” comuns em aplicação Rails com certa complexidade de regra de negócio. Alguns exemplos interessantes são:
- Um objeto de Valor para Money, recebendo o valor e taxa de câmbio e cuidando da cotação;
- Temperatura que compara e converte as unidades de medidas como Celsius e Fahrenheit;
- Endereço: quando temos uma associação 1:1 de usuário com endereço, podemos deixar as colunas na tabela de usuarios, mas criar um Value Object específico para o Endereço.
Conclusão
Value Objects ajudam a encapsular e isolar a lógica relacionada a valores dentro de classes pequenas e reutilizáveis. É um padrão amplamente recomendado em literaturas como Domain Driven Design. No Rails, o uso de composed_of
facilita a integração desses objetos nos models, e as validações nativas do Rails podem ser usadas para garantir a consistência desses valores. Essa prática melhora a legibilidade do código, torna-o mais modular e facilita a manutenção, especialmente em domínios ricos em regras de negócio.