← Volver al listado de tecnologías

Capítulo 9: Factories

Por: Artiko
dddfactoriespythontactical-design

Capítulo 9: Factories

¿Qué es una Factory?

Una Factory encapsula la lógica de creación de objetos complejos del dominio. Oculta la complejidad de construcción y garantiza que los objetos se creen en un estado válido.

┌─────────────────────────────────────────────────────────────┐
│                    CUÁNDO USAR FACTORY                       │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ¿La creación es simple?                                    │
│           │                                                  │
│     ┌─────┴─────┐                                           │
│     ▼           ▼                                           │
│    SÍ          NO                                           │
│     │           │                                           │
│     ▼           ▼                                           │
│  Constructor  ¿Requiere dependencias externas?              │
│  normal              │                                      │
│              ┌───────┴───────┐                              │
│              ▼               ▼                              │
│             NO              SÍ                              │
│              │               │                              │
│              ▼               ▼                              │
│         Factory         Factory                             │
│         Method          Class                               │
│         (en clase)      (separada)                          │
│                                                              │
└─────────────────────────────────────────────────────────────┘

“Cuando la creación de un objeto es una operación complicada, use una Factory para encapsular el conocimiento necesario.” — Eric Evans

Tipos de Factories

TipoDescripciónUso
Factory MethodMétodo estático en la entidadCreación simple
Factory ClassClase separadaRequiere dependencias
Abstract FactoryInterfaz con variantesMúltiples familias
ReconstructorDesde persistenciaMapeo BD → Dominio

Factory Method en la Entidad

from dataclasses import dataclass, field
from datetime import datetime, date
from uuid import UUID, uuid4
from decimal import Decimal
from enum import Enum

class TipoCliente(Enum):
    REGULAR = "regular"
    PREMIUM = "premium"
    CORPORATIVO = "corporativo"

@dataclass
class Cliente:
    id: UUID
    email: "Email"
    nombre: str
    tipo: TipoCliente
    fecha_registro: datetime
    limite_credito: Decimal
    _pedidos_activos: list["Pedido"] = field(default_factory=list)

    @classmethod
    def registrar_nuevo(
        cls,
        email: "Email",
        nombre: str,
    ) -> "Cliente":
        """Factory method para nuevos clientes"""
        return cls(
            id=uuid4(),
            email=email,
            nombre=nombre,
            tipo=TipoCliente.REGULAR,
            fecha_registro=datetime.now(),
            limite_credito=Decimal("1000"),
        )

    @classmethod
    def registrar_corporativo(
        cls,
        email: "Email",
        nombre: str,
        limite_credito: Decimal,
    ) -> "Cliente":
        """Factory method para clientes corporativos"""
        if limite_credito < Decimal("10000"):
            raise ValueError("Corporativos requieren límite mínimo de 10000")

        return cls(
            id=uuid4(),
            email=email,
            nombre=nombre,
            tipo=TipoCliente.CORPORATIVO,
            fecha_registro=datetime.now(),
            limite_credito=limite_credito,
        )

@dataclass
class Pedido:
    id: UUID
    cliente_id: UUID
    fecha_creacion: datetime
    _lineas: list["LineaPedido"]
    _estado: "EstadoPedido"

    @classmethod
    def crear_nuevo(cls, cliente_id: UUID) -> "Pedido":
        """Crea un pedido vacío en estado borrador"""
        return cls(
            id=uuid4(),
            cliente_id=cliente_id,
            fecha_creacion=datetime.now(),
            _lineas=[],
            _estado=EstadoPedido.BORRADOR,
        )

    @classmethod
    def desde_carrito(
        cls,
        cliente_id: UUID,
        carrito: "Carrito"
    ) -> "Pedido":
        """Crea un pedido desde un carrito de compras"""
        if carrito.esta_vacio():
            raise CarritoVacioError()

        pedido = cls.crear_nuevo(cliente_id)
        for item in carrito.items:
            pedido.agregar_producto(
                producto_id=item.producto_id,
                nombre=item.nombre,
                cantidad=item.cantidad,
                precio=item.precio,
            )
        return pedido

    @classmethod
    def crear_recurrente(
        cls,
        pedido_anterior: "Pedido"
    ) -> "Pedido":
        """Crea un nuevo pedido basado en uno anterior"""
        pedido = cls.crear_nuevo(pedido_anterior.cliente_id)
        for linea in pedido_anterior.obtener_lineas():
            pedido.agregar_producto(
                producto_id=linea.producto_id,
                nombre=linea.nombre_producto,
                cantidad=linea.cantidad,
                precio=linea.precio_unitario,
            )
        return pedido

Factory Class Separada

from dataclasses import dataclass

@dataclass
class FactoryPedidos:
    """
    Factory class cuando se requieren dependencias externas.
    Útil para validaciones que requieren acceso a repositorios.
    """
    repo_productos: "RepositorioProductos"
    repo_clientes: "RepositorioClientes"
    servicio_precios: "ServicioPrecios"

    def crear_desde_carrito(
        self,
        cliente_id: UUID,
        carrito: "Carrito"
    ) -> "Pedido":
        # Validar cliente
        cliente = self.repo_clientes.obtener(cliente_id)
        if not cliente:
            raise ClienteNoEncontrado(cliente_id)
        if not cliente.puede_realizar_pedidos():
            raise ClienteBloqueado(cliente_id)

        # Crear pedido
        pedido = Pedido.crear_nuevo(cliente_id)

        # Agregar productos con precios actualizados
        for item in carrito.items:
            producto = self.repo_productos.obtener(item.producto_id)
            if not producto:
                raise ProductoNoEncontrado(item.producto_id)
            if not producto.esta_disponible():
                raise ProductoNoDisponible(producto.id)

            # Calcular precio con descuentos del cliente
            precio = self.servicio_precios.calcular_precio(
                producto, cliente, item.cantidad
            )

            pedido.agregar_producto(
                producto_id=producto.id,
                nombre=producto.nombre,
                cantidad=item.cantidad,
                precio=precio.total,
            )

        return pedido

    def crear_pedido_express(
        self,
        cliente_id: UUID,
        producto_id: UUID,
        cantidad: int = 1
    ) -> "Pedido":
        """Atajo para pedidos de un solo producto"""
        cliente = self.repo_clientes.obtener(cliente_id)
        producto = self.repo_productos.obtener(producto_id)

        pedido = Pedido.crear_nuevo(cliente_id)
        precio = self.servicio_precios.calcular_precio(
            producto, cliente, cantidad
        )
        pedido.agregar_producto(
            producto_id=producto.id,
            nombre=producto.nombre,
            cantidad=cantidad,
            precio=precio.total,
        )
        return pedido

Factory para Value Objects

from dataclasses import dataclass
import re

@dataclass(frozen=True)
class Direccion:
    calle: str
    numero: str
    ciudad: str
    codigo_postal: str
    pais: str

class FactoryDirecciones:
    """Factory para crear direcciones desde diferentes formatos"""

    @staticmethod
    def desde_texto(texto: str) -> Direccion:
        """
        Parsea: 'Av. Reforma 123, Ciudad de México, 06600, México'
        """
        partes = [p.strip() for p in texto.split(",")]
        if len(partes) != 4:
            raise FormatoDireccionInvalido(
                "Formato esperado: calle numero, ciudad, cp, pais"
            )

        calle_numero = partes[0].rsplit(" ", 1)
        calle = calle_numero[0] if len(calle_numero) > 1 else partes[0]
        numero = calle_numero[1] if len(calle_numero) > 1 else "S/N"

        return Direccion(
            calle=calle,
            numero=numero,
            ciudad=partes[1],
            codigo_postal=partes[2],
            pais=partes[3],
        )

    @staticmethod
    def desde_dict(datos: dict) -> Direccion:
        """Desde un diccionario (ej: JSON de API)"""
        return Direccion(
            calle=datos["calle"],
            numero=datos.get("numero", "S/N"),
            ciudad=datos["ciudad"],
            codigo_postal=datos["codigo_postal"],
            pais=datos.get("pais", "México"),
        )

    @staticmethod
    def desde_google_maps(place: dict) -> Direccion:
        """Desde respuesta de Google Places API"""
        componentes = {
            c["types"][0]: c["long_name"]
            for c in place["address_components"]
        }
        return Direccion(
            calle=componentes.get("route", ""),
            numero=componentes.get("street_number", "S/N"),
            ciudad=componentes.get("locality", ""),
            codigo_postal=componentes.get("postal_code", ""),
            pais=componentes.get("country", ""),
        )

class FactoryDinero:
    """Factory para el Value Object Dinero"""

    @staticmethod
    def desde_centavos(centavos: int, moneda: str = "MXN") -> "Dinero":
        return Dinero(
            cantidad=Decimal(centavos) / Decimal("100"),
            moneda=moneda,
        )

    @staticmethod
    def desde_string(texto: str) -> "Dinero":
        """Parsea: '$1,234.56 MXN' o '1234.56'"""
        texto = texto.replace(",", "").replace("$", "").strip()
        partes = texto.split()
        cantidad = Decimal(partes[0])
        moneda = partes[1] if len(partes) > 1 else "MXN"
        return Dinero(cantidad=cantidad, moneda=moneda)

    @staticmethod
    def cero(moneda: str = "MXN") -> "Dinero":
        return Dinero(cantidad=Decimal("0"), moneda=moneda)

Factory para Reconstrucción (ORM)

class ReconstructorPedido:
    """
    Reconstruye agregados desde datos de persistencia.
    Usado por el repositorio para mapear modelos a entidades.
    """

    def reconstruir(self, datos: dict) -> Pedido:
        """Reconstruye un Pedido desde un dict (resultado de BD)"""
        # Usar __new__ para evitar validaciones del constructor
        pedido = Pedido.__new__(Pedido)

        # Asignar campos básicos
        pedido.id = UUID(datos["id"])
        pedido.cliente_id = UUID(datos["cliente_id"])
        pedido.fecha_creacion = datos["fecha_creacion"]
        pedido._estado = EstadoPedido(datos["estado"])

        # Reconstruir dirección si existe
        if datos.get("direccion_calle"):
            pedido.direccion_envio = Direccion(
                calle=datos["direccion_calle"],
                numero=datos["direccion_numero"],
                ciudad=datos["direccion_ciudad"],
                codigo_postal=datos["direccion_cp"],
                pais=datos["direccion_pais"],
            )
        else:
            pedido.direccion_envio = None

        # Reconstruir líneas
        pedido._lineas = [
            self._reconstruir_linea(l) for l in datos["lineas"]
        ]

        # Inicializar campos transientes
        pedido._siguiente_linea_id = max(
            (l.id for l in pedido._lineas), default=0
        ) + 1
        pedido._eventos = []

        return pedido

    def _reconstruir_linea(self, datos: dict) -> LineaPedido:
        return LineaPedido(
            id=datos["id"],
            producto_id=UUID(datos["producto_id"]),
            nombre_producto=datos["nombre_producto"],
            cantidad=datos["cantidad"],
            precio_unitario=Decimal(str(datos["precio_unitario"])),
        )

class ReconstructorCliente:
    def reconstruir(self, datos: dict) -> Cliente:
        cliente = Cliente.__new__(Cliente)
        cliente.id = UUID(datos["id"])
        cliente.email = Email(datos["email"])
        cliente.nombre = datos["nombre"]
        cliente.tipo = TipoCliente(datos["tipo"])
        cliente.fecha_registro = datos["fecha_registro"]
        cliente.limite_credito = Decimal(str(datos["limite_credito"]))
        cliente._pedidos_activos = []
        return cliente

Abstract Factory

from abc import ABC, abstractmethod

class FactoryNotificaciones(ABC):
    """Abstract Factory para crear notificaciones"""

    @abstractmethod
    def crear_confirmacion_pedido(
        self, pedido: "Pedido"
    ) -> "Notificacion":
        pass

    @abstractmethod
    def crear_envio_realizado(
        self, pedido: "Pedido", tracking: str
    ) -> "Notificacion":
        pass

    @abstractmethod
    def crear_recordatorio_pago(
        self, pedido: "Pedido"
    ) -> "Notificacion":
        pass

class FactoryNotificacionesEmail(FactoryNotificaciones):
    def __init__(self, plantillas: "RepositorioPlantillas"):
        self._plantillas = plantillas

    def crear_confirmacion_pedido(self, pedido: "Pedido") -> "NotificacionEmail":
        plantilla = self._plantillas.obtener("email_confirmacion_pedido")
        return NotificacionEmail(
            destinatario=pedido.cliente_email,
            asunto=f"Pedido #{pedido.id} confirmado",
            cuerpo=plantilla.renderizar({
                "pedido": pedido,
                "total": pedido.total,
            }),
        )

    def crear_envio_realizado(
        self, pedido: "Pedido", tracking: str
    ) -> "NotificacionEmail":
        return NotificacionEmail(
            destinatario=pedido.cliente_email,
            asunto=f"Tu pedido #{pedido.id} está en camino",
            cuerpo=f"Tracking: {tracking}",
        )

    def crear_recordatorio_pago(self, pedido: "Pedido") -> "NotificacionEmail":
        return NotificacionEmail(
            destinatario=pedido.cliente_email,
            asunto="Recordatorio: Pago pendiente",
            cuerpo=f"Tu pedido por ${pedido.total} está pendiente de pago",
        )

class FactoryNotificacionesSMS(FactoryNotificaciones):
    def crear_confirmacion_pedido(self, pedido: "Pedido") -> "NotificacionSMS":
        return NotificacionSMS(
            telefono=pedido.cliente_telefono,
            mensaje=f"Pedido #{pedido.id} confirmado. Total: ${pedido.total}",
        )

    def crear_envio_realizado(
        self, pedido: "Pedido", tracking: str
    ) -> "NotificacionSMS":
        return NotificacionSMS(
            telefono=pedido.cliente_telefono,
            mensaje=f"Pedido en camino. Track: {tracking}",
        )

    def crear_recordatorio_pago(self, pedido: "Pedido") -> "NotificacionSMS":
        return NotificacionSMS(
            telefono=pedido.cliente_telefono,
            mensaje=f"Pago pendiente: ${pedido.total}",
        )

# Uso
def obtener_factory_notificaciones(preferencia: str) -> FactoryNotificaciones:
    if preferencia == "email":
        return FactoryNotificacionesEmail(repo_plantillas)
    elif preferencia == "sms":
        return FactoryNotificacionesSMS()
    else:
        raise ValueError(f"Tipo no soportado: {preferencia}")

Cuándo Usar Cada Tipo

┌─────────────────────────────────────────────────────────────┐
│              GUÍA DE SELECCIÓN DE FACTORY                   │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  SITUACIÓN                          SOLUCIÓN                │
│  ─────────────────────────────────  ─────────────────────   │
│                                                              │
│  Creación simple, sin lógica        Constructor normal      │
│  Pedido(id=uuid4(), ...)           __init__                 │
│                                                              │
│  Validación en creación             Factory Method          │
│  Pedido.crear_nuevo(cliente_id)    @classmethod             │
│                                                              │
│  Múltiples formas de crear          Factory Methods         │
│  Pedido.desde_carrito(...)         múltiples @classmethod   │
│  Pedido.crear_recurrente(...)                               │
│                                                              │
│  Requiere dependencias              Factory Class           │
│  (repos, servicios)                 clase separada          │
│                                                              │
│  Múltiples variantes                Abstract Factory        │
│  (email, sms, push)                 interfaz + impls        │
│                                                              │
│  Reconstruir desde BD               Reconstructor           │
│  (mapeo ORM)                        en repositorio          │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Antipatrones a Evitar

Factory que hace demasiado

# MAL: Factory que persiste
class FactoryPedidosMal:
    def crear(self, datos):
        pedido = Pedido(...)
        self.repo.guardar(pedido)  # NO! Factory solo crea
        self.email.enviar(...)     # NO! Efectos secundarios
        return pedido

# BIEN: Factory solo crea
class FactoryPedidosBien:
    def crear(self, datos):
        return Pedido(...)  # Solo creación

Checklist de Factories