← Volver al listado de tecnologías

Capítulo 4: Value Objects

Por: Artiko
dddvalue-objectspythontactical-design

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

  1. Inmutabilidad - Una vez creado, no cambia
  2. Igualdad por valor - Dos VOs con mismos atributos son iguales
  3. Sin identidad - No tiene un ID único
  4. Auto-validante - Valida sus invariantes en construcción
  5. 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

BeneficioDescripción
ExpresividadEmail vs str comunica intención
Validación centralizadaUna vez, en el constructor
InmutabilidadSin efectos secundarios sorpresivos
TesteabilidadFáciles de crear y comparar
ReutilizaciónMismo VO en múltiples entidades

Checklist de Value Objects