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.pyServicio.calcularPrecio()→catalogo/dominio/precios.pyBloqueDisponibilidad.estaLibre()→reservas/dominio/agenda.pyReserva.calcularTotal()→reservas/dominio/precios.pyReserva.confirmar()ycancelar()→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.