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.
DEC-022 · Aviso de reporte listo, no PDF pre-generado
Contexto. El cliente pidió que el reporte de un servicio se genere y envíe solo al terminar, sin que alguien tenga que acordarse de hacerlo. Se evaluó generar el PDF automáticamente al completar el servicio y guardarlo listo para compartir.
Decisión. No se pre-genera ni se almacena. Al completarse un servicio se
crea una notificación interna (SERVICE_REPORT_READY) para los administradores
activos; el PDF se sigue generando al vuelo en cada descarga, como ya fijaba
DEC-010.
Por qué se descartó la alternativa. Guardar el PDF automáticamente reintroduciría exactamente el problema que DEC-010 evitó: versionarlo e invalidarlo cada vez que algo del servicio cambie después de completado (una corrección de horas, una revisión de insumos). El aviso resuelve lo que el cliente pidió en realidad —que no se le olvide a nadie que el reporte ya se puede pedir— sin duplicar esa lógica.
A cambio. Sigue sin haber envío de correo real (§6 de pendientes): el aviso
es interno, visible en /admin/notificaciones. Si se confirma el envío por
correo, se engancha al mismo evento sin tocar service.ts otra vez.
DEC-023 — El impuesto de una cotización, en puntos básicos y congelado
Contexto. Las cotizaciones llevan IVA. En Costa Rica es el 13 %, y Royal Clean lo pidió así.
Decisión. El tipo se guarda como entero en puntos básicos
(1300 = 13,00 %) en cada cotización, y se aplica sobre el subtotal ya
sumado, nunca línea a línea.
Por qué.
- Entero y no
0.13. Un porcentaje en coma flotante arrastra el mismo error que los importes, contra el que existe la invariante del dinero. Una cotización que no cuadra al sumarla a mano no se le puede enseñar a nadie. - Congelado por cotización y no leído de la configuración. Si el IVA del país cambia, lo que ya se le pasó al cliente debe seguir diciendo lo que dijo (§18). La configuración sólo aporta el valor por defecto al crear una nueva.
- Sobre el subtotal y no por línea. Redondear en cada línea y sumar después separa el total de la comprobación que hace cualquier cliente: subtotal por 13 %. Hay un test que fija justo esa diferencia.
Consecuencia. AppSettings.defaultTaxRateBasisPoints propone;
Quote.taxRateBasisPoints manda. Los tres importes —subtotal, impuesto y
total— se derivan siempre en el servidor y se guardan congelados; el
formulario calcula los mismos números con la misma función pura sólo para
mostrarlos mientras se escribe.
DEC-024 — La variante dark: atada a la clase, no a la preferencia del sistema
Contexto. El tema lo elige la persona con el interruptor, y se aplica
poniendo la clase dark en el <html>; los colores salen de variables CSS
redefinidas bajo .dark. Pero Tailwind v4 resuelve dark: con
prefers-color-scheme, es decir, con la preferencia del sistema operativo.
Decisión. Declarar @custom-variant dark (&:where(.dark, .dark *)) en
globals.css.
Por qué. Sin esa línea las dos cosas se contradicen. Quien tuviera el sistema en oscuro y la aplicación en claro veía la interfaz clara con los fondos de marca en oscuro encima: el elemento activo de la navegación, el avatar, las insignias y el día de hoy en el calendario. Se descubrió al sustituir el logotipo por el real, que tiene una versión clara y otra oscura: en modo claro se mostraba la blanca, invisible sobre la barra lateral.
Consecuencia. Las dieciséis utilidades dark: del sistema siguen ahora al
interruptor, que es lo que su código ya daba por hecho. No hay ninguna que
quiera seguir al sistema operativo: el tema es una elección explícita.
DEC-025 — El horario como imagen se dibuja en SVG y lo rasteriza sharp
Contexto. Royal Clean captura el horario de la pantalla y se lo manda al personal por WhatsApp. Pidieron poder descargarlo como imagen.
Decisión. Se construye un SVG en el servidor y sharp lo convierte a PNG.
Por qué. La alternativa obvia —un navegador headless que fotografíe la
página— costaría unos 300 MB en la imagen de Docker y cientos de MB de RAM por
captura, en una VPS pequeña. Es exactamente el razonamiento de
DEC-009 para el PDF, y la conclusión es la misma. sharp ya es
dependencia del proyecto por la compresión de evidencia, así que no se añade
nada.
Consecuencia. El SVG se rasteriza al doble de densidad para que se lea en la pantalla de un teléfono, que es donde se va a mirar. El diseño se escribe a mano en coordenadas, lo que limita lo elaborado que puede ser: a cambio es determinista y no depende de ningún navegador.
La imagen y el PDF parten de buildScheduleLayout, un módulo puro con tests. Si
cada formato agrupara por su cuenta, el que se manda y el que se imprime
acabarían diciendo cosas distintas.
DEC-026 — Los insumos se importan de un catálogo, copiando sus importes
Contexto. Los insumos se registraban sueltos en cada servicio. «Papel higiénico» se volvía a escribir y a valorar una y otra vez, y acababa costando cosas distintas en servicios del mismo mes. Royal Clean pidió un catálogo como el de tareas.
Decisión. Un SupplyCatalogItem global, editable en /admin/insumos. El
servicio lo importa y lo que se guarda en ServiceSupply es una copia de
nombre, unidad, costo y precio. catalogItemId queda como referencia, pero
ninguna cifra se relee del catálogo.
Por qué. Es la invariante 3 aplicada tal cual: cambiar un precio no puede reescribir el pasado. Apuntar al catálogo habría hecho que subir el precio del café cambiara la rentabilidad de servicios cerrados hace meses, y el desglose de una factura ya emitida dejaría de cuadrar con lo que se cobró.
Consecuencia. Corregir un precio mal puesto no arregla los servicios que ya lo importaron: hay que editarlos. Es el precio de que el histórico no se mueva solo, y es el mismo trato que ya tienen las tareas y las tarifas.
El nombre no lleva @unique en la base. El duplicado entre los activos se
comprueba en createSupplyCatalogItem, normalizando mayúsculas y acentos: así
«Papel Higiénico» y «papel higienico» no son dos productos, el mensaje sale en
español en lugar de un error del motor, y un insumo archivado puede volver a
crearse con su nombre — que con una restricción del motor sería imposible.
DEC-027 — Transporte, supervisión y alimentación entran en el margen
Contexto. El catálogo de tareas sólo estimaba el costo de insumos. El resto de lo que Royal Clean paga por ejecutar una tarea —traslado del personal, tiempo de supervisión, viáticos— no aparecía en ninguna cifra, así que el margen que enseñaba el sistema era mejor que el real.
Decisión. Tres campos más en el catálogo de tareas, congelados en
ServiceTask al crear el servicio y sumados al costo en
calculateServiceFinance.
Por qué. La alternativa era dejarlos como dato informativo, sin tocar el cálculo. Se descartó: un margen que se sabe incompleto y se sigue presentando como definitivo es peor que no tenerlo, porque se usa para decidir precios.
Consecuencia. Son estimaciones conviviendo con gastos medidos, que no es
ideal. Se acepta porque la alternativa es contarlos como cero, que es una
estimación peor. Un valor sin estimar deja las cifras parciales con el motivo
OPERATIONAL_COST_MISSING, de modo que la diferencia entre «no cuesta nada» y
«no lo hemos calculado» sigue siendo visible.
Los servicios anteriores a estos campos no los tienen y calculan lo mismo que antes. Hay un test que lo fija: desplegar esto no puede reescribir el histórico.
TASK_OPERATIONAL_COST_SELECT se comparte entre las cuatro consultas que
calculan rentabilidad. Repetido cuatro veces, bastaba olvidar uno para que una
pantalla diera un margen distinto de las demás sin que nada fallara.