En 2025 un cliente me contrato para construir una plataforma de telemedicina desde cero. Consultas medicas online, agendamiento, videollamadas, generacion de documentos clinicos y reportes para el sistema de salud colombiano.
Tenia un junior al lado al que le delegaba tareas especificas. El resto — arquitectura, decisiones de stack, infraestructura, CI/CD, seguridad — fue trabajo mio.
Este post es el registro tecnico de lo que construi, por que tome las decisiones que tome, y que problemas reales resolvi.
Contexto del dominio
Salud es un dominio regulado. En Colombia los prestadores de salud deben generar reportes RIPS (Registro Individual de Prestacion de Servicios) para el Ministerio de Salud. Los datos de pacientes son sensibles. Las videollamadas tienen que funcionar — si se cae la llamada a mitad de una consulta medica, el medico y el paciente pierden tiempo real.
Eso cambia como disenas el sistema. No puedes improvisar el manejo de errores. No puedes dejar tokens sin expiracion. No puedes loggear datos de pacientes sin sanitizar.
Arquitectura — por que 18 servicios
Parti de la pregunta: ¿que bounded contexts tiene este sistema?
- Autenticacion y roles — quien puede hacer que
- Personas — medicos, pacientes, centros medicos
- Citas — el dominio principal
- Archivos — documentos clinicos, consentimientos, firmas
- Notificaciones — email, SMS, WhatsApp
- Videollamadas — tokens Jitsi, coordinacion
- Datos comunes — diagnosticos CIE-10, EPS, municipios
Cada contexto termino siendo un servicio independiente con su propia base de datos. No un monolito con tablas compartidas.
graph TD
Browser["Angular 19"] --> nginx["nginx (SSL)"]
nginx --> GW["API Gateway :3000\nCORS · Rate limit · Auth"]
GW --> Auth["Auth :3050\nJWT · RBAC · Roles"]
GW --> People["People :3002\nMedicos · Pacientes · EPS"]
GW --> Appt["Appointments :3003\nCitas · RIPS · Documentos"]
GW --> Files["Files :3070\nFastAPI · PDF · S3"]
GW --> Notif["Notifications :3080\nSendGrid · Twilio · WA"]
GW --> Meet["Meetings :3099\nJitsi JWT · Tokens"]
Appt --> PG1[("PostgreSQL")]
Auth --> PG2[("PostgreSQL")]
People --> PG3[("PostgreSQL\n+ MongoDB")]
Files --> S3[("S3-compatible")]
GW --> Redis[("Redis\nCache · Rate limit")]
El gateway centraliza CORS, Helmet, Sentry y rate limiting. Los servicios validan el JWT internamente ademas — si el gateway cae o rota el secreto, cada servicio tiene su propia capa de validacion.
El dominio mas complejo: citas medicas
El servicio appointments termino siendo el mas grande del proyecto — 21k lineas, 16 modulos internos.
Por que tanta complejidad:
Disponibilidad de medicos. Cada medico tiene ventanas de atencion configuradas por dia y hora. El algoritmo de slots disponibles tiene que calcular interseccion de ventana horaria del medico, duracion de consulta, citas ya agendadas, y feriados. Todo en UTC, renderizado en la zona horaria del usuario.
State machine de citas. Una cita pasa por estados: pending → confirmed → in_progress → completed | cancelled. Cada transicion tiene reglas — solo el medico puede pasar a in_progress, solo el sistema puede cancelar automaticamente si no hay confirmacion antes de X minutos.
RIPS. El reporte para el Ministerio de Salud colombiano requiere estructura especifica: datos del prestador, datos del paciente, codigos CIE-10, procedimientos. Implementado como exportacion XLS/CSV/TXT con filtros por fecha y validacion de campos obligatorios.
Autenticacion — ADR-004
El primer despliegue a produccion tenia un bug critico: las sesiones expiraban cada 15 minutos. Los medicos perdian la sesion en mitad de una consulta.
El problema era que el JWT tenia TTL de 15 minutos (seguia un template generico sin pensar en el caso de uso). Para telemedicina, una consulta puede durar 30-45 minutos facilmente.
Decision (ADR-004): tokens de 8 horas con refresh automatico cuando quedan menos de 15 minutos para expirar.
Token emitido → 8h TTL
↓
threshold: 7h 45min
↓
cliente detecta remaining < 15min
↓
POST /auth/refresh → nuevo token
El trade-off aceptado: si un token se compromete, la ventana de exposicion es mas larga. La mitigacion es HTTPS obligado, sin tokens en localStorage (httpOnly cookies), y JWT_SECRET rotable sin downtime via rolling restart.
Servicio de archivos — por que FastAPI y no Node
El servicio de archivos esta en Python, no en Node. Esa fue una decision deliberada.
Necesitaba:
- Generacion de PDFs con layout clinico preciso (campos, tablas, margenes regulados)
- Firma digital con QR embebido
- Integracion con S3
Las librerias Python para esto (reportlab, PyPDF2, qrcode) son mas maduras y tienen mejor soporte para los casos de uso especificos de documentos clinicos que las equivalentes Node. El costo fue mantener dos runtimes — lo acepto porque el servicio tiene responsabilidad clara y aislada.
El servicio expone endpoints REST, el gateway lo ruta igual que al resto, y la autenticacion es el mismo JWT.
Notificaciones — adapter pattern
El servicio de notificaciones necesitaba soportar tres canales con comportamiento distinto: email (SendGrid), SMS (Twilio), WhatsApp.
Implemente un ProviderFactory: segun el canal requerido, instancia el adapter correspondiente. Cada adapter implementa la misma interfaz (send(recipient, template, vars)). Los templates estan en base de datos con variables interpolables.
Beneficio directo: agregar un cuarto canal (push notifications, por ejemplo) no toca la logica de negocio — solo agrega un nuevo adapter.
Videollamadas — Jitsi + JWT
Jitsi tiene autenticacion via JWT — el servidor valida que el token este firmado con el secreto correcto antes de permitir entrar a la sala.
El problema que aparecio en produccion: el TTL del token Jitsi estaba en 120 segundos (2 minutos). Las llamadas se cortaban automaticamente a los 2 minutos porque el token expiraba.
La solucion fue aumentar el TTL a 1800 segundos (30 minutos) y asignar rol moderador automaticamente al medico. El paciente no puede iniciar la sala — solo unirse cuando el medico ya esta adentro.
Infraestructura y CI/CD
Todo el sistema corre en DigitalOcean con Docker. Cada servicio tiene su Dockerfile multi-stage (build + runtime) y su entrada en docker-compose.yml.
El CI/CD es GitHub Actions con deploy por SSH:
push to main
→ SSH to droplet
→ git pull
→ make down && make clean && make upd
→ Docker rebuild y restart
Simple, predecible, sin Kubernetes. Para el tamano de este proyecto, sobre-ingenierizarlo con K8s hubiera sido agregar complejidad sin beneficio real.
Los backups de PostgreSQL estan automatizados con scripts hacia S3. Los secretos viven en GitHub Secrets para CI y en .env files por ambiente (dev/qa/prod) para runtime.
Observabilidad
- Sentry en todos los servicios Node y Angular para error tracking
- Winston con JSON estructurado para logs de aplicacion
- Morgan en el gateway para logs HTTP
- Servicio de logs centralizado interno para audit trail de acciones medicas
Lo que falta y reconozco como deuda: OpenTelemetry para trazas distribuidas entre servicios. Con 18 servicios, cuando un request falla es difícil seguir el hilo completo sin traces correlacionados.
Numeros finales
| Metrica | Valor |
|---|---|
| Servicios | 18 (13 APIs + 1 web + libs) |
| LOC totales | ~50,000+ |
| Endpoints | ~150+ |
| Bases de datos | PostgreSQL + MongoDB + Redis |
| Roles RBAC | Admin · Medico · Paciente · Centro |
| Canales notificacion | Email · SMS · WhatsApp |
Lo que aprendí
Disenar para un dominio regulado fuerza decisiones que en otros proyectos puedes postergar: manejo de datos sensibles en logs, audit trails, exportaciones con formato especifico. Es un buen ejercicio porque te obliga a pensar en el negocio real, no solo en el stack.
El hibrido Node + Python funciona bien cuando los boundaries estan claros. El problema surge si los equipos no entienden por que existe cada runtime — termine documentando la decision como ADR para que no hubiera confusion.
Y lo mas importante: los bugs en produccion que mas duelen no son los crashes, son los silenciosos — una cita que aparece en el mes equivocado, una sesion que expira en mitad de una consulta. El testing de dominio vale mas que el coverage de unidades.