← Volver al listado de tecnologías

Capítulo 6: Repositorios

Por: Artiko
dddrepositoriospythontactical-design

Capítulo 6: Repositorios

¿Qué es un Repositorio?

Un Repositorio proporciona una abstracción tipo colección para acceder a agregados, ocultando los detalles de persistencia. Actúa como puente entre el dominio y la infraestructura.

graph LR
    subgraph Dominio["DOMINIO"]
        AGG["Agregado\n(Pedido)"]
        REPO["«interface»\nRepositorio\n(Puerto)"]
    end

    subgraph Infra["INFRAESTRUCTURA"]
        IMPL["Repositorio\nSQLAlchemy\n(Adaptador)"]
        DB[("Base de Datos")]
    end

    AGG -->|"usa"| REPO
    IMPL -->|"implementa"| REPO
    IMPL -->|"persiste"| DB

“Un repositorio representa todos los objetos de cierto tipo como un conjunto conceptual. Actúa como una colección, excepto con capacidades de consulta más elaboradas.” — Eric Evans

Interfaz del Repositorio (Puerto)

from abc import ABC, abstractmethod
from typing import Optional
from uuid import UUID

class RepositorioPedidos(ABC):
    """
    Puerto: Define el contrato que debe cumplir cualquier implementación.
    Pertenece al dominio, no conoce detalles de persistencia.
    """

    @abstractmethod
    def guardar(self, pedido: "Pedido") -> None:
        """Persiste un agregado (insert o update)"""
        pass

    @abstractmethod
    def obtener(self, pedido_id: UUID) -> Optional["Pedido"]:
        """Recupera un agregado por su ID"""
        pass

    @abstractmethod
    def obtener_por_cliente(self, cliente_id: UUID) -> list["Pedido"]:
        """Recupera todos los pedidos de un cliente"""
        pass

    @abstractmethod
    def eliminar(self, pedido_id: UUID) -> None:
        """Elimina un agregado"""
        pass

    @abstractmethod
    def siguiente_id(self) -> UUID:
        """Genera un nuevo ID para un agregado"""
        pass

class RepositorioClientes(ABC):
    @abstractmethod
    def guardar(self, cliente: "Cliente") -> None:
        pass

    @abstractmethod
    def obtener(self, cliente_id: UUID) -> Optional["Cliente"]:
        pass

    @abstractmethod
    def buscar_por_email(self, email: "Email") -> Optional["Cliente"]:
        pass

    @abstractmethod
    def existe_email(self, email: "Email") -> bool:
        pass

Implementación en Memoria (Testing)

from uuid import uuid4

class RepositorioPedidosMemoria(RepositorioPedidos):
    """
    Implementación en memoria para testing.
    Simula comportamiento de base de datos.
    """

    def __init__(self):
        self._pedidos: dict[UUID, Pedido] = {}

    def guardar(self, pedido: Pedido) -> None:
        # Simular copia profunda como haría una BD
        self._pedidos[pedido.id] = pedido

    def obtener(self, pedido_id: UUID) -> Optional[Pedido]:
        return self._pedidos.get(pedido_id)

    def obtener_por_cliente(self, cliente_id: UUID) -> list[Pedido]:
        return [
            p for p in self._pedidos.values()
            if p.cliente_id == cliente_id
        ]

    def eliminar(self, pedido_id: UUID) -> None:
        self._pedidos.pop(pedido_id, None)

    def siguiente_id(self) -> UUID:
        return uuid4()

    # Métodos auxiliares para testing
    def limpiar(self) -> None:
        self._pedidos.clear()

    def contar(self) -> int:
        return len(self._pedidos)

    def todos(self) -> list[Pedido]:
        return list(self._pedidos.values())

Implementación con SQLAlchemy

from sqlalchemy import Column, String, Integer, ForeignKey, Numeric, Enum
from sqlalchemy.orm import Session, relationship, declarative_base
from uuid import UUID, uuid4
from decimal import Decimal

Base = declarative_base()

# === Modelos de Persistencia (Infraestructura) ===

class PedidoModelo(Base):
    __tablename__ = "pedidos"

    id = Column(String(36), primary_key=True)
    cliente_id = Column(String(36), nullable=False, index=True)
    estado = Column(String(20), nullable=False)
    direccion_calle = Column(String(200))
    direccion_ciudad = Column(String(100))
    direccion_cp = Column(String(10))
    lineas = relationship("LineaModelo", back_populates="pedido",
                         cascade="all, delete-orphan")

class LineaModelo(Base):
    __tablename__ = "lineas_pedido"

    id = Column(Integer, primary_key=True)
    pedido_id = Column(String(36), ForeignKey("pedidos.id"))
    producto_id = Column(String(36), nullable=False)
    nombre_producto = Column(String(200))
    cantidad = Column(Integer, nullable=False)
    precio_unitario = Column(Numeric(10, 2), nullable=False)
    pedido = relationship("PedidoModelo", back_populates="lineas")

# === Repositorio SQLAlchemy ===

class RepositorioPedidosSQLAlchemy(RepositorioPedidos):
    def __init__(self, session: Session):
        self._session = session

    def guardar(self, pedido: Pedido) -> None:
        modelo = self._a_modelo(pedido)
        self._session.merge(modelo)
        self._session.flush()

    def obtener(self, pedido_id: UUID) -> Optional[Pedido]:
        modelo = self._session.get(PedidoModelo, str(pedido_id))
        if modelo:
            return self._a_entidad(modelo)
        return None

    def obtener_por_cliente(self, cliente_id: UUID) -> list[Pedido]:
        modelos = (
            self._session.query(PedidoModelo)
            .filter(PedidoModelo.cliente_id == str(cliente_id))
            .all()
        )
        return [self._a_entidad(m) for m in modelos]

    def eliminar(self, pedido_id: UUID) -> None:
        modelo = self._session.get(PedidoModelo, str(pedido_id))
        if modelo:
            self._session.delete(modelo)

    def siguiente_id(self) -> UUID:
        return uuid4()

    # === Mapeo Modelo <-> Entidad ===

    def _a_modelo(self, pedido: Pedido) -> PedidoModelo:
        direccion = pedido.direccion_envio
        return PedidoModelo(
            id=str(pedido.id),
            cliente_id=str(pedido.cliente_id),
            estado=pedido.estado.value,
            direccion_calle=direccion.calle if direccion else None,
            direccion_ciudad=direccion.ciudad if direccion else None,
            direccion_cp=direccion.codigo_postal if direccion else None,
            lineas=[
                LineaModelo(
                    id=l.id,
                    producto_id=str(l.producto_id),
                    nombre_producto=l.nombre_producto,
                    cantidad=l.cantidad,
                    precio_unitario=l.precio_unitario,
                )
                for l in pedido.obtener_lineas()
            ],
        )

    def _a_entidad(self, modelo: PedidoModelo) -> Pedido:
        pedido = Pedido.__new__(Pedido)
        pedido.id = UUID(modelo.id)
        pedido.cliente_id = UUID(modelo.cliente_id)
        pedido._estado = EstadoPedido(modelo.estado)
        pedido._siguiente_linea_id = 1
        pedido._eventos = []

        # Reconstruir dirección
        if modelo.direccion_calle:
            pedido.direccion_envio = DireccionEnvio(
                calle=modelo.direccion_calle,
                ciudad=modelo.direccion_ciudad,
                codigo_postal=modelo.direccion_cp,
                pais="México"
            )
        else:
            pedido.direccion_envio = None

        # Reconstruir líneas
        pedido._lineas = []
        for lm in modelo.lineas:
            linea = LineaPedido(
                id=lm.id,
                producto_id=UUID(lm.producto_id),
                nombre_producto=lm.nombre_producto,
                cantidad=lm.cantidad,
                precio_unitario=Decimal(str(lm.precio_unitario)),
            )
            pedido._lineas.append(linea)
            pedido._siguiente_linea_id = max(
                pedido._siguiente_linea_id, lm.id + 1
            )

        return pedido

Patrón Unit of Work

El Unit of Work coordina la escritura de cambios y maneja transacciones.

from abc import ABC, abstractmethod
from contextlib import contextmanager

class UnitOfWork(ABC):
    """Coordina la persistencia de múltiples repositorios"""
    pedidos: RepositorioPedidos
    clientes: RepositorioClientes
    productos: RepositorioProductos

    @abstractmethod
    def __enter__(self) -> "UnitOfWork":
        pass

    @abstractmethod
    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        pass

    @abstractmethod
    def commit(self) -> None:
        pass

    @abstractmethod
    def rollback(self) -> None:
        pass

    def recolectar_eventos(self) -> list:
        """Recolecta eventos de dominio de todos los agregados"""
        eventos = []
        # Los agregados guardan eventos pendientes
        return eventos

class UnitOfWorkSQLAlchemy(UnitOfWork):
    def __init__(self, session_factory):
        self._session_factory = session_factory
        self._session = None

    def __enter__(self) -> "UnitOfWork":
        self._session = self._session_factory()
        self.pedidos = RepositorioPedidosSQLAlchemy(self._session)
        self.clientes = RepositorioClientesSQLAlchemy(self._session)
        self.productos = RepositorioProductosSQLAlchemy(self._session)
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        if exc_type:
            self.rollback()
        self._session.close()

    def commit(self) -> None:
        self._session.commit()

    def rollback(self) -> None:
        self._session.rollback()

class UnitOfWorkMemoria(UnitOfWork):
    """Para testing - no requiere base de datos"""

    def __init__(self):
        self.pedidos = RepositorioPedidosMemoria()
        self.clientes = RepositorioClientesMemoria()
        self.productos = RepositorioProductosMemoria()
        self._committed = False

    def __enter__(self) -> "UnitOfWork":
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        if exc_type:
            self.rollback()

    def commit(self) -> None:
        self._committed = True

    def rollback(self) -> None:
        self._committed = False

Especificaciones (Consultas Complejas)

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Generic, TypeVar

T = TypeVar("T")

class Especificacion(ABC, Generic[T]):
    """Patrón Specification para consultas complejas"""

    @abstractmethod
    def es_satisfecha_por(self, candidato: T) -> bool:
        pass

    def y(self, otra: "Especificacion[T]") -> "Especificacion[T]":
        return EspecificacionY(self, otra)

    def o(self, otra: "Especificacion[T]") -> "Especificacion[T]":
        return EspecificacionO(self, otra)

    def no(self) -> "Especificacion[T]":
        return EspecificacionNo(self)

@dataclass
class EspecificacionY(Especificacion[T]):
    izq: Especificacion[T]
    der: Especificacion[T]

    def es_satisfecha_por(self, candidato: T) -> bool:
        return (
            self.izq.es_satisfecha_por(candidato)
            and self.der.es_satisfecha_por(candidato)
        )

# === Especificaciones de Pedido ===

@dataclass
class PedidoPorEstado(Especificacion["Pedido"]):
    estado: "EstadoPedido"

    def es_satisfecha_por(self, pedido: "Pedido") -> bool:
        return pedido.estado == self.estado

@dataclass
class PedidoPorCliente(Especificacion["Pedido"]):
    cliente_id: UUID

    def es_satisfecha_por(self, pedido: "Pedido") -> bool:
        return pedido.cliente_id == self.cliente_id

@dataclass
class PedidoConMontoMinimo(Especificacion["Pedido"]):
    monto_minimo: Decimal

    def es_satisfecha_por(self, pedido: "Pedido") -> bool:
        return pedido.total >= self.monto_minimo

@dataclass
class PedidoEnRangoFechas(Especificacion["Pedido"]):
    desde: date
    hasta: date

    def es_satisfecha_por(self, pedido: "Pedido") -> bool:
        fecha = pedido.fecha_creacion.date()
        return self.desde <= fecha <= self.hasta

# === Uso de Especificaciones ===

class RepositorioPedidosConEspecificacion(RepositorioPedidos):
    @abstractmethod
    def buscar(self, especificacion: Especificacion["Pedido"]) -> list["Pedido"]:
        pass

# Ejemplo de uso
def obtener_pedidos_pendientes_grandes(repo, cliente_id: UUID):
    spec = (
        PedidoPorCliente(cliente_id)
        .y(PedidoPorEstado(EstadoPedido.PENDIENTE))
        .y(PedidoConMontoMinimo(Decimal("1000")))
    )
    return repo.buscar(spec)

Uso en Casos de Uso

from dataclasses import dataclass
from uuid import UUID

@dataclass
class ConfirmarPedidoCommand:
    pedido_id: UUID

class ConfirmarPedidoHandler:
    def __init__(self, uow: UnitOfWork):
        self._uow = uow

    def ejecutar(self, comando: ConfirmarPedidoCommand) -> None:
        with self._uow:
            pedido = self._uow.pedidos.obtener(comando.pedido_id)

            if not pedido:
                raise PedidoNoEncontrado(comando.pedido_id)

            pedido.confirmar()

            self._uow.pedidos.guardar(pedido)
            self._uow.commit()

            # Publicar eventos después del commit
            for evento in pedido.obtener_eventos():
                self._publicar(evento)

@dataclass
class CrearPedidoCommand:
    cliente_id: UUID
    productos: list[dict]  # [{producto_id, cantidad}]

class CrearPedidoHandler:
    def __init__(self, uow: UnitOfWork):
        self._uow = uow

    def ejecutar(self, comando: CrearPedidoCommand) -> UUID:
        with self._uow:
            # Validar que el cliente existe
            cliente = self._uow.clientes.obtener(comando.cliente_id)
            if not cliente:
                raise ClienteNoEncontrado(comando.cliente_id)

            # Crear pedido con ID generado por repositorio
            pedido_id = self._uow.pedidos.siguiente_id()
            pedido = Pedido(id=pedido_id, cliente_id=comando.cliente_id)

            # Agregar productos
            for item in comando.productos:
                producto = self._uow.productos.obtener(item["producto_id"])
                if not producto:
                    raise ProductoNoEncontrado(item["producto_id"])

                pedido.agregar_producto(
                    producto_id=producto.id,
                    nombre=producto.nombre,
                    cantidad=item["cantidad"],
                    precio=producto.precio,
                )

            self._uow.pedidos.guardar(pedido)
            self._uow.commit()

            return pedido_id

Reglas de Repositorios

┌─────────────────────────────────────────────────────────────┐
│                  REGLAS DE REPOSITORIOS                      │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. UN REPOSITORIO POR AGGREGATE ROOT                       │
│     ✓ RepositorioPedidos                                    │
│     ✗ RepositorioLineasPedido (es entidad interna)          │
│                                                              │
│  2. INTERFAZ EN DOMINIO, IMPLEMENTACIÓN EN INFRAESTRUCTURA  │
│     dominio/repositorios.py  → interfaces abstractas        │
│     infra/sql_repositorios.py → implementaciones            │
│                                                              │
│  3. SIMULA UNA COLECCIÓN EN MEMORIA                         │
│     guardar(agregado)  → add/update                         │
│     obtener(id)        → get by id                          │
│     eliminar(id)       → remove                             │
│                                                              │
│  4. RECONSTRUYE AGREGADOS COMPLETOS                         │
│     ✓ Retorna Pedido con todas sus LineaPedido              │
│     ✗ Retorna solo datos parciales                          │
│                                                              │
│  5. NO EXPONE DETALLES DE PERSISTENCIA                      │
│     ✓ obtener(id: UUID)                                     │
│     ✗ obtener(query: "SELECT * FROM...")                    │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Antipatrones a Evitar

1. Repositorio Genérico

# MAL: Demasiado genérico, pierde significado de dominio
class RepositorioGenerico(ABC):
    def guardar(self, entidad: Any) -> None: pass
    def obtener(self, id: Any) -> Any: pass
    def buscar(self, criterio: dict) -> list: pass

# BIEN: Específico para cada agregado
class RepositorioPedidos(ABC):
    def guardar(self, pedido: Pedido) -> None: pass
    def obtener(self, pedido_id: UUID) -> Optional[Pedido]: pass
    def obtener_pendientes_de_envio(self) -> list[Pedido]: pass

2. Repositorio con Lógica de Negocio

# MAL: El repositorio tiene lógica de negocio
class RepositorioPedidosMal(RepositorioPedidos):
    def confirmar_pedido(self, pedido_id: UUID) -> None:
        pedido = self.obtener(pedido_id)
        pedido._estado = EstadoPedido.CONFIRMADO  # Lógica de negocio
        self.guardar(pedido)

# BIEN: Repositorio solo persiste
class RepositorioPedidosBien(RepositorioPedidos):
    def guardar(self, pedido: Pedido) -> None:
        # Solo persistencia, sin lógica
        modelo = self._a_modelo(pedido)
        self._session.merge(modelo)

3. Exponer Modelos de Persistencia

# MAL: Retorna modelo de SQLAlchemy
class RepositorioMal(RepositorioPedidos):
    def obtener(self, id: UUID) -> PedidoModelo:  # Modelo de BD
        return self._session.get(PedidoModelo, str(id))

# BIEN: Retorna entidad de dominio
class RepositorioBien(RepositorioPedidos):
    def obtener(self, id: UUID) -> Optional[Pedido]:  # Entidad
        modelo = self._session.get(PedidoModelo, str(id))
        return self._a_entidad(modelo) if modelo else None

Checklist de Repositorios