entrgable 1 - theansw3r/CasaLista-Taller01 GitHub Wiki

Wiki — CasaLista

Entrega 1, Arquitectura de Software.

CasaLista es un marketplace para reservar servicios del hogar (plomería, electricidad, etc). Acá explicamos las tres cosas que pide el enunciado.


1. Por qué las carpetas están así

config/            settings y rutas
apps/
  comun/           lo que usan todas las apps
  cuentas/         Usuario, Cliente, Profesional
  catalogo/        Servicio
  reservas/        BloqueDisponibilidad, Reserva, DetalleReserva
  pagos/           Pago
templates/         el front
static/            css y js

Una app por módulo

En la Actividad 1 ya habíamos separado las clases en cuatro módulos, según qué cosas cambian juntas. Aquí hicimos lo mismo, para no inventar una organización distinta a la que ya habíamos justificado:

  • Cuentas y Catálogo → cuentas + catalogo
  • Agenda y Reservas → reservas
  • Pagos y Liquidación → pagos
  • Reputación → todavía no (Entrega 2)

Lo único que cambiamos fue separar cuentas de catalogo, aunque en el documento van juntos. Nos pareció que el catálogo va a recibir muchísimas más consultas que los perfiles (uno busca varias veces antes de reservar una), y si algún día toca meterle caché, es más fácil si ya está aparte.

De las 10 clases del diagrama tenemos 8. Faltan Liquidación y Reseña, que pasan después de que se vende. Metimos Pago porque sin ella no podíamos hacer RN-03, que dice que una reserva solo se confirma si hay un pago aprobado.

Cómo dependen unas de otras

flowchart RL
    pagos --> reservas
    reservas --> catalogo
    reservas --> cuentas
    catalogo --> cuentas
    cuentas --> comun
    reservas --> comun
    pagos --> comun
    catalogo --> comun

No hay ciclos. cuentas no importa nada de las otras, y de pagos no depende nadie. Eso quiere decir que si algún día hay que sacar pagos a otro servidor, sería lo más fácil de mover.

Adentro de cada app

models.py          nada más los campos
dominio/           las reglas (RN-01, RN-02...) y el Builder
services.py        el caso de uso, una clase por flujo
infraestructura/   Factories, correo, pasarela de pago
api/               serializers + APIView + urls
tests/

Hay dos cosas que respetamos siempre:

Los modelos no calculan nada. El enunciado avisa que poner cálculos o validaciones en un Model penaliza, así que los sacamos todos. Lo que en el diagrama era un método de la clase, acá quedó en dominio/:

  • Profesional.cubreZona() → cuentas/dominio/cobertura.py
  • Servicio.calcularPrecio() → catalogo/dominio/precios.py
  • BloqueDisponibilidad.estaLibre() → reservas/dominio/agenda.py
  • Reserva.calcularTotal() → reservas/dominio/precios.py
  • Reserva.confirmar() y cancelar() → reservas/dominio/ciclo_de_vida.py

Al final nos sirvió para algo más que cumplir la norma: como las reglas ya no dependen del modelo, se pueden probar sin base de datos. La prueba de RN-07 corre contra un objeto falso de tres líneas. Por eso las 144 pruebas demoran menos de un segundo.

dominio/ no importa nada de infraestructura/. El dominio dice qué necesita en puertos.py (una interfaz) y la infraestructura lo implementa. Así el service depende de la interfaz y no de la clase concreta, y en las pruebas le podemos pasar uno falso.

Cosas que pensamos y no hicimos

Carpetas domain/, application/, infrastructure/ en la raíz. Se ve en varios ejemplos de Clean Architecture. El problema es que agrupa por capa antes que por módulo, y entonces pagos queda repartido en tres carpetas. Como lo dejamos, apps/pagos/ se puede mover completo.

Un Repository encima del ORM. Lo estuvimos mirando pero el QuerySet de Django ya hace básicamente eso. Habríamos terminado escribiendo métodos que solo llaman a otro método. Lo que sí separamos fue traducir el DoesNotExist a excepciones nuestras, que era lo que de verdad servía.


2. Diagrama de secuencia: crear una reserva

Este es el flujo más complicado que tenemos (POST /api/v1/reservas/). Pasa por todas las capas, usa el Builder y las dos Factories, y escribe en tres tablas dentro de una transacción.

sequenceDiagram
    actor Cliente
    participant V as ReservasView
    participant S as ReservaService
    participant F as Factories
    participant B as ReservaBuilder
    participant DB as Base de datos
    participant N as Notificador

    Cliente->>V: POST /reservas/ (con JWT)
    V->>V: valida la forma con el serializer
    V->>S: crear_reserva(comando)

    S->>F: crear() notificador y tarifa
    F-->>S: las implementaciones del entorno
    S->>DB: busca cliente, bloque y servicios
    S->>B: arma el builder paso a paso
    S->>B: build()

    B->>B: RN-02 mismo profesional
    B->>B: RN-07 profesional verificado y cubre la zona
    B->>B: RN-01 bloque libre y del mismo profesional
    B->>B: los servicios caben en la franja

    alt alguna regla falla
        B-->>V: excepcion de dominio (nadie la atrapa)
        V-->>Cliente: 409 (o 404 segun el caso)
    else todo bien
        B->>DB: BEGIN
        B->>DB: SELECT bloque FOR UPDATE
        B->>DB: revisa otra vez que siga libre
        B->>DB: guarda Reserva + DetalleReserva
        B->>DB: pone el bloque en OCUPADO
        B->>DB: COMMIT
        B-->>S: reserva creada
        S->>N: notifica (ya fuera de la transaccion)
        S-->>V: reserva
        V-->>Cliente: 201 con los datos
    end

Los errores no pasan por la vista

Ninguna vista tiene try/except. El dominio lanza su excepción, DRF la agarra, y un solo archivo (apps/comun/api/manejador_errores.py) la traduce. Cada excepción trae su código HTTP encima:

class BloqueNoDisponible(ReglaDeNegocioViolada):
    codigo = "RN-01"          # estado_http = 409

Y todos los errores salen igual, no importa si vienen del dominio o de un serializer:

{"error": {"codigo": "RN-01", "mensaje": "La franja horaria ya fue tomada."}}

Los estados de la reserva

stateDiagram-v2
    [*] --> PENDIENTE : se crea
    PENDIENTE --> CONFIRMADA : pago aprobado (RN-03)
    PENDIENTE --> CANCELADA : cancelacion (RN-04)
    CONFIRMADA --> COMPLETADA : visita hecha (Entrega 2)
    CONFIRMADA --> CANCELADA : cancelacion (RN-04)
    COMPLETADA --> [*]
    CANCELADA --> [*]

Cancelar libera el bloque y calcula la penalización. Cualquier otro cambio de estado devuelve 409.


3. Cómo queda preparado para un API Gateway

Un API Gateway es lo que se pone adelante de la API cuando la app ya está en producción. Toda la gente entra por ahí, y él se encarga del HTTPS, de revisar que el usuario esté autenticado, de que nadie haga mil peticiones por segundo, y de mandar cada petición al servicio que le toca.

flowchart LR
    W[Web / Movil] --> G[API Gateway]
    G -->|"/api/v1/pagos/*"| P[Servicio Pagos]
    G -->|"/api/v1/*"| C[CasaLista Core]
    P --> BD[(PostgreSQL)]
    C --> BD

Nosotros no montamos un Gateway (no era parte de la entrega), pero sí dejamos la API de forma que ponerle uno después no obligue a reescribir nada. Estas son las cosas que nos parecieron importantes:

Las rutas van por módulo. Todo cuelga de /api/v1/ y cada módulo tiene su pedazo: /cuentas/, /catalogo/, /reservas/, /pagos/. Un Gateway decide a dónde mandar cada cosa mirando el comienzo de la URL, así que si mañana pagos se va a otro servidor, se cambia la config del Gateway y ya. Y el /v1/ sirve para que si algún día cambiamos la API, los que usaban la vieja no se rompan.

Login con JWT y no con sesión. Esta creemos que es la más importante. Con las sesiones normales de Django, quién eres queda guardado en el servidor, entonces siempre tienes que volver al mismo. Con JWT el token trae todo adentro y viene firmado, así que cualquier servidor te puede atender y el Gateway incluso puede rechazar un token falso sin molestar a la app. Eso es lo que permite tener varias instancias corriendo.

Todos los errores salen igual. Da lo mismo si el error viene de una regla de negocio o de un campo mal enviado, siempre tiene la misma forma. Así el Gateway los puede contar sin tener que entender varios formatos.

Los códigos HTTP dicen algo. El Gateway usa el código para decidir si vale la pena reintentar. Por eso cuando la pasarela rechaza un pago devolvemos 409 y no 502: la pasarela respondió bien, solo que dijo que no, y volver a intentar daría lo mismo. El 502 lo dejamos para cuando de verdad no contesta.

Hay un endpoint de salud. GET /api/v1/salud/, sin login. Revisa si la base de datos responde. Si no, devuelve 503 en vez de 500, porque el 503 significa "estoy caído, no me mandes gente" y el 500 significa "tengo un bug".

Se puede rastrear una petición. Un middleware guarda la cabecera X-Request-ID que manda el Gateway (o se inventa una si no viene) y la devuelve siempre. Sin eso, si un usuario dice "me salió error", no hay forma de encontrar cuál fue su petición.

Nada está quemado en el código. Todo lo que cambia entre desarrollo y producción sale de variables de entorno.

Lo que le pasaríamos al Gateway

Hay cosas que hoy hace la app pero que en producción debería hacer él:

  • El límite de peticiones: hoy lo hace DRF, pero cada instancia cuenta por su lado, entonces con tres instancias el límite se triplica sin querer.
  • CORS, HTTPS y la caché del catálogo.

Lo que nunca le pasaríamos son las reglas de negocio. RN-01, RN-04 y RN-07 se quedan en el dominio. Si el Gateway empieza a saber si un profesional cubre una zona, ya dejó de ser un gateway.

Qué faltaría para separar pagos de verdad

Para ser sinceros, hoy no está listo para partirlo:

  • Se rompería la transacción, porque guardar el pago y confirmar la reserva están en el mismo transaction.atomic().
  • Revisar que la reserva sea del cliente pasaría a ser una llamada por red, con la demora y los fallos que eso trae.

No lo resolvimos acá. Lo que sí logramos es que las fronteras entre módulos quedaran donde tienen que quedar, y eso es lo que haría que el día que toque, no haya que reescribir todo.


Anexo: dónde quedaron los patrones

  • Builder → apps/reservas/dominio/reserva_builder.py
  • Factory (notificador y tarifa) → apps/reservas/infraestructura/factories.py
  • Factory (pasarelas de pago) → apps/pagos/infraestructura/factories.py

Por qué el Builder está en Reserva. Porque es la clase más enredada que tenemos. Para crear una reserva hace falta el cliente, un bloque que además hay que marcar como ocupado, las líneas con el precio congelado, el total que calcula la estrategia de tarifa, y que se cumplan siete reglas al tiempo. Aparte es la única que escribe en tres tablas que tienen que quedar cuadradas entre sí. Sin Builder todo eso le tocaría al Service, y se repetiría cada vez que alguien más quiera crear una reserva.

Por qué son tres Factories y no una. Porque cada una resuelve algo distinto: cómo se manda el aviso, cuánto se cobra y cómo se mueve la plata. Se pueden combinar como uno quiera (correo real con tarifa base, o consola con tarifa dinámica), y si las juntábamos en una sola quedaban amarradas sin razón. Las tres usan un diccionario en vez de if/elif, así agregar una opción nueva es agregar una línea:

registro = {"MOCK": NotificadorConsola, "REAL": NotificadorEmail}

Para verlo funcionando:

TARIFA_TYPE=BASE     python manage.py sembrar_demo --reiniciar
TARIFA_TYPE=DINAMICA python manage.py sembrar_demo --reiniciar

Mismo código, distinto total.