← Volver al listado de tecnologías
Capítulo 10: Caso Práctico - Sistema de Reservas
Capítulo 10: Caso Práctico Completo
“El valor de un modelo de dominio se demuestra en su uso práctico.” — Eric Evans
Visión General del Sistema
Implementaremos un Sistema de Reservas de Hotel aplicando todos los conceptos DDD aprendidos.
mindmap
root((Sistema de Reservas))
Bloques Tácticos
Entidades
Value Objects
Agregados
Persistencia
Repositorios
Factories
Comunicación
Domain Events
Servicios
Estratégico
Lenguaje Ubicuo
Bounded Contexts
Lenguaje Ubicuo del Dominio
| Término | Significado |
|---|---|
| Habitación | Espacio físico rentable con número y tipo |
| Reserva | Compromiso de ocupar habitación en fechas |
| Huésped | Cliente que realiza una reserva |
| Check-in | Llegada y ocupación de la habitación |
| Check-out | Salida y liberación de la habitación |
| Temporada | Período que afecta precios (alta/media/baja) |
| Cancelación | Anulación de reserva con posible penalización |
Arquitectura por Capas
graph TB
subgraph Presentation["Presentation Layer"]
API[API REST]
CLI[CLI]
Web[Web Interface]
end
subgraph Application["Application Layer"]
UC1[CrearReserva]
UC2[CancelarReserva]
UC3[ConfirmarReserva]
end
subgraph Domain["Domain Layer"]
AGG[Agregados]
SVC[Servicios]
EVT[Events]
FAC[Factories]
end
subgraph Infrastructure["Infrastructure Layer"]
REPO[Repositorios SQL]
BUS[EventBus]
NOTIF[Notificaciones]
end
Presentation --> Application
Application --> Domain
Domain --> Infrastructure
Value Objects
# domain/value_objects.py
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Self
class TipoHabitacion(Enum):
SIMPLE = "simple"
DOBLE = "doble"
SUITE = "suite"
@property
def capacidad(self) -> int:
return {"simple": 1, "doble": 2, "suite": 4}[self.value]
@dataclass(frozen=True)
class RangoFechas:
entrada: date
salida: date
def __post_init__(self):
if self.entrada >= self.salida:
raise ValueError("Entrada debe ser anterior a salida")
@property
def noches(self) -> int:
return (self.salida - self.entrada).days
def se_superpone(self, otro: "RangoFechas") -> bool:
return self.entrada < otro.salida and otro.entrada < self.salida
def contiene_fecha(self, fecha: date) -> bool:
return self.entrada <= fecha < self.salida
@dataclass(frozen=True)
class Dinero:
cantidad: Decimal
moneda: str = "USD"
def __post_init__(self):
if self.cantidad < 0:
raise ValueError("Cantidad no puede ser negativa")
def __add__(self, otro: Self) -> Self:
self._validar_moneda(otro)
return Dinero(self.cantidad + otro.cantidad, self.moneda)
def __mul__(self, factor: int | Decimal) -> Self:
return Dinero(self.cantidad * Decimal(str(factor)), self.moneda)
def porcentaje(self, pct: int) -> Self:
return Dinero(self.cantidad * pct / 100, self.moneda)
def _validar_moneda(self, otro: Self):
if self.moneda != otro.moneda:
raise ValueError(f"Monedas diferentes: {self.moneda} vs {otro.moneda}")
@dataclass(frozen=True)
class DatosHuesped:
nombre: str
email: str
telefono: str
def __post_init__(self):
if "@" not in self.email:
raise ValueError("Email inválido")
Domain Events
# domain/events.py
from dataclasses import dataclass, field
from datetime import datetime
from uuid import UUID, uuid4
from abc import ABC
@dataclass(frozen=True)
class DomainEvent(ABC):
event_id: UUID = field(default_factory=uuid4)
occurred_on: datetime = field(default_factory=datetime.now)
@dataclass(frozen=True)
class ReservaCreada(DomainEvent):
reserva_id: UUID
habitacion_id: UUID
huesped_id: UUID
entrada: date
salida: date
precio_total: Decimal
@dataclass(frozen=True)
class ReservaConfirmada(DomainEvent):
reserva_id: UUID
metodo_pago: str
@dataclass(frozen=True)
class ReservaCancelada(DomainEvent):
reserva_id: UUID
motivo: str
penalizacion: Decimal
@dataclass(frozen=True)
class CheckInRealizado(DomainEvent):
reserva_id: UUID
habitacion_numero: str
@dataclass(frozen=True)
class CheckOutRealizado(DomainEvent):
reserva_id: UUID
cargos_adicionales: Decimal
Entidad: Reserva
# domain/entities.py
from dataclasses import dataclass, field
from uuid import UUID, uuid4
from datetime import datetime, date
from enum import Enum
class EstadoReserva(Enum):
PENDIENTE = "pendiente"
CONFIRMADA = "confirmada"
EN_CURSO = "en_curso"
COMPLETADA = "completada"
CANCELADA = "cancelada"
@dataclass
class Reserva:
id: UUID
habitacion_id: UUID
huesped_id: UUID
fechas: RangoFechas
precio_total: Dinero
estado: EstadoReserva
creada_en: datetime
_eventos: list[DomainEvent] = field(default_factory=list)
@classmethod
def crear(cls, habitacion_id: UUID, huesped_id: UUID,
fechas: RangoFechas, precio: Dinero) -> "Reserva":
reserva = cls(
id=uuid4(),
habitacion_id=habitacion_id,
huesped_id=huesped_id,
fechas=fechas,
precio_total=precio,
estado=EstadoReserva.PENDIENTE,
creada_en=datetime.now(),
)
reserva._registrar_evento(ReservaCreada(
reserva_id=reserva.id,
habitacion_id=habitacion_id,
huesped_id=huesped_id,
entrada=fechas.entrada,
salida=fechas.salida,
precio_total=precio.cantidad,
))
return reserva
def confirmar(self, metodo_pago: str) -> None:
if self.estado != EstadoReserva.PENDIENTE:
raise EstadoInvalido("Solo reservas pendientes pueden confirmarse")
self.estado = EstadoReserva.CONFIRMADA
self._registrar_evento(ReservaConfirmada(
reserva_id=self.id, metodo_pago=metodo_pago
))
def cancelar(self, motivo: str, penalizacion: Dinero) -> None:
if self.estado in (EstadoReserva.COMPLETADA, EstadoReserva.CANCELADA):
raise EstadoInvalido("No se puede cancelar esta reserva")
self.estado = EstadoReserva.CANCELADA
self._registrar_evento(ReservaCancelada(
reserva_id=self.id, motivo=motivo,
penalizacion=penalizacion.cantidad
))
def check_in(self, habitacion_numero: str) -> None:
if self.estado != EstadoReserva.CONFIRMADA:
raise EstadoInvalido("Debe estar confirmada para check-in")
if date.today() < self.fechas.entrada:
raise CheckInAnticipado(self.fechas.entrada)
self.estado = EstadoReserva.EN_CURSO
self._registrar_evento(CheckInRealizado(
reserva_id=self.id, habitacion_numero=habitacion_numero
))
def check_out(self, cargos_adicionales: Dinero = None) -> None:
if self.estado != EstadoReserva.EN_CURSO:
raise EstadoInvalido("Debe estar en curso para check-out")
self.estado = EstadoReserva.COMPLETADA
cargos = cargos_adicionales or Dinero(Decimal("0"))
self._registrar_evento(CheckOutRealizado(
reserva_id=self.id, cargos_adicionales=cargos.cantidad
))
def esta_activa(self) -> bool:
return self.estado in (EstadoReserva.PENDIENTE,
EstadoReserva.CONFIRMADA,
EstadoReserva.EN_CURSO)
def _registrar_evento(self, evento: DomainEvent):
self._eventos.append(evento)
def obtener_eventos(self) -> list[DomainEvent]:
eventos = self._eventos.copy()
self._eventos.clear()
return eventos
Agregado: Habitación
# domain/aggregates.py
@dataclass
class Habitacion:
id: UUID
numero: str
tipo: TipoHabitacion
precio_base: Dinero
_reservas: list[Reserva] = field(default_factory=list)
_eventos: list[DomainEvent] = field(default_factory=list)
def esta_disponible(self, fechas: RangoFechas) -> bool:
return not any(
r.fechas.se_superpone(fechas) and r.esta_activa()
for r in self._reservas
)
def reservar(self, huesped_id: UUID, fechas: RangoFechas,
precio: Dinero) -> Reserva:
if not self.esta_disponible(fechas):
raise HabitacionNoDisponible(self.numero, fechas)
reserva = Reserva.crear(
habitacion_id=self.id,
huesped_id=huesped_id,
fechas=fechas,
precio=precio,
)
self._reservas.append(reserva)
return reserva
def obtener_reserva(self, reserva_id: UUID) -> Reserva | None:
return next((r for r in self._reservas if r.id == reserva_id), None)
def cancelar_reserva(self, reserva_id: UUID, motivo: str,
penalizacion: Dinero) -> None:
reserva = self.obtener_reserva(reserva_id)
if not reserva:
raise ReservaNoEncontrada(reserva_id)
reserva.cancelar(motivo, penalizacion)
def obtener_todos_eventos(self) -> list[DomainEvent]:
eventos = []
for reserva in self._reservas:
eventos.extend(reserva.obtener_eventos())
return eventos
Servicio de Dominio: Precios
# domain/services.py
class ServicioPrecios:
PRECIOS_TEMPORADA = {
"alta": Decimal("1.5"),
"media": Decimal("1.2"),
"baja": Decimal("1.0"),
}
PENALIZACION_CANCELACION = {
7: 0, # Gratis si >7 días antes
3: 25, # 25% si 3-7 días
1: 50, # 50% si 1-3 días
0: 100, # 100% si <1 día
}
def calcular_precio_reserva(self, habitacion: Habitacion,
fechas: RangoFechas) -> Dinero:
multiplicador = self._obtener_multiplicador(fechas.entrada)
precio_noche = Dinero(
habitacion.precio_base.cantidad * multiplicador,
habitacion.precio_base.moneda
)
return precio_noche * fechas.noches
def calcular_penalizacion(self, reserva: Reserva) -> Dinero:
dias_anticipacion = (reserva.fechas.entrada - date.today()).days
for dias, porcentaje in sorted(
self.PENALIZACION_CANCELACION.items(), reverse=True
):
if dias_anticipacion >= dias:
return reserva.precio_total.porcentaje(porcentaje)
return reserva.precio_total
def _obtener_multiplicador(self, fecha: date) -> Decimal:
mes = fecha.month
if mes in (12, 1, 7, 8):
return self.PRECIOS_TEMPORADA["alta"]
if mes in (6, 9, 3, 4):
return self.PRECIOS_TEMPORADA["media"]
return self.PRECIOS_TEMPORADA["baja"]
Factory
# domain/factories.py
class HabitacionFactory:
@staticmethod
def crear(numero: str, tipo: TipoHabitacion,
precio_base: Decimal) -> Habitacion:
return Habitacion(
id=uuid4(),
numero=numero,
tipo=tipo,
precio_base=Dinero(precio_base),
)
@staticmethod
def crear_suite(numero: str) -> Habitacion:
return HabitacionFactory.crear(
numero, TipoHabitacion.SUITE, Decimal("250")
)
@staticmethod
def crear_doble(numero: str) -> Habitacion:
return HabitacionFactory.crear(
numero, TipoHabitacion.DOBLE, Decimal("120")
)
Casos de Uso
# application/use_cases.py
@dataclass
class CrearReservaCommand:
habitacion_id: UUID
huesped_id: UUID
entrada: date
salida: date
class CrearReserva:
def __init__(self, repo: RepositorioHabitaciones,
precios: ServicioPrecios, event_bus: EventBus):
self._repo = repo
self._precios = precios
self._event_bus = event_bus
def ejecutar(self, cmd: CrearReservaCommand) -> UUID:
fechas = RangoFechas(cmd.entrada, cmd.salida)
habitacion = self._repo.obtener(cmd.habitacion_id)
if not habitacion:
raise HabitacionNoEncontrada(cmd.habitacion_id)
precio = self._precios.calcular_precio_reserva(habitacion, fechas)
reserva = habitacion.reservar(cmd.huesped_id, fechas, precio)
self._repo.guardar(habitacion)
self._publicar_eventos(habitacion)
return reserva.id
def _publicar_eventos(self, habitacion: Habitacion):
for evento in habitacion.obtener_todos_eventos():
self._event_bus.publicar(evento)
class CancelarReserva:
def __init__(self, repo: RepositorioHabitaciones,
precios: ServicioPrecios, event_bus: EventBus):
self._repo = repo
self._precios = precios
self._event_bus = event_bus
def ejecutar(self, habitacion_id: UUID, reserva_id: UUID,
motivo: str) -> Dinero:
habitacion = self._repo.obtener(habitacion_id)
reserva = habitacion.obtener_reserva(reserva_id)
penalizacion = self._precios.calcular_penalizacion(reserva)
habitacion.cancelar_reserva(reserva_id, motivo, penalizacion)
self._repo.guardar(habitacion)
self._publicar_eventos(habitacion)
return penalizacion
Repositorio con SQLAlchemy
# infrastructure/repositories.py
class RepositorioHabitacionesSQL(RepositorioHabitaciones):
def __init__(self, session: Session):
self._session = session
def guardar(self, habitacion: Habitacion) -> None:
modelo = self._a_modelo(habitacion)
self._session.merge(modelo)
self._session.commit()
def obtener(self, id: UUID) -> Habitacion | None:
modelo = self._session.get(HabitacionModelo, id)
return self._a_dominio(modelo) if modelo else None
def buscar_disponibles(self, fechas: RangoFechas,
tipo: TipoHabitacion = None) -> list[Habitacion]:
query = self._session.query(HabitacionModelo)
if tipo:
query = query.filter(HabitacionModelo.tipo == tipo.value)
habitaciones = [self._a_dominio(m) for m in query.all()]
return [h for h in habitaciones if h.esta_disponible(fechas)]
Event Handlers
# infrastructure/event_handlers.py
class NotificacionHandler:
def __init__(self, servicio_email: ServicioEmail):
self._email = servicio_email
def handle_reserva_creada(self, evento: ReservaCreada):
self._email.enviar(
destinatario=evento.huesped_id,
asunto="Reserva recibida",
cuerpo=f"Reserva #{evento.reserva_id} pendiente de confirmación"
)
def handle_reserva_confirmada(self, evento: ReservaConfirmada):
self._email.enviar(
destinatario=evento.reserva_id, # obtener email del huésped
asunto="Reserva confirmada",
cuerpo=f"Su reserva #{evento.reserva_id} ha sido confirmada"
)
Test del Sistema
# tests/test_reservas.py
def test_crear_reserva_exitosa():
# Arrange
repo = RepositorioHabitacionesMemoria()
precios = ServicioPrecios()
event_bus = EventBusMock()
habitacion = HabitacionFactory.crear_doble("101")
repo.guardar(habitacion)
caso_uso = CrearReserva(repo, precios, event_bus)
# Act
cmd = CrearReservaCommand(
habitacion_id=habitacion.id,
huesped_id=uuid4(),
entrada=date(2025, 7, 15),
salida=date(2025, 7, 20),
)
reserva_id = caso_uso.ejecutar(cmd)
# Assert
assert reserva_id is not None
assert len(event_bus.eventos) == 1
assert isinstance(event_bus.eventos[0], ReservaCreada)
def test_no_permite_reservas_superpuestas():
repo = RepositorioHabitacionesMemoria()
habitacion = HabitacionFactory.crear_doble("101")
repo.guardar(habitacion)
# Primera reserva
fechas1 = RangoFechas(date(2025, 7, 15), date(2025, 7, 20))
habitacion.reservar(uuid4(), fechas1, Dinero(Decimal("600")))
# Segunda reserva superpuesta
fechas2 = RangoFechas(date(2025, 7, 18), date(2025, 7, 25))
with pytest.raises(HabitacionNoDisponible):
habitacion.reservar(uuid4(), fechas2, Dinero(Decimal("840")))
Checklist de Implementación DDD
| Concepto | Aplicado | Ejemplo |
|---|---|---|
| Lenguaje Ubicuo | ✅ | Reserva, Huésped, Check-in |
| Value Objects | ✅ | RangoFechas, Dinero, DatosHuesped |
| Entidades | ✅ | Reserva (con identidad y ciclo de vida) |
| Agregados | ✅ | Habitación (raíz con Reservas) |
| Repositorios | ✅ | RepositorioHabitaciones |
| Servicios | ✅ | ServicioPrecios |
| Domain Events | ✅ | ReservaCreada, ReservaCancelada |
| Factories | ✅ | HabitacionFactory |
| Casos de Uso | ✅ | CrearReserva, CancelarReserva |
Resumen del Tutorial
Has aprendido a diseñar software usando Domain-Driven Design:
- Modelo el dominio primero - antes del código
- Habla el idioma del negocio - Lenguaje Ubicuo
- Separa lo mutable de lo inmutable - Entidades vs Value Objects
- Protege la consistencia - Agregados con invariantes
- Abstrae la persistencia - Repositorios
- Comunica cambios - Domain Events
- Crea objetos complejos - Factories