← Volver al listado de tecnologías
Capítulo 3: Entidades
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
- Identidad única - Un identificador que la distingue de todas las demás
- Continuidad - Persiste a través del tiempo y cambios de estado
- Mutabilidad controlada - Sus atributos pueden cambiar, su identidad no
- 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
- ¿La entidad tiene un ID único e inmutable?
- ¿Implementa
__eq__y__hash__basados en ID? - ¿Encapsula sus reglas de negocio?
- ¿Las transiciones de estado están validadas?
- ¿No expone sus campos internos directamente?