← Volver al listado de tecnologías
Capítulo 9: Factories
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
| Tipo | Descripción | Uso |
|---|---|---|
| Factory Method | Método estático en la entidad | Creación simple |
| Factory Class | Clase separada | Requiere dependencias |
| Abstract Factory | Interfaz con variantes | Múltiples familias |
| Reconstructor | Desde persistencia | Mapeo 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
- ¿La factory encapsula la complejidad de creación?
- ¿Los objetos creados están en estado válido?
- ¿Se usa el tipo correcto de factory?
- ¿La factory no tiene efectos secundarios?
- ¿Los nombres de métodos son descriptivos?
- ¿Las validaciones ocurren en la factory?