Decisiones técnicas
Las decisiones con su contexto y su motivo, para no revertirlas sin saber qué protegían.
Registro ligero de decisiones (ADR-lite). Cada una explica el contexto, la opción elegida y qué se acepta a cambio.
DEC-001 · Monolito modular, no microservicios
Contexto. Royal Clean son dos propietarios y un puñado de empleados. El sistema tiene que desplegarse en una VPS pequeña y costar poco de operar.
Decisión. Una única aplicación Next.js con dominios separados por carpetas
en src/modules/.
Por qué. Repartir esto en servicios añadiría despliegues, redes internas y consistencia eventual sin resolver ningún problema que este negocio tenga. Además, operaciones como «finalizar servicio» tocan varias tablas a la vez: en un monolito son una transacción; entre servicios serían una saga.
A cambio. Escalar significa escalar todo junto. Para este volumen, irrelevante.
DEC-002 · PostgreSQL con Prisma
Decisión. PostgreSQL 17 y Prisma 7 como ORM.
Por qué. Es preferencia explícita del propietario técnico y encaja bien: PostgreSQL ofrece restricciones CHECK e índices parciales, que aquí sostienen invariantes de negocio. Prisma aporta tipos derivados del esquema y migraciones versionadas.
A cambio. Prisma no expresa todas las restricciones, así que las avanzadas se escriben en SQL manual y quedan documentadas.
DEC-003 · Cloudflare R2 con bucket privado
Decisión. La evidencia vive en R2. El bucket es privado y cada visualización pasa por una URL firmada de 10 minutos.
Por qué. R2 no cobra por transferencia de salida, que es justo lo que más pesaría al mostrar galerías de fotografías. Guardar imágenes en PostgreSQL inflaría los respaldos; guardarlas en el disco del contenedor las perdería en cada redespliegue.
A cambio. Una dependencia externa más. Se mitiga con MinIO en desarrollo, que habla el mismo protocolo S3.
DEC-004 · Sesiones de trabajo, no un contador
Contexto. Hay que registrar cuánto trabaja cada persona en cada servicio.
Decisión. Filas WorkSession con inicio y fin. El total se calcula sumando
intervalos.
Por qué. Un contador mutable haría imposible pausar, reanudar, reabrir o corregir sin perder información, y escribiría en la base cada segundo. Con sesiones, retomar el trabajo simplemente añade una fila y el tiempo previo queda intacto por construcción.
A cambio. Cada lectura suma intervalos. Con decenas de sesiones por servicio, el costo es despreciable.
DEC-005 · Snapshot de tareas
Decisión. Al crear un servicio, las tareas de la plantilla se copian a registros propios de ese servicio.
Por qué. Un reporte de hace seis meses debe describir lo que realmente se pidió aquel día. Si el servicio leyera la plantilla actual, editarla reescribiría la historia de todos los servicios pasados.
A cambio. Datos duplicados. Es exactamente el punto.
DEC-006 · Snapshot de tarifa
Decisión. La tarifa horaria se congela en ServiceAssignment al asignar.
Por qué. Si María pasa de ₡5.000 a ₡5.500/h en octubre, los servicios de agosto no pueden recalcularse: ya se pagaron a la tarifa anterior. Sin el snapshot, cualquier consulta histórica daría cifras distintas cada vez que alguien ajusta una tarifa.
A cambio. Cambiar una tarifa exige entender que sólo aplica hacia adelante. La interfaz lo advierte de forma explícita.
DEC-007 · PWA, no aplicación nativa
Decisión. Aplicación web instalable, con manifiesto e iconos.
Por qué. Una app nativa exigiría dos bases de código, cuentas de desarrollador y ciclos de revisión en tiendas, para un equipo de tres personas. Con la web se instala desde el navegador y se actualiza al recargar.
A cambio. Sin acceso a APIs nativas. La cámara, que es lo único que hace falta, funciona con un input de captura.
DEC-008 · Sesiones opacas en base de datos, no JWT
Contexto. Hay que autenticar y poder expulsar a alguien de inmediato.
Decisión. Tokens aleatorios en una cookie httpOnly; la base guarda su SHA-256.
Por qué. Un JWT sin estado no se puede revocar: desactivar a un empleado
dejaría su token válido hasta que caduque. Aquí, desactivar una cuenta o
restablecer una contraseña corta el acceso en el acto. La comprobación cuesta
una consulta por petición, memoizada con cache() de React.
También se descartó Auth.js: para credenciales propias sin proveedores externos aporta más superficie que valor, y la versión compatible con este stack está en beta.
A cambio. Una consulta por petición y código propio que mantener. Es poco código y está cubierto por tests.
DEC-009 · PDF con @react-pdf/renderer, no Chromium
Decisión. El reporte se genera con @react-pdf/renderer.
Por qué. Generar PDF con un navegador headless añadiría unos 300 MB a la imagen de Docker y varios cientos de MB de RAM por reporte, en una VPS pequeña. Este renderizador corre en el mismo proceso de Node, es determinista y produce el mismo documento siempre.
A cambio. Un subconjunto de CSS más limitado. Para un reporte tabular con fotografías es suficiente.
DEC-010 · PDF bajo demanda, sin almacenar
Decisión. El reporte se genera en cada descarga y no se guarda.
Por qué. Royal Clean genera unos pocos reportes al día y cada uno tarda menos de un segundo. Guardarlos obligaría a versionarlos e invalidarlos cada vez que cambie algo del servicio; generarlos al vuelo garantiza que siempre reflejan el estado actual, sin almacenamiento ni lógica de caducidad.
A cambio. Se recalcula cada vez. Si el volumen crece mucho, se puede cachear sin cambiar la interfaz.
DEC-011 · Dinero en céntimos enteros
Decisión. Todo importe es un entero de céntimos de CRC.
Por qué. 0.1 + 0.2 no es 0.3 en coma flotante. En una nómina esos
errores se acumulan y producen descuadres que nadie sabe explicar. Con enteros
la suma es exacta por construcción. Se prefirió a Decimal porque los enteros
son más rápidos, viajan sin problema al cliente y no arrastran un tipo especial
por todo el código.
A cambio. Hay que convertir en los bordes: al leer de un formulario y al mostrar. Ambas conversiones están centralizadas en un módulo con tests.
DEC-012 · Compresión de imágenes en el navegador
Decisión. La foto se redimensiona y se comprime en el teléfono antes de subirla.
Por qué. Una foto de móvil pesa entre 4 y 12 MB. Subirla entera por una red móvil de Guanacaste para que el servidor la reduzca a 300 KB desperdicia el tiempo y los datos del empleado.
Como efecto secundario deseable, volver a codificar la imagen en un canvas
descarta los metadatos EXIF, incluidas las coordenadas GPS y el modelo del
dispositivo. Royal Clean no rastrea a su personal, así que esos datos no deben
ni salir del teléfono.
A cambio. Depende del navegador. Hay respaldo para los que no soportan WebP.
DEC-013 · Rate limiting en PostgreSQL, sin Redis
Decisión. Los intentos de login se cuentan en una tabla.
Por qué. Royal Clean despliega una sola instancia y PostgreSQL ya está ahí. Un contador en memoria se perdería en cada redespliegue —justo cuando un atacante lo aprovecharía— mientras que la tabla sobrevive a los reinicios y además deja rastro auditable.
A cambio. Una consulta más por intento de login. Irrelevante frente al costo de Argon2.
DEC-014 · Sin tiempo real
Decisión. Nada de websockets. El panel se actualiza al navegar.
Por qué. Un equipo de tres personas no necesita ver los cambios de sus compañeros al instante. Añadir una capa de tiempo real traería infraestructura, reconexiones y estado compartido para resolver un problema que aquí no existe.
A cambio. Los datos pueden tener unos segundos de antigüedad.
DEC-015 · Fuente del sistema, sin Google Fonts
Contexto. El primer build de Docker falló al no poder descargar Inter.
Decisión. Pila de fuentes del sistema.
Por qué. next/font/google descarga la tipografía en tiempo de
compilación. Un build sin salida a Internet —CI restringido, VPS detrás de un
proxy— falla por completo, y un despliegue autohospedado no puede depender de
que Google esté accesible para compilar. Además se ahorra una descarga en la red
móvil del personal y el texto se pinta sin salto de fuente.
A cambio. La tipografía cambia según la plataforma. Si Royal Clean quiere
una propia, se copia el .woff2 al repositorio y se usa next/font/local, que
no necesita red.
DEC-016 · Cliente Prisma perezoso
Contexto. El build de Docker fallaba porque next build importa cada módulo
para recolectar datos de las páginas, y el cliente Prisma se construía al
importarse, exigiendo un DATABASE_URL real.
Decisión. El cliente se crea en el primer uso, detrás de un Proxy.
Por qué. Permite compilar sin secretos —que es lo correcto: el build no
debería necesitarlos— y conserva la ergonomía de import { prisma } sin que
ningún llamador tenga que acordarse de invocar una función.
A cambio. Una indirección. Invisible en el uso diario.
DEC-017 · CLI de Prisma instalada aparte en la imagen
Contexto. El contenedor no podía aplicar migraciones: el node_modules de
pnpm usa enlaces simbólicos al almacén .pnpm, así que copiar sólo la carpeta
prisma dejaba fuera sus dependencias transitivas.
Decisión. Una etapa del Dockerfile instala la CLI con npm —que genera un
árbol plano y autocontenido— y se copia a /app/migrator.
Por qué. Es explícito y reproducible. Copiar el almacén .pnpm entero
habría inflado la imagen; renunciar a migrar al arrancar habría dejado el paso
más delicado del despliegue fuera del contenedor.
A cambio. Unos 250 MB extra en la imagen, porque la CLI arrastra Prisma Studio y su servidor de desarrollo.
Se intentó podarlos —el contenedor sólo ejecuta migrate deploy— pero la CLI de
Prisma 7 los requiere incluso para responder a --version. El paso de
verificación que se añadió al Dockerfile atrapó el fallo durante el build, que
es exactamente donde debía atraparlo. Se acepta el peso a cambio de que el
despliegue aplique las migraciones por sí mismo en lugar de depender de un paso
manual.
DEC-018 · Direccionamiento por ruta en el cliente S3
Contexto. Un test de integración contra MinIO falló con
ENOTFOUND royal-clean-evidence.localhost.
Decisión. forcePathStyle: true en el cliente de S3.
Por qué. El SDK usa por defecto el estilo virtual (bucket.host), que
depende de comodines DNS que no controlamos en el endpoint de R2 y que en
desarrollo produce hosts inexistentes. Con direccionamiento por ruta, el mismo
código funciona contra R2 y contra MinIO sin diferencia.
A cambio. Ninguno relevante: R2 soporta ambos estilos.
DEC-019 · Vista de propiedad en secciones apiladas, no en pestañas
Decisión. La ficha de propiedad muestra datos, plantilla, acceso, historial y cifras en secciones apiladas.
Por qué. Las pestañas esconden información y obligan a más toques, especialmente en móvil, donde los propietarios consultan el negocio. Con secciones, un vistazo y un desplazamiento bastan.
A cambio. La página es larga. Aceptable: se lee de arriba abajo en orden de importancia.
DEC-020 · Kanban con arrastre Y menú de teclado
Decisión. Cada tarjeta del tablero ofrece arrastrar y soltar, y además un selector «Mover a…».
Por qué. El arrastre no funciona con teclado, con lector de pantalla ni bien con el dedo. Ofrecer sólo arrastre habría dejado la función inaccesible para parte de los usuarios. Ambos caminos ejecutan exactamente la misma acción del servidor, que pasa por la máquina de estados.
A cambio. Un control más en cada tarjeta.
DEC-021 — Superadministrador como distintivo, no como tercer rol
Contexto. La especificación fija dos roles y sólo dos: ADMIN y EMPLOYEE (§6). Pero hace falta que una persona concreta —quien administra la aplicación, no el negocio— pueda gestionar las cuentas de todos: crear administradores, restablecer contraseñas, desactivar accesos y cerrar sesiones.
Decisión. Un booleano User.isSuperAdmin, no un valor nuevo en Role.
Por qué. No describe a otra clase de persona: quien lo tiene es un
administrador con una atribución más. Añadir un rol habría obligado a revisar
cada comprobación de role === "ADMIN" del sistema —hay decenas— y cualquier
olvido habría dejado a esa persona fuera de pantallas que sí le corresponden.
El error habría sido silencioso y molesto de diagnosticar.
Qué lo sostiene.
- Restricción en el motor:
CHECK (NOT "isSuperAdmin" OR "role" = 'ADMIN'). Un empleado no puede acabar gestionando cuentas por un error de asignación. requireSuperAdmin()comprueba el rol y el distintivo, de forma redundante a propósito: si alguien relajara la restricción de la base, la guarda seguiría sosteniendo la regla.- Las acciones lo comprueban otra vez contra la base, no contra la sesión: la guarda protege la pantalla, esa segunda comprobación protege la operación aunque alguien invoque la acción por su cuenta.
- Nadie puede actuar sobre su propia cuenta desde el panel. Quitarse el acceso a uno mismo deja el sistema sin quien lo administre, y recuperarlo exige entrar a la base a mano.
Consecuencia. La ruta /admin/cuentas responde 404 a un ADMIN normal,
igual que cualquier otro recurso que no le corresponde, y el enlace no aparece
en su navegación. Ocultarlo no es la protección; es no ofrecer una puerta que no
se puede abrir.