← Volver al listado de tecnologías
Capítulo 4: Value Objects
Capítulo 4: Value Objects
¿Qué es un Value Object?
Un Value Object (VO) es un objeto que describe una característica del dominio, definido completamente por sus atributos. No tiene identidad conceptual propia.
Características Fundamentales
- Inmutabilidad - Una vez creado, no cambia
- Igualdad por valor - Dos VOs con mismos atributos son iguales
- Sin identidad - No tiene un ID único
- Auto-validante - Valida sus invariantes en construcción
- Reemplazable - Se reemplaza completo, no se modifica
“Cuando solo te importa QUÉ es algo, no CUÁL es, tienes un Value Object.” — Eric Evans
Cuándo Usar Value Objects
┌────────────────────────────────────────────────────────────┐
│ PREGUNTA: ¿Necesito rastrear este objeto individualmente? │
├────────────────────────────────────────────────────────────┤
│ │
│ "¿Es ESTE billete de $100 específico?" → NO → Value Object
│ "¿Son $100?" → SÍ │
│ │
│ "¿Es ESTE cliente específico?" → SÍ → Entidad │
│ "¿Es un cliente?" → NO │
│ │
│ "¿Es ESTA dirección específica?" → NO → Value Object
│ "¿Es la dirección correcta?" → SÍ │
└────────────────────────────────────────────────────────────┘
Implementación Base
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True) # frozen=True garantiza inmutabilidad
class ValueObject:
"""Clase base para Value Objects"""
def __post_init__(self):
"""Validar invariantes después de la inicialización"""
self._validar()
def _validar(self) -> None:
"""Sobrescribir en subclases para validaciones"""
pass
def __eq__(self, other: Any) -> bool:
if not isinstance(other, self.__class__):
return False
return self.__dict__ == other.__dict__
def __hash__(self) -> int:
return hash(tuple(sorted(self.__dict__.items())))
Value Object: Dinero
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import Self
@dataclass(frozen=True)
class Moneda:
codigo: str
simbolo: str
decimales: int
@classmethod
def USD(cls) -> "Moneda":
return cls("USD", "$", 2)
@classmethod
def EUR(cls) -> "Moneda":
return cls("EUR", "€", 2)
@classmethod
def MXN(cls) -> "Moneda":
return cls("MXN", "$", 2)
@dataclass(frozen=True)
class Dinero:
"""
Value Object que representa una cantidad monetaria.
Inmutable y con operaciones aritméticas seguras.
"""
cantidad: Decimal
moneda: Moneda
def __post_init__(self):
# Redondear a los decimales de la moneda
redondeado = self.cantidad.quantize(
Decimal(10) ** -self.moneda.decimales,
rounding=ROUND_HALF_UP
)
# Workaround para frozen dataclass
object.__setattr__(self, 'cantidad', redondeado)
if self.cantidad < 0:
raise ValueError("El dinero no puede ser negativo")
@classmethod
def cero(cls, moneda: Moneda) -> "Dinero":
return cls(Decimal("0"), moneda)
@classmethod
def desde_centavos(cls, centavos: int, moneda: Moneda) -> "Dinero":
cantidad = Decimal(centavos) / Decimal(10 ** moneda.decimales)
return cls(cantidad, moneda)
def __add__(self, otro: "Dinero") -> "Dinero":
self._validar_misma_moneda(otro)
return Dinero(self.cantidad + otro.cantidad, self.moneda)
def __sub__(self, otro: "Dinero") -> "Dinero":
self._validar_misma_moneda(otro)
resultado = self.cantidad - otro.cantidad
if resultado < 0:
raise ValueError("Resultado negativo no permitido")
return Dinero(resultado, self.moneda)
def __mul__(self, factor: int | float | Decimal) -> "Dinero":
return Dinero(self.cantidad * Decimal(str(factor)), self.moneda)
def __truediv__(self, divisor: int | float | Decimal) -> "Dinero":
if divisor == 0:
raise ValueError("División por cero")
return Dinero(self.cantidad / Decimal(str(divisor)), self.moneda)
def __lt__(self, otro: "Dinero") -> bool:
self._validar_misma_moneda(otro)
return self.cantidad < otro.cantidad
def __le__(self, otro: "Dinero") -> bool:
self._validar_misma_moneda(otro)
return self.cantidad <= otro.cantidad
def __gt__(self, otro: "Dinero") -> bool:
self._validar_misma_moneda(otro)
return self.cantidad > otro.cantidad
def es_cero(self) -> bool:
return self.cantidad == 0
def porcentaje(self, porcentaje: Decimal) -> "Dinero":
"""Calcula un porcentaje de este monto"""
return self * (porcentaje / Decimal("100"))
def _validar_misma_moneda(self, otro: "Dinero") -> None:
if self.moneda != otro.moneda:
raise ValueError(
f"No se pueden operar {self.moneda.codigo} con {otro.moneda.codigo}"
)
def __str__(self) -> str:
return f"{self.moneda.simbolo}{self.cantidad:,.2f}"
def __repr__(self) -> str:
return f"Dinero({self.cantidad}, {self.moneda.codigo})"
Value Object: Email
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Email:
"""Value Object para direcciones de email validadas"""
valor: str
_PATRON = re.compile(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
)
def __post_init__(self):
valor_normalizado = self.valor.lower().strip()
object.__setattr__(self, 'valor', valor_normalizado)
if not self._PATRON.match(self.valor):
raise ValueError(f"Email inválido: {self.valor}")
if len(self.valor) > 254:
raise ValueError("Email demasiado largo")
@property
def dominio(self) -> str:
return self.valor.split("@")[1]
@property
def usuario(self) -> str:
return self.valor.split("@")[0]
def es_corporativo(self, dominios_corporativos: list[str]) -> bool:
return self.dominio in dominios_corporativos
def __str__(self) -> str:
return self.valor
Value Object: Dirección
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class CodigoPostal:
valor: str
pais: str
def __post_init__(self):
valor_limpio = self.valor.replace(" ", "").upper()
object.__setattr__(self, 'valor', valor_limpio)
if not self._es_valido():
raise ValueError(f"Código postal inválido para {self.pais}: {self.valor}")
def _es_valido(self) -> bool:
patrones = {
"MX": r"^\d{5}$",
"US": r"^\d{5}(-\d{4})?$",
"ES": r"^\d{5}$",
"UK": r"^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$",
}
import re
patron = patrones.get(self.pais, r"^.+$")
return bool(re.match(patron, self.valor))
@dataclass(frozen=True)
class Direccion:
"""Value Object que representa una dirección postal completa"""
calle: str
numero_exterior: str
numero_interior: Optional[str]
colonia: str
ciudad: str
estado: str
codigo_postal: CodigoPostal
pais: str
def __post_init__(self):
# Validar campos requeridos
if not self.calle or len(self.calle) < 3:
raise ValueError("Calle debe tener al menos 3 caracteres")
if not self.ciudad:
raise ValueError("Ciudad es requerida")
if not self.estado:
raise ValueError("Estado es requerido")
@classmethod
def crear_mexicana(
cls,
calle: str,
numero_exterior: str,
colonia: str,
ciudad: str,
estado: str,
cp: str,
numero_interior: str = None,
) -> "Direccion":
return cls(
calle=calle,
numero_exterior=numero_exterior,
numero_interior=numero_interior,
colonia=colonia,
ciudad=ciudad,
estado=estado,
codigo_postal=CodigoPostal(cp, "MX"),
pais="México",
)
def linea_1(self) -> str:
base = f"{self.calle} {self.numero_exterior}"
if self.numero_interior:
base += f" Int. {self.numero_interior}"
return base
def linea_2(self) -> str:
return f"{self.colonia}, {self.ciudad}"
def linea_3(self) -> str:
return f"{self.estado}, {self.codigo_postal.valor}, {self.pais}"
def formato_completo(self) -> str:
return f"{self.linea_1()}\n{self.linea_2()}\n{self.linea_3()}"
def es_misma_ciudad(self, otra: "Direccion") -> bool:
return (
self.ciudad.lower() == otra.ciudad.lower()
and self.estado.lower() == otra.estado.lower()
and self.pais.lower() == otra.pais.lower()
)
Value Object: Rango de Fechas
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Iterator
@dataclass(frozen=True)
class RangoFechas:
"""Value Object para rangos de fechas con validaciones"""
inicio: date
fin: date
def __post_init__(self):
if self.inicio > self.fin:
raise ValueError(
f"Fecha inicio ({self.inicio}) debe ser anterior a fin ({self.fin})"
)
@classmethod
def desde_hoy(cls, dias: int) -> "RangoFechas":
hoy = date.today()
return cls(hoy, hoy + timedelta(days=dias))
@classmethod
def mes_actual(cls) -> "RangoFechas":
hoy = date.today()
inicio = hoy.replace(day=1)
siguiente_mes = (hoy.replace(day=28) + timedelta(days=4)).replace(day=1)
fin = siguiente_mes - timedelta(days=1)
return cls(inicio, fin)
@property
def dias(self) -> int:
return (self.fin - self.inicio).days
@property
def noches(self) -> int:
"""Para reservas de hotel, noches = días"""
return self.dias
def contiene(self, fecha: date) -> bool:
return self.inicio <= fecha <= self.fin
def se_superpone(self, otro: "RangoFechas") -> bool:
return self.inicio <= otro.fin and otro.inicio <= self.fin
def interseccion(self, otro: "RangoFechas") -> "RangoFechas | None":
if not self.se_superpone(otro):
return None
return RangoFechas(
max(self.inicio, otro.inicio),
min(self.fin, otro.fin)
)
def union(self, otro: "RangoFechas") -> "RangoFechas":
if not self.se_superpone(otro):
raise ValueError("Los rangos no se superponen")
return RangoFechas(
min(self.inicio, otro.inicio),
max(self.fin, otro.fin)
)
def dividir_por_mes(self) -> list["RangoFechas"]:
"""Divide el rango en sub-rangos por mes"""
rangos = []
actual = self.inicio
while actual <= self.fin:
fin_mes = (actual.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
fin_rango = min(fin_mes, self.fin)
rangos.append(RangoFechas(actual, fin_rango))
actual = fin_rango + timedelta(days=1)
return rangos
def iterar_dias(self) -> Iterator[date]:
"""Iterador sobre cada día del rango"""
actual = self.inicio
while actual <= self.fin:
yield actual
actual += timedelta(days=1)
Uso en Entidades
@dataclass
class Cliente:
id: UUID
nombre: str
email: Email # Value Object
direccion_facturacion: Direccion # Value Object
direccion_envio: Direccion # Value Object
saldo: Dinero # Value Object
def cambiar_email(self, nuevo_email: Email) -> None:
"""Reemplaza el VO completo, no lo modifica"""
self.email = nuevo_email
def actualizar_direccion_envio(self, nueva: Direccion) -> None:
self.direccion_envio = nueva
def aplicar_cargo(self, monto: Dinero) -> None:
self.saldo = self.saldo - monto
def aplicar_abono(self, monto: Dinero) -> None:
self.saldo = self.saldo + monto
Beneficios de Value Objects
| Beneficio | Descripción |
|---|---|
| Expresividad | Email vs str comunica intención |
| Validación centralizada | Una vez, en el constructor |
| Inmutabilidad | Sin efectos secundarios sorpresivos |
| Testeabilidad | Fáciles de crear y comparar |
| Reutilización | Mismo VO en múltiples entidades |
Checklist de Value Objects
- ¿Es inmutable (frozen=True)?
- ¿Valida invariantes en
__post_init__? - ¿Implementa igualdad por valor?
- ¿Las operaciones retornan nuevas instancias?
- ¿Se usa en lugar de primitivos donde hay reglas?