Cómo refactoré el código más crítico de mi SaaS sin romperlo —construyendo la red de tests durante el refactor
Toda plataforma que crece tiene un archivo que nadie quiere tocar: el más viejo, el más enredado y, casualmente, el más importante. En TrabaJIA —mi plataforma multi-empresa de flotas, activos retornables y logística— ese archivo es el motor de inventario. Cuantas más operaciones reales dependen de él, más caro sale equivocarse y más miedo da modificarlo.
Así que dejé de agregar features por un rato y me senté a hacer algo menos glamoroso pero que define si una plataforma envejece bien o mal: endurecer. Este es el lote que salió a producción. Cuatro frentes, una sola obsesión: que el rincón más delicado del código se pueda tocar sin que tiemble el resto.
El valor de un sistema no está en lo que hace hoy, sino en lo barato que sea cambiarlo mañana sin romper lo de ayer.
El frente más difícil: refactorizar el motor de inventario sin red de tests
El corazón del módulo de activos retornables es un solo archivo: app/api/activos/movimientos/route.ts. 664 líneas. Ahí adentro vivía, todo mezclado, lo más delicado del producto:
- La idempotencia (que mandar el mismo movimiento dos veces no duplique nada).
- Las reglas de flujo (qué transición de estado es válida y cuál no).
- Las devoluciones con split (devolver parcial, partir un lote).
- El manejo de seriales.
- El censo de apertura (el conteo inicial).
- La firma + hash de la remisión.
Todo eso, en un único endpoint, sin un solo test. Es el código que más miedo da tocar —porque si se rompe, no se nota en la pantalla: se nota tres semanas después, cuando los números del inventario de un cliente no cuadran y nadie sabe por qué.
El problema clásico: no tenía tests, y para escribir tests con confianza primero conviene tener el código ordenado… pero ordenarlo sin tests es exactamente lo peligroso. El huevo y la gallina del refactor de legacy.
La decisión: extraer la lógica pura y construir la red durante el refactor
Lo que hice fue separar dos cosas que estaban casadas a la fuerza:
- La orquestación (recibir el request, abrir transacción, leer/escribir en Postgres, responder) — eso se queda en el route.
- Las decisiones (¿esta transición es válida?, ¿este movimiento ya se procesó?, ¿cómo se parte esta devolución?, ¿qué hash le corresponde a esta remisión?) — eso es lógica pura: entra data, sale data, no toca la base.
Saqué toda la lógica pura a lib/activos/movimientos-logic.ts. El route quedó como un orquestador delgado: pide decisiones a las funciones puras y se encarga solo de hablar con la base de datos.
¿Y la red de tests? La construí sobre las funciones puras, a medida que las extraía. Cada vez que sacaba una regla del route, le escribía sus casos. Como ya no dependían de la base de datos, eran rápidas, deterministas y exhaustivas: pruebas el split de una devolución sin levantar Postgres. Terminé con 39 tests sobre el módulo de lógica.
El truco mental fue ese: no escribí tests para el código viejo y enredado; escribí tests para el código nuevo y limpio, mientras nacía. La extracción y la red crecieron juntas.
Y un requisito innegociable: comportamiento idéntico. No es una reescritura, es un refactor behavior-preserving. Lo verifiqué con smoke tests contra el endpoint real: misma entrada, misma salida que antes. Nadie debía notar el cambio —ese es el punto.
Por qué NO hice lo obvio
Acá está la parte que la mayoría haría distinto, y por qué no la elegí.
No esperé a "tener tests antes de refactorizar". El consejo de manual es "nunca refactorices sin red de tests". Suena prudente, pero aplicado al pie de la letra te paraliza: escribir tests a través de un route de 664 líneas que abre transacciones y pega contra Postgres es lento, frágil y termina probando el cableado en vez de la lógica. La jugada fue extraer primero la lógica pura —un movimiento mecánico y de bajo riesgo, porque solo mueves código sin cambiarlo— y recién ahí poner la red, donde sí es barata y vale. La red no fue un prerrequisito: fue un producto del refactor.
No reescribí desde cero. La tentación con 664 líneas feas es tirarlas y empezar de nuevo. Es la decisión más cara y más riesgosa que existe: ese archivo tiene años de reglas de negocio reales —casos borde que aprendí a los golpes en operaciones de verdad— y una reescritura los pierde silenciosamente. Un refactor que preserva el comportamiento conserva ese conocimiento acumulado; una reescritura lo bota a la basura y lo redescubre con bugs en producción.
Los otros tres frentes del hardening
El refactor fue el más jugoso, pero la confiabilidad se gana en los detalles. Tres más:
1. Una fuga cross-tenant que era el único olvido. En un SaaS multi-empresa, cada endpoint que toca un recurso tiene que verificar que ese recurso sea de tu empresa. Encontré que POST /api/ots (crear una orden de trabajo) no validaba que el vehículo fuera de la empresa del usuario. Todas las rutas hermanas sí lo hacían —era el único olvido—, así que un usuario podía crear una OT apuntando al vehículo de otra empresa. Lo cerré con el mismo patrón de candado id_empresa que ya usaba el resto. La lección de multi-tenant: el aislamiento no es una capa global mágica, es una verificación por endpoint, y alcanza con que falte en uno solo para que el modelo se rompa.
2. El leak de errores de Postgres al navegador. Encontré 38 rutas que, cuando algo fallaba, devolvían el e.message crudo de Postgres directo al cliente. Eso filtra nombres de tablas, de columnas, de constraints —un mapa gratis de tu esquema para cualquiera que mire la consola del navegador. Hice un helper, respuestaError, que loguea el detalle del lado servidor (con redacción de secretos) y al usuario le devuelve un mensaje genérico. Lo apliqué a 23 archivos. El servidor sigue viendo todo para depurar; el navegador no ve nada que no deba.
3. Concurrencia: cerrar las carreras. Dos puntos perdían carreras bajo carga:
- La numeración de OT podía dar números duplicados si dos requests llegaban juntos. Le agregué un lock
FOR UPDATEque serializa la numeración por empresa —el mismo patrón que ya tenían las remisiones. - El POST/DELETE de kilometraje hacía un
INSERTy unUPDATEsueltos. Si dos lecturas de odómetro entraban a la vez, el recálculo se pisaba. Lo volví transaccional y atómico, con lock del vehículo, para que ningún recálculo pierda la carrera.
La regla acá: en multi-tenant serializas por la unidad de negocio (la empresa, el vehículo), no globalmente. Lockear de más mata la concurrencia entre clientes que no se molestan entre sí.
El resultado
- El motor de inventario pasó de un monolito de 664 líneas sin tests a un orquestador delgado + un módulo de lógica pura con 39 tests, con comportamiento verificado idéntico.
- Cerrado el último hueco cross-tenant en creación de OTs.
- 38 rutas dejaron de filtrar el esquema de la base; 23 archivos pasados al helper de errores.
- Dos carreras de concurrencia eliminadas con locks por empresa/vehículo.
Nada de esto se ve en la pantalla. Todo se ve el día que hay que cambiar algo —o el día que algo falla a las 3 de la mañana. Que es justamente el punto.
Lo que aprendí
El hardening no se trata de tapar agujeros a último momento. Se trata de demostrar disciplina: que el rincón más feo del código —el motor de inventario— se puede tocar sin que tiemble la plataforma, porque ahora tiene una red abajo. Esa es la diferencia entre un código que puedes hacer crecer y uno que te tiene de rehén. No "no tiene bugs" (eso no existe), sino "esto se puede mantener sin rezar".
Y el aprendizaje técnico que me llevo, el que aplica a cualquier código legacy crítico:
No esperes a tener tests para refactorizar el código que te da miedo. Extrae la lógica pura primero —eso es seguro porque no la cambias— y construye la red de tests sobre ella mientras la sacas. La red no es el requisito del refactor: es su primer producto.
Preguntas frecuentes
¿Cómo refactoro código legacy crítico que no tiene tests, sin romperlo?
Separa la lógica pura (decisiones que solo dependen de la entrada) de la orquestación (base de datos, I/O). Extraer la lógica pura es de bajo riesgo porque solo mueves código, no lo cambias. Una vez extraída, escribirle tests es barato y determinista —no necesitas levantar la base. La red de seguridad se construye durante el refactor, no antes.
¿Conviene reescribir desde cero un archivo monolítico y feo?
Casi nunca, si tiene años de reglas de negocio reales. Esas reglas incluyen casos borde aprendidos a los golpes que una reescritura pierde silenciosamente. Un refactor que preserva el comportamiento conserva ese conocimiento; una reescritura lo redescubre con bugs en producción. Reescribe solo si el comportamiento actual ya está mal.
¿Cómo evito fugas de datos entre empresas en un SaaS multi-tenant?
El aislamiento no es una capa global: es una verificación por endpoint de que el recurso pertenece a la empresa del usuario (un candado por id_empresa o equivalente). Alcanza con que falte en un solo endpoint para romper el modelo. Audita ruta por ruta, especialmente las de creación, que son fáciles de olvidar.
¿Por qué no devolver el mensaje de error de la base de datos al usuario?
Porque filtra el esquema —nombres de tablas, columnas y constraints—, que es un mapa de tu base para un atacante. Registra el detalle del lado servidor (redactando secretos) y devuelve al cliente un mensaje genérico. El equipo depura con el log; el navegador no ve nada sensible.
¿Cómo manejo concurrencia en operaciones críticas de un SaaS multi-tenant?
Haz atómicas las operaciones que hoy son varios pasos sueltos (envuélvelas en una transacción) y serializa con locks (FOR UPDATE) por la unidad de negocio —la empresa, el vehículo—, no globalmente. Lockear de más mata la concurrencia entre clientes que no compiten entre sí.
Soy Miller Millán, fundador de TrabaJIA, una plataforma multi-empresa de gestión de flotas, activos retornables y logística. Construyo software real para operaciones reales —y lo endurezco para que crezca sin romperse. ¿Estás resolviendo algo parecido? Escríbeme.