← Volver al listado de tecnologías

Capítulo 3: Entidades

Por: Artiko
dddentidadespythontactical-design

Capítulo 3: Entidades

¿Qué es una Entidad?

Una Entidad es un objeto definido primariamente por su identidad, no por sus atributos. Dos entidades con los mismos atributos pero diferente identidad son objetos distintos.

Características Fundamentales

  1. Identidad única - Un identificador que la distingue de todas las demás
  2. Continuidad - Persiste a través del tiempo y cambios de estado
  3. Mutabilidad controlada - Sus atributos pueden cambiar, su identidad no
  4. Ciclo de vida - Tiene estados y transiciones definidas

“Cuando un objeto se distingue por su identidad, más que por sus atributos, haz de esto lo primario en su definición.” — Eric Evans

Entidad vs Valor

graph LR
    subgraph Entidades["ENTIDAD - Igualdad por ID"]
        CA["Cliente A\nID: 123\nNombre: Juan"]
        CB["Cliente B\nID: 456\nNombre: Juan"]
        CA -.-|"≠"| CB
    end

    subgraph ValueObjects["VALUE OBJECT - Igualdad por valor"]
        DA["Dinero A\n100 USD"]
        DB["Dinero B\n100 USD"]
        DA -.-|"="| DB
    end

Implementación Base de Entidad

from abc import ABC
from dataclasses import dataclass, field
from datetime import datetime
from typing import TypeVar, Generic
from uuid import UUID, uuid4

T = TypeVar("T")

@dataclass
class Entidad(ABC, Generic[T]):
    """Clase base para todas las entidades del dominio"""
    id: T = field(default_factory=uuid4)
    creado_en: datetime = field(default_factory=datetime.now)
    actualizado_en: datetime = field(default_factory=datetime.now)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, self.__class__):
            return False
        return self.id == other.id

    def __hash__(self) -> int:
        return hash(self.id)

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(id={self.id})"

    def _marcar_actualizado(self) -> None:
        self.actualizado_en = datetime.now()

Ejemplo Completo: Entidad Usuario

from dataclasses import dataclass, field
from datetime import datetime, date
from enum import Enum
from typing import Optional
from uuid import UUID, uuid4

class EstadoUsuario(Enum):
    PENDIENTE_VERIFICACION = "pendiente_verificacion"
    ACTIVO = "activo"
    SUSPENDIDO = "suspendido"
    ELIMINADO = "eliminado"

class TipoUsuario(Enum):
    REGULAR = "regular"
    PREMIUM = "premium"
    ADMIN = "admin"

@dataclass
class Usuario:
    """
    Entidad Usuario - identificada por su ID único.
    Encapsula reglas de negocio relacionadas con usuarios.
    """
    id: UUID = field(default_factory=uuid4)
    email: "Email" = None
    nombre: str = ""
    apellido: str = ""
    fecha_nacimiento: date = None
    tipo: TipoUsuario = TipoUsuario.REGULAR
    estado: EstadoUsuario = EstadoUsuario.PENDIENTE_VERIFICACION
    fecha_registro: datetime = field(default_factory=datetime.now)
    ultimo_acceso: Optional[datetime] = None
    intentos_login_fallidos: int = 0
    _historial_cambios: list["CambioUsuario"] = field(default_factory=list)

    # === Métodos de Identidad ===

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Usuario):
            return False
        return self.id == other.id

    def __hash__(self) -> int:
        return hash(self.id)

    # === Reglas de Negocio ===

    def verificar_cuenta(self) -> None:
        """Activa la cuenta después de verificar el email"""
        if self.estado != EstadoUsuario.PENDIENTE_VERIFICACION:
            raise EstadoInvalidoError(
                f"Solo usuarios pendientes pueden verificarse. Estado actual: {self.estado}"
            )
        self._cambiar_estado(EstadoUsuario.ACTIVO, "Verificación de email completada")

    def suspender(self, motivo: str) -> None:
        """Suspende la cuenta del usuario"""
        if self.estado == EstadoUsuario.ELIMINADO:
            raise EstadoInvalidoError("No se puede suspender un usuario eliminado")
        self._cambiar_estado(EstadoUsuario.SUSPENDIDO, motivo)

    def reactivar(self) -> None:
        """Reactiva una cuenta suspendida"""
        if self.estado != EstadoUsuario.SUSPENDIDO:
            raise EstadoInvalidoError("Solo usuarios suspendidos pueden reactivarse")
        self._cambiar_estado(EstadoUsuario.ACTIVO, "Cuenta reactivada")

    def eliminar(self, motivo: str) -> None:
        """Elimina lógicamente la cuenta"""
        self._cambiar_estado(EstadoUsuario.ELIMINADO, motivo)
        # Anonimizar datos personales por GDPR
        self.email = Email("[email protected]")
        self.nombre = "Usuario"
        self.apellido = "Eliminado"

    # === Autenticación ===

    def registrar_login_exitoso(self) -> None:
        """Registra un login exitoso"""
        self.ultimo_acceso = datetime.now()
        self.intentos_login_fallidos = 0

    def registrar_login_fallido(self) -> bool:
        """
        Registra un intento de login fallido.
        Retorna True si la cuenta debe bloquearse.
        """
        self.intentos_login_fallidos += 1
        if self.intentos_login_fallidos >= 5:
            self.suspender("Demasiados intentos de login fallidos")
            return True
        return False

    # === Validaciones ===

    def puede_acceder(self) -> bool:
        """Verifica si el usuario puede acceder al sistema"""
        return self.estado == EstadoUsuario.ACTIVO

    def es_mayor_de_edad(self) -> bool:
        if not self.fecha_nacimiento:
            return False
        hoy = date.today()
        edad = hoy.year - self.fecha_nacimiento.year
        if (hoy.month, hoy.day) < (self.fecha_nacimiento.month, self.fecha_nacimiento.day):
            edad -= 1
        return edad >= 18

    def puede_ser_premium(self) -> bool:
        """Regla de negocio: solo mayores de edad activos pueden ser premium"""
        return self.puede_acceder() and self.es_mayor_de_edad()

    # === Actualización de datos ===

    def actualizar_perfil(self, nombre: str, apellido: str) -> None:
        """Actualiza datos del perfil con validaciones"""
        if not nombre or len(nombre) < 2:
            raise DatosInvalidosError("Nombre debe tener al menos 2 caracteres")
        if not apellido or len(apellido) < 2:
            raise DatosInvalidosError("Apellido debe tener al menos 2 caracteres")

        self.nombre = nombre.strip()
        self.apellido = apellido.strip()
        self._registrar_cambio("perfil_actualizado", f"{nombre} {apellido}")

    def cambiar_email(self, nuevo_email: "Email") -> None:
        """Cambia el email y requiere re-verificación"""
        if self.email == nuevo_email:
            return

        self.email = nuevo_email
        self.estado = EstadoUsuario.PENDIENTE_VERIFICACION
        self._registrar_cambio("email_cambiado", str(nuevo_email))

    def promover_a_premium(self) -> None:
        """Promueve el usuario a premium si cumple requisitos"""
        if not self.puede_ser_premium():
            raise ReglaNegocioError("Usuario no cumple requisitos para premium")
        self.tipo = TipoUsuario.PREMIUM
        self._registrar_cambio("promovido_premium", "")

    # === Métodos privados ===

    def _cambiar_estado(self, nuevo_estado: EstadoUsuario, motivo: str) -> None:
        estado_anterior = self.estado
        self.estado = nuevo_estado
        self._registrar_cambio(
            f"estado_{estado_anterior.value}_a_{nuevo_estado.value}",
            motivo
        )

    def _registrar_cambio(self, tipo: str, detalle: str) -> None:
        self._historial_cambios.append(
            CambioUsuario(tipo=tipo, detalle=detalle)
        )

    # === Propiedades computadas ===

    @property
    def nombre_completo(self) -> str:
        return f"{self.nombre} {self.apellido}"

    @property
    def dias_desde_registro(self) -> int:
        return (datetime.now() - self.fecha_registro).days

@dataclass
class CambioUsuario:
    """Registro de cambios en el historial del usuario"""
    tipo: str
    detalle: str
    fecha: datetime = field(default_factory=datetime.now)

Estrategias de Generación de ID

from abc import ABC, abstractmethod
from uuid import UUID, uuid4
import hashlib

class GeneradorId(ABC):
    @abstractmethod
    def generar(self) -> UUID:
        pass

class GeneradorUUID4(GeneradorId):
    """ID aleatorio - más común"""
    def generar(self) -> UUID:
        return uuid4()

class GeneradorUUIDDeterministico(GeneradorId):
    """ID basado en namespace - útil para idempotencia"""
    def __init__(self, namespace: UUID):
        self.namespace = namespace

    def generar_desde(self, valor: str) -> UUID:
        return UUID(hashlib.sha256(
            f"{self.namespace}{valor}".encode()
        ).hexdigest()[:32])

class GeneradorSecuencial(GeneradorId):
    """Para testing - IDs predecibles"""
    def __init__(self):
        self._contador = 0

    def generar(self) -> UUID:
        self._contador += 1
        return UUID(int=self._contador)

    def reset(self) -> None:
        self._contador = 0

Ciclo de Vida de Entidades

┌──────────────────────────────────────────────────────────────┐
│                  CICLO DE VIDA: USUARIO                      │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│   ┌────────────┐    verificar()    ┌────────────┐           │
│   │ PENDIENTE  │──────────────────▶│   ACTIVO   │           │
│   │VERIFICACIÓN│                   │            │           │
│   └────────────┘                   └─────┬──────┘           │
│         │                                │                  │
│         │ eliminar()                     │ suspender()      │
│         │                                ▼                  │
│         │                          ┌────────────┐           │
│         │                          │ SUSPENDIDO │           │
│         │                          └─────┬──────┘           │
│         │                                │                  │
│         │          reactivar()           │                  │
│         │          ◄─────────────────────┤                  │
│         │                                │ eliminar()       │
│         ▼                                ▼                  │
│   ┌─────────────────────────────────────────────┐           │
│   │                 ELIMINADO                    │           │
│   │            (estado final)                    │           │
│   └─────────────────────────────────────────────┘           │
└──────────────────────────────────────────────────────────────┘

Entidad con Estado (State Pattern)

from abc import ABC, abstractmethod
from dataclasses import dataclass

class EstadoPedidoBase(ABC):
    @abstractmethod
    def confirmar(self, pedido: "Pedido") -> None:
        pass

    @abstractmethod
    def cancelar(self, pedido: "Pedido") -> None:
        pass

    @abstractmethod
    def enviar(self, pedido: "Pedido") -> None:
        pass

class EstadoBorrador(EstadoPedidoBase):
    def confirmar(self, pedido: "Pedido") -> None:
        if not pedido.tiene_lineas():
            raise PedidoSinLineasError()
        pedido._estado = EstadoConfirmado()

    def cancelar(self, pedido: "Pedido") -> None:
        pedido._estado = EstadoCancelado()

    def enviar(self, pedido: "Pedido") -> None:
        raise OperacionInvalidaError("No se puede enviar un borrador")

class EstadoConfirmado(EstadoPedidoBase):
    def confirmar(self, pedido: "Pedido") -> None:
        raise OperacionInvalidaError("Ya está confirmado")

    def cancelar(self, pedido: "Pedido") -> None:
        pedido._estado = EstadoCancelado()

    def enviar(self, pedido: "Pedido") -> None:
        pedido._estado = EstadoEnviado()

@dataclass
class Pedido:
    id: UUID = field(default_factory=uuid4)
    _estado: EstadoPedidoBase = field(default_factory=EstadoBorrador)
    _lineas: list = field(default_factory=list)

    def confirmar(self) -> None:
        self._estado.confirmar(self)

    def cancelar(self) -> None:
        self._estado.cancelar(self)

    def enviar(self) -> None:
        self._estado.enviar(self)

    def tiene_lineas(self) -> bool:
        return len(self._lineas) > 0

Antipatrones a Evitar

1. Entidad Anémica

# MAL: Solo datos, sin comportamiento
@dataclass
class UsuarioAnemico:
    id: UUID
    nombre: str
    email: str
    estado: str
    # Sin métodos de negocio

# El comportamiento está en otro lugar
class UsuarioService:
    def activar(self, usuario: UsuarioAnemico):
        usuario.estado = "activo"  # Violación de encapsulación

# BIEN: Comportamiento en la entidad
@dataclass
class Usuario:
    id: UUID
    estado: EstadoUsuario

    def activar(self) -> None:
        if self.estado != EstadoUsuario.PENDIENTE:
            raise EstadoInvalidoError()
        self.estado = EstadoUsuario.ACTIVO

2. ID Mutable

# MAL: ID que puede cambiar
@dataclass
class Producto:
    id: UUID
    sku: str

    def cambiar_id(self, nuevo_id: UUID):  # NUNCA hacer esto
        self.id = nuevo_id

# BIEN: ID inmutable (field con init=False o frozen)

Checklist de Entidades