← Volver al listado de tecnologías

Capítulo 10: Caso Práctico - Sistema de Reservas

Por: Artiko
dddpythoncaso-practicoarquitectura

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érminoSignificado
HabitaciónEspacio físico rentable con número y tipo
ReservaCompromiso de ocupar habitación en fechas
HuéspedCliente que realiza una reserva
Check-inLlegada y ocupación de la habitación
Check-outSalida y liberación de la habitación
TemporadaPeríodo que afecta precios (alta/media/baja)
CancelaciónAnulació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

ConceptoAplicadoEjemplo
Lenguaje UbicuoReserva, Huésped, Check-in
Value ObjectsRangoFechas, Dinero, DatosHuesped
EntidadesReserva (con identidad y ciclo de vida)
AgregadosHabitación (raíz con Reservas)
RepositoriosRepositorioHabitaciones
ServiciosServicioPrecios
Domain EventsReservaCreada, ReservaCancelada
FactoriesHabitacionFactory
Casos de UsoCrearReserva, CancelarReserva

Resumen del Tutorial

Has aprendido a diseñar software usando Domain-Driven Design:

  1. Modelo el dominio primero - antes del código
  2. Habla el idioma del negocio - Lenguaje Ubicuo
  3. Separa lo mutable de lo inmutable - Entidades vs Value Objects
  4. Protege la consistencia - Agregados con invariantes
  5. Abstrae la persistencia - Repositorios
  6. Comunica cambios - Domain Events
  7. Crea objetos complejos - Factories