Refactorizar código heredado sin romper nada
Estrategias sistemáticas de refactorización en código heredado: pruebas de caracterización, strangler pattern y extracción incremental sin regresiones.

El código heredado no es simplemente código antiguo. Es código sin pruebas, código que nadie entiende o código que todos temen tocar. El instinto natural es reescribirlo desde cero, pero las reescrituras fracasan más a menudo de lo que tienen éxito, porque descartan años de lógica de negocio acumulada: correcciones de errores, casos límite y requisitos no documentados incrustados en ramas condicionales que ya nadie recuerda haber escrito.
El camino más seguro es la refactorización incremental: envolver lo desconocido en pruebas, extraer las partes comprensibles y reducir la huella del código heredado con el tiempo.
Pruebas de caracterización: entender qué hace realmente el código
Antes de modificar código heredado, necesitas saber qué hace realmente en este momento, no qué se supone que debería hacer. Las pruebas de caracterización capturan el comportamiento existente, incluidos los errores.
// ❌ Writing tests based on what you think the code should do
test("calculateDiscount returns 10% for premium users", () => {
expect(calculateDiscount("premium", 100)).toBe(90);
});
// This test might fail because the actual code has a bug
// that gives premium users 15%. The bug might be a feature
// that sales promised to customers.// ✅ Characterization tests: capture what the code actually does
function characterize(
fn: (...args: unknown[]) => unknown,
inputs: unknown[][]
): void {
for (const args of inputs) {
const result = fn(...args);
console.log(
`${fn.name}(${args.map(a => JSON.stringify(a)).join(", ")}) => ${JSON.stringify(result)}`
);
// Copy these outputs into test assertions
}
}
// Step 1: Run with various inputs and record actual outputs
characterize(calculateDiscount, [
["premium", 100],
["premium", 0],
["standard", 100],
["standard", 50],
["", 100],
[null, 100],
["premium", -10],
]);
// Step 2: Turn recorded outputs into tests
describe("calculateDiscount (characterization)", () => {
test("premium 100 → 85", () => {
expect(calculateDiscount("premium", 100)).toBe(85);
});
test("premium 0 → 0", () => {
expect(calculateDiscount("premium", 0)).toBe(0);
});
test("standard 100 → 95", () => {
expect(calculateDiscount("standard", 100)).toBe(95);
});
test("null tier → 100 (no discount)", () => {
expect(calculateDiscount(null, 100)).toBe(100);
});
test("negative amount → -10 (no guard)", () => {
expect(calculateDiscount("premium", -10)).toBe(-10);
});
});
// These tests document reality, not intentLas pruebas de caracterización funcionan como una red de seguridad. Si alguna refactorización modifica el comportamiento existente, una prueba fallará, avisándote para que investigues si ese cambio de comportamiento es intencional antes de que llegue a producción.
La técnica de las costuras: cómo encontrar puntos seguros para refactorizar
Una costura (seam, en inglés) es un lugar donde puedes alterar el comportamiento sin editar el código en sí. Michael Feathers acuñó este término en "Working Effectively with Legacy Code". Las costuras son tus puntos de entrada para insertar pruebas y extraer lógica.
// Legacy function with embedded dependencies
function processOrder(orderId: string): void {
// Direct database call — hard to test
const order = db.query(
`SELECT * FROM orders WHERE id = '${orderId}'`
);
// Business logic buried in the middle
let total = 0;
for (const item of order.items) {
let price = item.price;
if (item.category === "electronics" && order.memberTier === "gold") {
price = price * 0.9;
}
if (item.quantity > 10) {
price = price * 0.95;
}
total += price * item.quantity;
}
// Direct email service call
emailService.send(order.email, `Your total is $${total}`);
// Direct database update
db.query(
`UPDATE orders SET total = ${total} WHERE id = '${orderId}'`
);
}// Step 1: Extract parameters to create seams
function processOrder(
order: Order,
notify: (email: string, message: string) => void,
save: (orderId: string, total: number) => void
): number {
let total = 0;
for (const item of order.items) {
let price = item.price;
if (item.category === "electronics" && order.memberTier === "gold") {
price = price * 0.9;
}
if (item.quantity > 10) {
price = price * 0.95;
}
total += price * item.quantity;
}
notify(order.email, `Your total is $${total}`);
save(order.id, total);
return total;
}
// Step 2: Now you can test the business logic
test("gold member gets 10% off electronics", () => {
const order: Order = {
id: "1",
email: "test@example.com",
memberTier: "gold",
items: [
{ category: "electronics", price: 100, quantity: 1 },
],
};
const total = processOrder(
order,
() => {}, // stub notification
() => {} // stub persistence
);
expect(total).toBe(90);
});El patrón Extract-Wrap-Delegate
En funciones heredadas grandes, extrae la lógica de negocio a un módulo nuevo y limpio, envuelve el código antiguo para que delegue en ese módulo nuevo, y verifica que el comportamiento coincide.
// Step 1: Extract the pricing logic into a clean module
interface PricingRule {
applies: (item: OrderItem, order: Order) => boolean;
calculate: (price: number) => number;
}
const pricingRules: PricingRule[] = [
{
applies: (item, order) =>
item.category === "electronics" && order.memberTier === "gold",
calculate: (price) => price * 0.9,
},
{
applies: (item) => item.quantity > 10,
calculate: (price) => price * 0.95,
},
];
function calculateOrderTotal(
order: Order,
rules: PricingRule[]
): number {
let total = 0;
for (const item of order.items) {
let price = item.price;
for (const rule of rules) {
if (rule.applies(item, order)) {
price = rule.calculate(price);
}
}
total += price * item.quantity;
}
return total;
}// Step 2: Verify new module matches old behavior
function verifyEquivalence(testCases: Order[]): void {
for (const order of testCases) {
const oldResult = legacyCalculateTotal(order);
const newResult = calculateOrderTotal(order, pricingRules);
if (oldResult !== newResult) {
console.error(
`Mismatch for order ${order.id}: ` +
`legacy=${oldResult}, new=${newResult}`
);
}
}
}
// Step 3: Deploy behind a feature flag
function getOrderTotal(order: Order): number {
if (featureFlags.isEnabled("new-pricing-engine")) {
return calculateOrderTotal(order, pricingRules);
}
return legacyCalculateTotal(order);
}Estrangulamiento incremental de módulos heredados
Para esfuerzos de refactorización más grandes, el strangler fig pattern reemplaza gradualmente los módulos heredados: enruta el tráfico nuevo hacia el código nuevo, mientras el código heredado sigue gestionando las rutas existentes.
interface MigrationTracker {
module: string;
totalEndpoints: number;
migratedEndpoints: number;
legacyEndpoints: string[];
migratedOn: Map<string, Date>;
}
class StranglerRouter {
private migrated: Set<string> = new Set();
private tracker: MigrationTracker;
constructor(module: string, totalEndpoints: number) {
this.tracker = {
module,
totalEndpoints,
migratedEndpoints: 0,
legacyEndpoints: [],
migratedOn: new Map(),
};
}
markMigrated(endpoint: string): void {
this.migrated.add(endpoint);
this.tracker.migratedEndpoints++;
this.tracker.migratedOn.set(endpoint, new Date());
}
route(
endpoint: string,
legacyHandler: () => unknown,
newHandler: () => unknown
): unknown {
if (this.migrated.has(endpoint)) {
return newHandler();
}
return legacyHandler();
}
getProgress(): { percentage: number; remaining: string[] } {
return {
percentage:
(this.tracker.migratedEndpoints / this.tracker.totalEndpoints) * 100,
remaining: this.tracker.legacyEndpoints.filter(
e => !this.migrated.has(e)
),
};
}
}Lista de verificación para una refactorización segura
interface RefactoringStep {
step: string;
verification: string;
rollbackPlan: string;
}
const safeRefactoringProcess: RefactoringStep[] = [
{
step: "Write characterization tests for existing behavior",
verification: "All tests pass against current code",
rollbackPlan: "N/A — no code changes yet",
},
{
step: "Extract testable interfaces (seams)",
verification: "Characterization tests still pass",
rollbackPlan: "Revert extraction commit",
},
{
step: "Write unit tests for extracted logic",
verification: "Unit tests match characterization test behavior",
rollbackPlan: "Delete new tests, keep old code",
},
{
step: "Implement new module alongside legacy",
verification: "Run both, compare outputs for N days",
rollbackPlan: "Feature flag to legacy path",
},
{
step: "Route traffic to new module",
verification: "Monitor error rates, latencies, business metrics",
rollbackPlan: "Feature flag back to legacy",
},
{
step: "Remove legacy code",
verification: "All tests pass, monitoring stable for 2 weeks",
rollbackPlan: "Git revert — legacy code still in history",
},
];Conclusiones clave
Refactorizar código heredado de forma segura consiste en generar confianza mediante pruebas antes de hacer cambios. Empieza con pruebas de caracterización que documenten el comportamiento real, no el comportamiento previsto. Encuentra costuras donde puedas inyectar dobles de prueba y extraer lógica. Usa el patrón Extract-Wrap-Delegate para construir reemplazos limpios junto al código heredado, comparando las salidas en producción antes de hacer el cambio definitivo. Los feature flags te dan un rollback instantáneo cuando algo no coincide. El objetivo no es dejar el código perfecto, sino hacerlo un poco mejor con cada cambio, sin romper nunca la funcionalidad existente. Los equipos que refactorizan con éxito son los que resisten la tentación de reescribir por completo y, en cambio, reducen la superficie de código heredado de forma constante, un módulo extraído a la vez.


