Migración a Microservicios (Strangler Pattern) - MiguelAngelMarinMejia1/ArquitecturaDeSoftware-Proyecto GitHub Wiki

Módulo estrangulado: Cotización de precios

El cálculo de lo que cuesta una estadía (noches, tarifa por habitación, impuestos y total) salió del monolito Django y ahora vive en un microservicio independiente en Flask, expuesto en /api/v2/cotizacion/. El resto del sistema sigue en Django sin cambios y Nginx reparte el tráfico entre ambos según la URL.

Matriz de decisión

Se evaluaron los módulos del sistema con los tres criterios del taller. Escala: Baja / Media / Alta. En acoplamiento, "Alto" es malo: significa que el módulo depende mucho de otras tablas.

Módulo Frecuencia de cambio Consumo de recursos Acoplamiento con la BD Decisión
Clientes / identidad (CustomerService) Baja Bajo Alto (tabla Customer enlazada a User) Mantener en Django
Catálogo y disponibilidad (selectors.py) Baja Medio Alto (consulta cruzada de reservas y habitaciones) Mantener en Django
Crear reserva (ReservationService) Media Medio Muy alto (transacción sobre 5 tablas) Mantener en Django
Pagos y facturación (PaymentService, InvoiceService) Media Medio Alto (Payment e Invoice ligados a Reservation) Mantener en Django (candidato futuro)
Cotización de precios (PricingPolicy, TaxPolicy) Alta Medio Nulo (cálculo puro, no lee la BD) Estrangular (Flask)

Justificación

  • Es la regla que más cambia. Tarifas, impuestos y temporadas altas/bajas se ajustan con más frecuencia que cualquier otra parte del negocio. Hoy SeasonalPricingPolicy cobra la tarifa base todo el año y el TAX_RATE del 19 % es provisional: son cambios seguros a corto plazo. Aislados en su propio servicio, se despliegan sin reiniciar ni arriesgar el monolito de reservas.
  • Acoplamiento nulo con la base de datos. El cálculo solo necesita fechas y tarifas por noche. Toda esa información viaja en el JSON de la petición, así que el microservicio no comparte tablas con Django. Es la separación más limpia posible: no hay que dividir la base de datos.
  • Se consulta mucho más de lo que se reserva. Un huésped cotiza varias veces antes de comprar. Con el servicio separado, ese tráfico de lectura se puede escalar (más workers o réplicas) sin escalar todo Django.
  • Riesgo bajo. Si el microservicio falla, el flujo de reserva de Django no se cae: sigue usando su propia PricingPolicy, que tiene exactamente las mismas reglas.

Los demás módulos se quedan en Django porque dependen de varias tablas relacionadas y de transacciones: separarlos obligaría a partir la base de datos, algo que no se justifica todavía.

Nueva arquitectura

flowchart LR
    Cliente["Navegador / cliente API"] -->|"http://localhost :80"| Nginx

    subgraph Docker["Red interna de Docker Compose"]
        Nginx["Nginx<br/>(puerta de entrada)"]
        Django["django_web :8000<br/>Monolito Django + DRF"]
        Flask["pricing_service :5000<br/>Microservicio Flask + gunicorn"]
        DB[("SQLite<br/>volumen sqlite_data")]

        Nginx -->|"/api/v2/cotizacion/*"| Flask
        Nginx -->|"/api/v1/*"| Django
        Nginx -->|"/ (web, admin, static)"| Django
        Django --> DB
    end
Loading

Solo Nginx publica un puerto hacia el exterior. Django y Flask viven en la red interna y no se puede llegar a ellos sin pasar por Nginx. Flask no tiene conexión a la base de datos.

Flujo de una cotización

sequenceDiagram
    participant C as Cliente
    participant N as Nginx
    participant F as Flask (pricing_service)

    C->>N: POST /api/v2/cotizacion/ (JSON)
    N->>F: reenvía la petición (proxy_pass)
    F->>F: validation.py valida fechas y habitaciones
    alt datos inválidos
        F-->>N: 400 {"error": {"code", "detail"}}
    else datos válidos
        F->>F: pricing.py calcula noches, subtotal, impuestos y total
        F-->>N: 200 cotización en JSON
    end
    N-->>C: respuesta
Loading

Cómo se logró la separación técnica

1. Microservicio Flask (pricing_service/)

Cada archivo tiene una sola responsabilidad:

Archivo Responsabilidad
app.py Crea la app Flask y define las rutas /api/v2/cotizacion/ y /health/
pricing.py Reglas de precio portadas de reservation/domain/policies.py (mismas fórmulas, Decimal, redondeo ROUND_HALF_UP)
validation.py Valida y convierte el JSON recibido (fechas ISO, rango válido, precios > 0, ids sin repetir)
errors.py Excepciones del dominio y manejadores que convierten cualquier error en JSON estructurado
config.py Configuración por variables de entorno (TAX_RATE, TIME_ZONE, PORT)

Contrato de la API

POST /api/v2/cotizacion/
{
  "check_in_date": "2026-10-10",
  "check_out_date": "2026-10-13",
  "rooms": [
    { "room_id": 1, "price_per_night": "180000.00" },
    { "room_id": 5, "price_per_night": "250000.00" }
  ]
}
200 OK
{
  "check_in_date": "2026-10-10",
  "check_out_date": "2026-10-13",
  "nights": 3,
  "rooms": [
    { "room_id": 1, "price_per_night": "180000.00", "nights": 3, "line_total": "540000.00" },
    { "room_id": 5, "price_per_night": "250000.00", "nights": 3, "line_total": "750000.00" }
  ],
  "subtotal": "1290000.00",
  "tax_rate": "0.19",
  "taxes": "245100.00",
  "total": "1535100.00"
}

Los montos viajan como texto para no perder precisión (igual que la API v1 de Django).

Manejo de errores (resiliencia). Todas las respuestas de error usan el mismo formato que la API v1, así un cliente parsea una sola estructura:

{ "error": { "code": "INVALIDDATERANGE", "detail": "La fecha de salida debe ser posterior a la fecha de entrada." } }
Código HTTP code Cuándo
400 INVALIDPAYLOAD El cuerpo no es JSON o falta/está mal un campo
400 INVALIDDATERANGE Salida no posterior a la entrada, o entrada en el pasado
400 NOROOMSSELECTED La lista rooms está vacía
400 INVALIDPRICE Un precio no es numérico o no es mayor que 0
404 NOTFOUND Ruta inexistente
405 METHODNOTALLOWED Método HTTP incorrecto (ej. GET a /api/v2/cotizacion/)
500 INTERNALSERVERERROR Error inesperado: se registra en el log, el cliente recibe un mensaje genérico y el servicio sigue atendiendo

2. Contenedorización (Docker)

  • Dockerfile (raíz): imagen de Django. Al arrancar ejecuta migrate, loaddata seed y runserver 0.0.0.0:8000.
  • pricing_service/Dockerfile: imagen independiente de Flask, con sus propias dependencias, servida con gunicorn (2 workers) en el puerto 5000.
  • docker-compose.yml: levanta django_web, pricing_service y nginx. La base de datos SQLite se guarda en el volumen sqlite_data, para que los datos sobrevivan al recrear el contenedor. Para que esto funcione, settings.py ahora lee SQLITE_PATH y ALLOWED_HOSTS del entorno.

3. Orquestación del tráfico (Nginx)

# nginx/nginx.conf
upstream django_backend  { server django_web:8000; }
upstream pricing_backend { server pricing_service:5000; }

server {
    listen 80;

    # Microservicio nuevo (funcionalidad estrangulada)
    location /api/v2/cotizacion {
        proxy_pass http://pricing_backend;
    }

    # Monolito legacy: API v1
    location /api/v1/ {
        proxy_pass http://django_backend;
    }

    # Monolito legacy: sitio web, admin y estáticos
    location / {
        proxy_pass http://django_backend;
    }
}

Nginx elige siempre la location con el prefijo más largo que coincida, por eso /api/v2/cotizacion gana sobre /. Para estrangular otro módulo en el futuro basta con agregar un servicio en docker-compose.yml, su upstream y una nueva location.

Cómo probarlo

docker compose up --build
# Salud del microservicio
curl.exe http://localhost/api/v2/cotizacion/health/

# Cotización (PowerShell)
$body = '{"check_in_date":"2026-12-10","check_out_date":"2026-12-13","rooms":[{"room_id":1,"price_per_night":"180000.00"}]}'
Invoke-RestMethod -Method Post -Uri http://localhost/api/v2/cotizacion/ -ContentType "application/json" -Body $body

# Error estructurado (400)
Invoke-WebRequest -Method Post -Uri http://localhost/api/v2/cotizacion/ -ContentType "application/json" -Body '{}' -SkipHttpErrorCheck

# El monolito sigue respondiendo
curl.exe http://localhost/api/v1/health/
curl.exe http://localhost/api/v1/room-types/

-SkipHttpErrorCheck requiere PowerShell 7. En Windows PowerShell 5 usa curl.exe -i -X POST http://localhost/api/v2/cotizacion/ -H "Content-Type: application/json" -d "{}".

Impacto esperado en el sistema

Aspecto Antes (monolito) Después
Cambiar una tarifa o impuesto Editar y redesplegar todo Django Cambiar TAX_RATE o pricing.py y redesplegar solo pricing_service
Escalar las cotizaciones Escalar todo el monolito Subir los workers o réplicas solo de Flask
Fallo en el cálculo de precios Afecta al proceso que atiende todo el sitio Queda aislado en su contenedor; Django sigue funcionando
Punto de entrada Django directo Nginx único; los servicios internos no se exponen
Base de datos SQLite compartida Sin cambios: Flask no la usa, así que no hubo que dividirla

Costos que asumimos: un salto de red adicional (Nginx → Flask), un servicio más que mantener y monitorear, y durante la transición las reglas de precio existen en dos lugares (Django y Flask) y deben mantenerse iguales.

Siguientes pasos del estrangulamiento

  1. Hacer que la página de reserva de Django pida la cotización a /api/v2/cotizacion/ en lugar de usar su PricingPolicy local, dejando esta última solo como respaldo si Flask no responde.
  2. Cuando todo el tráfico de precios pase por Flask, eliminar PricingPolicy del monolito.
  3. Agregar autenticación o limitación de peticiones en Nginx: hoy /api/v2/cotizacion/ es público, igual que la API v1.
  4. Evaluar pagos y facturación como el siguiente módulo a estrangular.

División del trabajo

Integrante Responsabilidad
Daniel Giraldo Microservicio Flask: reglas de precio, validación, errores estructurados y endpoint
Miguel Marín Contenedorización: Dockerfiles, docker-compose.yml y configuración de Django por entorno
Johan Peña Orquestación con Nginx, README y esta página de la Wiki
⚠️ **GitHub.com Fallback** ⚠️