Refactorizar código legacy con IA: el método SDD en brownfield
El fichero se llamaba pricing.ts, tenía 1.100 líneas y un comentario en la línea 3 que decía // NO TOCAR — hablar con Javi antes. Javi se había ido de la empresa en 2021.
Cero tests. Cero documentación. Y toda la facturación pasando por ahí.
Hice lo que hace todo el mundo la primera vez que intenta refactorizar código legacy con IA: se lo pegué entero a Claude Code y le pedí que lo dejara limpio. Me devolvió algo precioso. Funciones puras, nombres decentes, 300 líneas en vez de 1.100.
Y roto para los pedidos que acumulaban cupón y descuento de socio a la vez.
El agente no alucinó nada. Hizo exactamente lo que le pedí. Le pedí arreglar un código cuya intención nadie le había explicado, porque nadie la sabía.
Esa es la tesis de este post: en legacy la spec no describe la feature que quieres, describe el comportamiento que ya tienes. Y por eso el primer artefacto no es spec.md, son los tests de caracterización.
Por qué refactorizar código legacy con IA falla sin tests
Refactorizar código legacy con IA usando SDD consiste en invertir el ciclo habitual: primero tests de caracterización que congelan el comportamiento observable, después una spec que documenta lo que el sistema ya hace, y solo entonces plan y tasks.
El motivo es simple. Un modelo lee código y ve perfectamente qué hace. Lo que no puede ver es qué debería hacer.
En un proyecto nuevo eso da igual, porque la intención está en tu cabeza y la escribes tú. Es lo que hacemos cuando arrancamos un greenfield con slices verticales: la spec va delante porque describe algo que todavía no existe.
En legacy la intención está enterrada bajo seis años de parches de viernes por la tarde. Y ahí aparece el problema real: ningún modelo distingue una regla de negocio rara de un bug que lleva años tolerándose.
En mi pricing.ts había un Math.floor donde cualquiera pondría Math.round. Claude lo "arregló". Llevaba ahí desde 2019 porque el departamento financiero quería redondear siempre a favor del cliente.
Eso no es un bug. Es un requisito no escrito. Y el agente no tenía forma humana de saberlo.
Los antipatrones de este escenario los desarrollé en los 5 errores fatales al refactorizar legacy con IA, así que no los repito. El método positivo empieza invirtiendo el orden.
| Spec greenfield | Spec brownfield | |
|---|---|---|
| Qué describe | Lo que quieres construir | Lo que ya hace el sistema |
| Fuente de verdad | Tu criterio de producto | El código en producción |
| Primer artefacto | spec.md |
Tests de caracterización |
| Criterio de éxito | Cumple los casos de uso nuevos | No cambia ninguna salida observable |
| Ambigüedad | Se resuelve preguntando | Se resuelve ejecutando |
| Riesgo principal | Construir lo que no toca | Romper lo que ya funcionaba |
En greenfield el ciclo es spec → plan → tasks → código. En brownfield es tests → spec → plan → tasks → código. La spec sigue existiendo, pero llega en segundo lugar: hasta que no ejecutas el módulo no sabes qué escribir en ella.
Paso 0 — Acota el blast radius antes de abrir el editor
La regla que más refactors me ha salvado: si no puedes escribir en una línea qué NO vas a tocar, no empieces.
Escribe estas cuatro cosas antes de nada:
- Dentro:
src/pricing.tsy sus dos helpers. - Fuera: el modelo de datos, los endpoints, la UI de checkout.
- Consumidores: quién importa esto. Lanza un
rgsobre el repo y pega la lista tal cual. - Contrato público: las funciones exportadas que otros usan. Esas firmas no se tocan.
Ese último punto es el que hace el trabajo acotable: si la frontera del módulo se mueve, ya no es un refactor, es un rediseño.
Paso 1 — Arqueología asistida: el agente lee, no escribe
Aquí Claude Code es brutalmente bueno, y es la parte que casi nadie usa. El agente tiene prohibido cambiar una sola línea.
El prompt que uso, más o menos literal:
Lee src/pricing.ts. No propongas mejoras ni refactorices nada.
Produce docs/legacy/pricing-observado.md con:
1. Cada rama de decisión del módulo, con la condición exacta que la activa.
2. Las entradas: tipos reales, no los declarados. Marca los que en la práctica
llegan como null o undefined.
3. Las salidas: forma del retorno en cada rama.
4. Efectos secundarios: I/O, escrituras, logs, mutación de argumentos, lecturas
de Date/Math.random o de variables globales.
5. Una sección "Comportamientos sospechosos": cosas que parecen bugs.
NO las arregles. Solo lístalas con número de línea.
6. Una sección "Preguntas que no puedo responder leyendo el código".
Las secciones 5 y 6 son el oro: una lista lo que el agente habría "arreglado" solo, la otra lo que tienes que ir a preguntarle a un humano o a los logs de producción.
En pricing.ts la sección 6 tenía nueve preguntas. Siete las resolví mirando datos reales. Dos las resolvió el responsable de facturación en cinco minutos. Ese día no escribí código y fue el día más productivo del refactor.
Paso 2 — Tests de caracterización: congela el comportamiento, incluso el feo
Un test de caracterización no comprueba que el código sea correcto. Comprueba que sigue haciendo lo mismo. En TDD el test va delante y define lo deseable; aquí va detrás y define lo existente.
Aunque lo existente sea horrible.
// pricing.characterization.test.ts
import { describe, it, expect } from 39;vitest39;
import { calcularPrecioFinal } from 39;../src/pricing39;
// Casos capturados de pedidos reales de producción, anonimizados.
const CASOS = [
{ nombre: 39;base sin descuentos39;, pedido: { subtotal: 100, cupon: null, pais: 39;ES39;, socio: false } },
{ nombre: 39;cupon y socio acumulados39;, pedido: { subtotal: 100, cupon: 39;VIP1039;, pais: 39;ES39;, socio: true } },
{ nombre: 39;cupon caducado39;, pedido: { subtotal: 100, cupon: 39;OLD2039;, pais: 39;ES39;, socio: false } },
{ nombre: 39;pais sin IVA39;, pedido: { subtotal: 100, cupon: null, pais: 39;US39;, socio: false } },
{ nombre: 39;decimales feos39;, pedido: { subtotal: 1234.56, cupon: 39;VIP1039;, pais: 39;ES39;, socio: true } },
{ nombre: 39;subtotal cero39;, pedido: { subtotal: 0, cupon: 39;VIP1039;, pais: 39;ES39;, socio: true } },
] as const
// CONGELADO: el caso 'decimales feos' devuelve un céntimo de menos por el
// Math.floor de pricing.ts:412. Se arregla DESPUÉS del refactor, en un
// commit propio. Ver LEG-14.
describe(39;calcularPrecioFinal — caracterización39;, () => {
it.each(CASOS)(39;$nombre39;, ({ pedido }) => {
expect(calcularPrecioFinal(pedido)).toMatchSnapshot()
})
})
Fíjate en lo que no hay: ningún valor esperado escrito a mano. El snapshot lo genera la primera ejecución. Tú no decides la salida correcta, la registras.
El término viene de Working Effectively with Legacy Code (Michael Feathers, 2004), y en Vitest 5 lo implementas con toMatchSnapshot().
Después abres el fichero de snapshots y lo lees entero. Ahí aparecen las sorpresas y ahí apuntas los // CONGELADO:. Cada uno es un ticket futuro, no una excusa para tocar nada ahora.
Y sí, congelas el bug a propósito. Si arreglas comportamiento y estructura en el mismo commit, cuando algo falle en producción no sabrás cuál de las dos cosas lo rompió.
Paso 3 — La spec brownfield
Ahora, y solo ahora, escribes la spec. Con los tests en verde delante deja de ser un ejercicio de memoria, y las secciones que importan no son las de un proyecto nuevo:
# Spec — Refactor de pricing
## Comportamiento observado
Documentado en docs/legacy/pricing-observado.md.
Congelado en pricing.characterization.test.ts (6 casos).
## Contrato público (NO cambia)
calcularPrecioFinal(pedido: Pedido): Precio
- Devuelve `total` en céntimos como number. No se migra a bigint en este refactor.
- Nunca lanza: ante entrada inválida devuelve { total: 0, error: string }.
## Efectos secundarios actuales
- Escribe en la tabla pricing_audit. SE MANTIENE.
- Lee process.env.TAX_MODE en caliente. SE MANTIENE, se aísla en config.ts.
- Muta el objeto `pedido` recibido. SE ELIMINA: ningún consumidor depende de
ello, verificado en los 4 call sites.
## Deuda congelada a propósito
- LEG-14: redondeo con Math.floor en la línea 412.
- LEG-15: cupón caducado devuelve descuento 0 en vez de error.
## Fuera de alcance
Modelo de datos, endpoints, UI de checkout, migración a bigint.
## Criterio de aceptación
Los 6 tests de caracterización pasan sin modificar sus snapshots.
El test de equivalencia legacy/refactor pasa en las 72 combinaciones.
Es corta a propósito. Y es lo que le das al agente en cada task, no el fichero de 1.100 líneas.
El formato completo lo tienes en el libro de Spec-Driven Development. Para el esqueleto uso el skill dominicode-sdd-creator, que genera spec.md + plan.md + tasks.md; el contenido brownfield lo pones tú, porque sale de los tests.
Si dudas de cuánta ceremonia merece el módulo, el criterio está en los tres niveles de SDD. Un refactor de legacy con dinero de por medio es nivel alto, sin discusión.
Paso 4 — Plan por fases, tasks pequeñas, un commit verde cada una
El plan de un refactor brownfield tiene siempre la misma forma:
- Aislar. Extraer funciones puras sin cambiar la lógica. Copiar, no reescribir.
- Tipar los bordes. Con los tipos reales del paso 1, no los declarados.
- Sustituir por partes. La implementación nueva convive con la vieja mientras dure.
- Borrar el legacy. Cuando la equivalencia lleve dos semanas en verde.
La fase 3 es la que necesita andamio. Copia el original a pricing.legacy.ts, deja pricing.ts para la implementación nueva, y este es todo el andamio:
// pricing.equivalence.test.ts
import { describe, it, expect } from 39;vitest39;
import { calcularPrecioFinal as legacy } from 39;../src/pricing.legacy39;
import { calcularPrecioFinal as refactor } from 39;../src/pricing39;
const subtotales = [0, 9.99, 100, 1234.56]
const cupones = [null, 39;VIP1039;, 39;OLD2039;]
const paises = [39;ES39;, 39;US39;, 39;DE39;]
const socios = [true, false]
describe(39;legacy vs refactor — equivalencia39;, () => {
for (const subtotal of subtotales) {
for (const cupon of cupones) {
for (const pais of paises) {
for (const socio of socios) {
const pedido = { subtotal, cupon, pais, socio }
it(`${subtotal} / ${cupon ?? 'sin cupon'} / ${pais} / socio=${socio}`, () => {
expect(refactor(pedido)).toEqual(legacy(pedido))
})
}
}
}
}
})
72 combinaciones que el agente ejecuta solo cada vez que cierra una task. Y ojo: si el test sale intermitente no tienes un problema de refactor, tienes un Date.now() o un Math.random() sin inyectar. Arréglalo antes de seguir.
Regla de tamaño de task: si el diff no lo puedes leer entero en diez minutos, pártela. El límite no lo pone el agente, lo pone tu capacidad de revisar lo que produjo — que es el verdadero cuello de botella de trabajar con agentes.
Paso 5 — Qué haces cuando un test se pone rojo
Un test de caracterización en rojo tiene tres causas. Míralas en este orden.
Uno: el refactor rompió algo. Nueve de cada diez veces, por mi experiencia. Revierte la task, no la parchees: el diff es pequeño precisamente para que revertir sea barato.
Dos: el refactor arregló un bug sin querer. Pasa más de lo que parece y es una trampa. Revierte igual y arréglalo en su propio commit, con su snapshot actualizado. Un cambio de comportamiento colado dentro de un refactor pasa desapercibido en la review casi siempre.
Tres: el test no era determinista. Fechas, aleatoriedad, orden de un Object.keys, zona horaria. Eso no es caracterización, es ruido. Arréglalo en el test o inyecta la dependencia.
La regla que resume el paso 5 entero: un refactor nunca cambia comportamiento, y un cambio de comportamiento nunca se llama refactor. Commits distintos, PRs distintas, riesgos distintos.
Este bucle es el mismo que aplico en TDD potenciado por IA, solo que en legacy los tests no los escribes para diseñar: los escribes para tener permiso a tocar.
Cómo empezar a refactorizar legacy con Claude Code el lunes
Coge el fichero que todo el mundo evita en tu repo. No lo refactorices. Haz solo esto, y no tardas más de una hora.
Escribe en una línea qué entra y qué queda fuera. Lanza a Claude Code el prompt de arqueología del paso 1 en modo lectura. Y escribe cinco tests de caracterización con los casos que ya te sabes de memoria, porque son los que se rompen cada trimestre.
El lunes no refactorizas nada. El martes ya puedes, y con red.
Cuando quieras montar la verificación en serio — el AGENTS.md, los carriles del agente y los criterios que se comprueban solos — está en el ebook gratuito de Revisión por Contrato. Y el ciclo completo de idea a producto con Claude Code ejecutando tasks es el recorrido del curso Construye con IA.
El código legacy no da miedo por antiguo. Da miedo porque no sabes qué hace. Y eso se arregla escribiendo tests, no reescribiendo código.
Preguntas frecuentes
¿Qué es un test de caracterización y en qué se diferencia de un test unitario normal?
Un test unitario afirma que el código hace lo correcto. Un test de caracterización afirma que sigue haciendo lo mismo que antes, sea correcto o no. No lo escribes a mano: ejecutas el módulo con entradas reales y registras la salida en un snapshot. Su único trabajo es ponerse rojo cuando el refactor cambia una salida observable.
¿Merece la pena congelar un comportamiento que sé que es un bug?
Sí, siempre. Si arreglas el bug en el mismo commit en el que reestructuras el código y algo revienta en producción, no podrás distinguir cuál de las dos cosas lo rompió. Congélalo con un comentario que explique la sospecha y su ticket, y arréglalo después en un commit propio donde el cambio de snapshot sea la parte visible de la pull request.
¿Cuánto código legacy le puedo dar a Claude Code de una vez?
Menos del que cabe. El límite útil no es la ventana de contexto, es lo que tú puedes verificar después. Yo trabajo módulo a módulo y en cada task le paso la spec brownfield y los tests, no el fichero original. Una vez documentado el comportamiento en el paso 1, ese documento sustituye al código fuente como contexto.
¿Puedo saltarme los tests de caracterización si el módulo ya está tipado con TypeScript estricto?
No. Los tipos garantizan la forma del dato, no el valor. Un refactor que cambia Math.floor por Math.round, que invierte el orden de dos descuentos o que redondea antes en vez de después compila perfecto, pasa el type-check y factura mal. Los tipos protegen el contrato; los tests de caracterización protegen el comportamiento.
¿Y si el módulo legacy no se puede ejecutar de forma aislada?
Entonces esa es tu primera task, y no es refactorizar. Si no puedes invocar la función sin levantar media aplicación, lo que falta es una costura: inyectar la base de datos, el reloj y las llamadas HTTP para poder ejecutarla con entradas controladas. Feathers lo llama seam. Hasta que no consigues ejecutar el módulo con entradas que tú decides, no hay tests de caracterización posibles ni refactor seguro.
¿Sirve este método si el módulo legacy no está en TypeScript?
Sí, el orden no cambia. Lo único que necesitas es un runner con snapshots: pytest con syrupy en Python, ApprovalTests en Java o C#, o el propio Vitest si es JavaScript sin tipar. Lo que sí cambia es el paso de tipar los bordes: sin tipos estáticos pierdes la red del compilador y el peso recae entero sobre los tests de caracterización, así que conviene capturar más casos de los que capturarías en TypeScript.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
