Value Objects
Objetos inmutables definidos por sus atributos, no por su identidad. La unidad mínima de lógica de dominio.
Un Value Object es un objeto cuya igualdad se define exclusivamente por el valor de sus atributos. Dos instancias con los mismos valores son intercambiables: no tienen identidad propia, no tienen ciclo de vida independiente y nunca cambian después de ser creadas. Ejemplos canónicos son un monto monetario (100 USD), una dirección postal o un rango de fechas.
La inmutabilidad es su propiedad más importante. Cuando necesitas 'cambiar' un Value Object, no lo mutes: crea uno nuevo. Esto elimina efectos secundarios, hace el código predecible y facilita el testing porque un VO con los mismos inputs siempre produce el mismo resultado.
Los Value Objects son el lugar natural para la auto-validación. Las reglas de negocio que restringen un concepto (un email debe tener @, una cantidad no puede ser negativa) viven en el constructor o en un método de fábrica. Si el objeto existe, es válido. No es necesario validar en la capa de aplicación ni en el repositorio.
En Python, @dataclass(frozen=True) es la forma idiomática de implementar Value Objects. El decorador genera __eq__ y __hash__ basados en los campos, y frozen=True impide la mutación. Esto los hace safe para usar como claves de diccionario o en conjuntos.
Los Value Objects no solo transportan datos: encapsulan comportamiento sin efectos secundarios. Un VO de dinero sabe sumar montos de la misma moneda, un VO de rango de fechas sabe si contiene otra fecha, un VO de coordenada sabe calcular la distancia a otra coordenada. Este comportamiento vive en el VO, no en un servicio externo.
La diferencia clave con una Entity es que los VOs no tienen identidad en la base de datos. Se almacenan como columnas en la fila del objeto que los contiene (embedded), o como registros denormalizados. Cuando el VO 'cambia', se guarda un nuevo valor: no se actualiza un registro por id.
# --- shared_kernel/domain/value_object.py ---
# Base opcional; @dataclass(frozen=True) es suficiente en la mayoría de casos.
# NO agregues métodos de persistencia ni importes infraestructura aquí.
from dataclasses import dataclass
from decimal import Decimal
# Correct: frozen=True hace el VO inmutable y __eq__/__hash__ por valor
@dataclass(frozen=True)
class <ValueObject>:
value: str # o el tipo apropiado
def __post_init__(self) -> None:
# Auto-validación: si el objeto existe, es válido
if not self.value:
raise ValueError("<ValueObject> cannot be empty")
# Ejemplo: VO con comportamiento sin efectos secundarios
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self) -> None:
if self.amount < Decimal('0'):
raise ValueError("Amount cannot be negative")
if not self.currency:
raise ValueError("Currency is required")
def add(self, other: 'Money') -> 'Money':
# Devuelve un NUEVO VO, no muta el actual
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(amount=self.amount + other.amount, currency=self.currency)
def is_zero(self) -> bool:
return self.amount == Decimal('0')
# WRONG — mutable: NO es un Value Object válido
# class BadVO:
# def __init__(self, value):
# self.value = value # ← mutable, sin frozen
# def set_value(self, v): # ← setter = anti-patrón
# self.value = vDebugging lab
Detecta y corrige el error o la violación de diseño.
- 3.1.5.1
class EmailAddress: def __init__(self, value: str): self.value = value def set_value(self, new_value: str): self.value = new_value
- 3.1.5.2
@dataclass(frozen=True) class Quantity: amount: float unit: str # En el servicio de dominio: def apply_discount(q: Quantity, pct: float) -> None: q.amount = q.amount * (1 - pct) # aplicar descuento
- 3.1.5.3
@dataclass(frozen=True) class ProductId: id: int name: str # ← atributo extra price: float
- 3.1.5.4
from sqlalchemy import Column, String from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() @dataclass(frozen=True) class Address: street: str city: str _table = Column(String) # ← columna ORM dentro del VO
- 3.1.5.5
# En el application service: def create_order(street: str, city: str) -> None: if not street: raise ValueError('street required') # validación fuera del VO if not city: raise ValueError('city required') # duplicada addr = Address(street=street, city=city)