En el diseño orientado a objetos, un Value Object representa una entidad inmutable, comparada por valor y sin identidad propia (es decir, su identidad es el valor). Es un patrón común en lenguajes como Java, C# y Kotlin.
Por ejemplo si necesitamos representar una fecha, dinero, fracciones, números complejos, etc. usaremos este patrón.
Un Value Object tiene tres características esenciales:
- Inmutabilidad: su estado no cambia después de ser creado.
- Comparación por valor: dos objetos con los mismos atributos se consideran iguales.
- Ausencia de identidad propia: no importa quién lo creó ni cuándo, solo importa su valor.
En C# con record, tenés inmutabilidad y comparación por valor automáticamente:
public record Money(decimal Amount, string Currency);
var a = new Money(10, "USD");
var b = new Money(10, "USD");
Console.WriteLine(a == b); // True
Y no podés modificar sus valores.
En Ruby, podés definir una clase que parezca un Value Object:
class Money
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
def ==(other)
amount == other.amount && currency == other.currency
end
end
Hasta ahí todo bien, pero... todo es mutable
usd = Money.new(100, "USD")
usd.instance_variable_set(:@amount, 999) # ¡Booom!
Ruby no impide modificar los atributos internos con metaprogramación. Incluso podés cambiar el comportamiento de un único objeto:
usd.define_singleton_method(:amount) { 0 }
Este tipo de cosas rompen completamente la idea de inmutabilidad.
Entonces, ¿Cómo hacer que un VO en Ruby sea más seguro? No hay garantía total, pero hay algunas medidas:
class SafeMoney
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount.freeze
@currency = currency.freeze
freeze
end
def ==(other)
amount == other.amount && currency == other.currency
end
end
- freeze impide modificaciones.
- freeze también se aplica a los valores internos.
- El objeto completo se congela con freeze.
Aun así... no es 100% a prueba de balas. Ruby confía en vos.
Ruby es expresivo, flexible y poderoso. Pero esa flexibilidad puede ser peligrosa cuando aplicás patrones pensados para lenguajes más rígidos.
¿Querés un Value Object en Ruby? Podés tener algo parecido, pero tené en cuenta que, la inmutabilidad en Ruby es un acto de fe... y freeze es tu mejor aliado.
Y por ultimo un recuerdo de cuando aprendi Ruby: Antes de Ruby 2.4, existían dos clases distintas para representar enteros: Fixnum (para enteros pequeños) y Bignum (para enteros grandes). Aunque eran objetos y permitían monkey patching, seguían siendo inmutables, y si se intentaba forzar una mutación real, el programa fallaba.
class Fixnum
def mutate!
self.replace(99) # Esto explota
end
end
5.mutate!
# => Error: can't modify frozen Integer (TypeError)
Incluso si se intentaba hacer algo como modificar self o reemplazar el contenido del número, Ruby lo impedía, ya que los enteros eran internamente inmutables y congelados. Esto confundía a quienes veían métodos con ! en otros objetos como String, donde sí había mutabilidad real.
Desde Ruby 2.4, Fixnum y Bignum se unificaron en Integer, y aunque el comportamiento de inmutabilidad se mantiene, ahora es más claro que los enteros no pueden ni deben ser mutados.