5 min de lectura

Refactorizar una app en producción sin romperla: el método

Cómo mover un proyecto grande a una arquitectura por capas, fusionar meses de trabajo divergente y desplegarlo en producción sin incidencias. Con el orden exacto de los pasos.

Capas de una aplicación con bloques de código moviéndose entre ellas

Toda aplicación que dura acaba teniendo el mismo problema: la lógica de negocio vive en las vistas, las vistas llaman al ORM directamente y nadie se atreve a tocar nada. La semana pasada cerré una refactorización de meses sobre una aplicación en producción: reorganización por capas, fusión de dos líneas de desarrollo divergentes y despliegue final sin incidencias. Este es el orden que seguí, porque el orden es lo que hace que no se rompa nada.

1. Nunca refactorices sin tests que te cubran

Antes de mover una sola línea, necesitas tests de caracterización: pruebas que fijen el comportamiento actual, aunque el código sea feo. No buscan corrección, buscan “que siga haciendo lo mismo”.

# Congela el comportamiento de las zonas que vas a tocar
pytest apps/facturacion apps/informes -q --create-db

Dos detalles que ahorran disgustos:

  • --create-db (esquema fresco). Una base de tests reutilizada de una ejecución anterior hace fallar tests correctos con errores que parecen del código, y te manda a cazar fantasmas.
  • Ejecuta la suite completa contra el commit anterior cuando algo falla. Si ya fallaba antes, no es una regresión: es deuda. Y hay que decirlo con la prueba delante, no de memoria.

2. Separar en capas, pero por dentro

El patrón que mejor me ha funcionado es el aburrido:

  • selectors/ → consultas a base de datos.
  • services/ → lógica de negocio y cálculos.
  • views/ → orquestación fina: validar entrada, llamar al servicio, devolver respuesta.

Lo importante no es el nombre de las carpetas, sino que el cálculo salga de la vista. En cuanto un cálculo vive en una función propia, se puede probar sin HTTP, sin sesión y sin plantillas. Ahí es donde la refactorización empieza a pagarse sola.

3. Ramas divergentes: comparar remotos, no locales

Si llevas meses con dos líneas de trabajo (la refactorizada y la de funcionalidades), las ramas locales mienten: probablemente están atrasadas. Compara siempre contra el remoto, tras un fetch --prune:

git fetch --all --prune
git rev-list --count origin/main..origin/refactor   # commits que le faltan a main
git cherry origin/main origin/refactor | head       # parches ya aplicados (-) vs nuevos (+)

Y para saber qué ficheros faltan de verdad, a nivel de árbol:

comm -23 <(git ls-tree -r --name-only origin/refactor | sort) \
         <(git ls-tree -r --name-only origin/main | sort)

4. El merge limpio que pierde código (el pitfall que más me mordió)

Un merge puede resolver sin marcar conflicto y aun así tirar un cambio. Ocurre cuando una rama extrajo un bloque a un método y la otra modificó ese mismo bloque: el hunk entrante desaparece dentro de la llamada al método nuevo. Git dice “todo bien” y tú has perdido un arreglo.

Verificación obligatoria tras cada merge, por cada cambio semántico:

  1. grep el nombre del parámetro/método nuevo en el fichero fusionado.
  2. Ojo al caso traicionero: el parámetro existe pero la lógica que lo usa no. Parece portado y no lo está.
  3. git diff origin/<rama-features> HEAD -- <fichero>: lo que salga debe ser estructura, nunca comportamiento que falta.

Un merge que termina “limpio” no demuestra nada. La prueba es buscar el comportamiento, no confiar en el mensaje de git.

5. Fusionar en el orden correcto

El orden que funcionó:

  1. Refactor → línea de funcionalidades (ahí están los conflictos gordos).
  2. Línea unificada → main.
  3. Comprobar si es fast-forward: git merge-base --is-ancestor A B antes de mergear.

Y si hay conflicto en ficheros de cálculo, se resuelve a mano, nunca con --ours/--theirs en bloque. En ficheros de interfaz que se reescribieron junto con sus plantillas y sus rutas, se toma la versión entrante entera: mezclar media interfaz nueva con media vieja es peor que elegir una.

6. Desplegar con red, no con fé

Antes de tocar producción:

  • Copia de la base de datos con verificación de integridad (pg_restore -l sobre el dump, y contar filas de las tablas clave antes y después).
  • Guardar el trabajo local del servidor: si el checkout de producción tiene cambios a mano, haz un parche antes de resetear. Y comprueba si esos cambios ya están en la release.
  • Anotar los identificadores de las imágenes Docker actuales para poder volver atrás.
  • Un modo de mantenimiento real, no un rm a mano de un fichero.
# Antes de desplegar: copia + parche de cambios locales + inventario de imágenes
bash scripts/pre_deploy_backup.sh
docker inspect --format '{{.Image}}' web > imagenes_previas.txt

Y tras el despliegue, un script de verificación que compruebe lo que importa: commit desplegado, migraciones aplicadas sin pendientes (migrate --check), traducciones compiladas, traducciones presentes, petición HTTP real y conteo de filas de las tablas críticas.

7. Limpiar

Cuando la rama ya está integrada, se borra. Pero con criterio:

  • Demuestra que está contenida antes de borrarla: git rev-list --count origin/main..origin/<rama> debe dar 0.
  • Si está checkouteada en un worktree, no la puedes borrar: primero git -C <worktree> checkout --detach <sha>.
  • Deja una rama de seguridad backup/<rama>-pre-merge-<fecha> y decide después si sobra.

Lo que me llevo

La refactorización no fue difícil por el código: fue difícil por la incertidumbre. Cada paso buscaba reducirla: tests que congelan el comportamiento, comparaciones contra el remoto, verificación de cada cambio tras el merge, copias con integridad comprobada y un despliegue con marcha atrás preparada. Si tuviera que resumirlo en una frase: no se trata de escribir código mejor, se trata de no perder nada por el camino.

¿Te ha servido? Puedes suscribirte por RSS o escribirme desde la página de contacto.

Comentarios

Cargando comentarios…