Guía para modificar código legacy

27-05-2025

Por Raúl Padilla Delgado

¡Domina el Código Legacy!

¿Te enfrentas a código legacy que da miedo tocar? En esta guía vamos a ver paso a paso cómo convertir ese código problemático en algo más mantenible. Basándome en técnicas recogidas del legendario libro “Working Effectively with Legacy Code” de Michael Feathers, te mostraré un enfoque para abordar este desafío. Además, comentaré otra serie de herramientas que he ido aprendiendo a medida que me iban siendo útiles en los procesos de refactoring.

El refactoring es una práctica fundamental para mantener el código sostenible. ¿Por qué es crucial dominar el refactoring? Simple:

  • Tu código será más fácil de mantener y modificar
  • Desarrollarás nuevas features más rápido
  • Disminuyes la probabilidad de bugs futuros

Sin embargo, refactorizar puede ser un desafío significativo, sobre todo cuando estamos modificando un código que no tiene test. ¿Cómo podemos garantizar que dicha modificación no ha cambiado comportamiento rompiendo así el funcionamiento actual? Sin tests, estás volando a ciegas. No importa si tu código tiene mil dependencias o llamadas a API externas - siempre hay una manera de testearlo. Los tests no solo te protegen de errores, sino que te ahorran horas de debugging.

Paso 1: Mapea el Terreno

Antes de tocar una sola línea de código, necesitas entender el sistema. Tu primera tarea será ser un detective. Necesitas investigar el código. Si ya existe documentación sobre el código, te será más fácil ir relacionando esa información con el código y te será más fácil entenderlo. Si se da el caso de que no hay documentación (algo más común de lo que me gustaría), el proceso será algo más difícil y enrevesado, pero no imposible. Tómate todo el tiempo que necesites para leer el código e ir documentando el flujo o caso de uso completo por el que transcurre una funcionalidad de la aplicación. Lo ideal es que registres también esas zonas del código que crees que pueden ser mejoradas.

Otra herramienta que te puede venir muy bien para esto es crear diagramas. Aquí te puedes apoyar en los estándares de la industria como diagramas de secuencia y demás, pero incluso unos cuantos cuadrados y flechas pueden ayudarte enormemente a entender el sistema. Una herramienta de pintura estilo Paint o Excalidraw te serán de gran ayuda para empezar a realizar esos primeros bocetos que te ayuden a construir el esquema mental de como funciona el código.

Vamos a poner un ejemplo muy básico de cómo puede ser un dibujo que nos empiece a dar pistas sobre cómo funciona actualmente el código:

image

En el dibujo hemos agrupado los distintos colaboradores que interactúan en un proceso de guardado de usuario. Gracias a tenerlo esquematizado de esta forma podremos detectar qué partes no tienen sentido o cómo se pueden mejorar. En el diagrama hemos incluido marcas de tiempo donde más se demora el código para tener una pista de qué lugares pueden ser mejorados en lo que a eficiencia se refiere. Para las marcas de tiempo hemos usado el profiler de IntelliJ, una herramienta que se ejecuta cuando lanzas el proceso y que irá monitoreando cuánto tarda en ejecutarse cada función de tu código. Será tu mejor amigo para encontrar cuellos de botella.

Seguro que este caso en concreto no será lo más difícil que te encuentres por ahí, pero espero que pueda aportar algo de inspiración para poder adaptar esta técnica a tus necesidades.

Ahora que tenemos los puntos de mejora localizados y tenemos una foto donde se muestra qué es lo que hace todo el proceso. Gracias a esto, ahora vamos a poder priorizar qué partes tiene más sentido o mayor retorno de valor ir afrontando y mejorando primero.

Paso 2: Libérate de las cadenas

Ahora que ya tienes claro qué pieza del código quieres modificar lo siguiente sería añadir test que lo cubran si es que aún no los tiene. Sin embargo, cuando hablamos de código legado es muy común que realizar un test a una clase sea bastante complicado, ya que tiene ciertas dependencias que no son posibles de instanciar o crear en un entorno de test. Veamos qué técnicas podemos aplicar para romper dependencias:

Inversión de Dependencias (El principio D de SOLID)

Si tu clase depende de implementaciones concretas (emails, bases de datos…), usa interfaces. Es más limpio que heredar y más flexible. Por ejemplo:

Antes de extraer la interfaz

public class ServicioAlerta { private NotificadorEmail notificadorEmail; public ServicioAlerta(NotificadorEmail notificadorEmail) { this.notificadorEmail = notificadorEmail; } public void enviarAlerta(String mensaje) { notificadorEmail.enviarMensaje(mensaje); } } public class NotificadorEmail { public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class Main { public static void main(String[] args) { ServicioAlerta servicio = new ServicioAlerta(new NotificadorEmail()); servicio.enviarAlerta("Alerta de prueba por email!"); } }

Después de extraer la interfaz

public interface Notificador { void enviarMensaje(String mensaje); } public class NotificadorEmail implements Notificador { @Override public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class NotificadorSMS implements Notificador { @Override public void enviarMensaje(String mensaje) { System.out.println("Enviando SMS: " + mensaje); } } public class ServicioAlerta { private final Notificador notificador; public ServicioAlerta(Notificador notificador) { this.notificador = notificador; } public void enviarAlerta(String mensaje) { notificador.enviarMensaje(mensaje); } } public class Main { public static void main(String[] args) { ServicioAlerta servicioEmail = new ServicioAlerta(new NotificadorEmail()); servicioEmail.enviarAlerta("Alerta de prueba por email!"); ServicioAlerta servicioSMS = new ServicioAlerta(new NotificadorSMS()); servicioSMS.enviarAlerta("Alerta de prueba por SMS!"); } }

De esta forma hemos reducido el acoplamiento. Ahora ServicioAlerta depende de la abstracción (Notificador), no de sus implementaciones concretas, algo que además de darnos flexibilidad, ya que permite añadir y más implementaciones sin modificar el módulo de alto nivel, también nos da la ventaja de que en los test vamos a poder crear una implementación “falsa” que evite usar implementaciones de SMS o email que llegarían a usuarios finales.

Shadowing de Clases

El shadowing es una técnica que consiste en redefinir el comportamiento de una clase en tiempo de ejecución (o en tiempo de construcción del objeto) para sustituir su funcionalidad sin modificar directamente la clase que la usa. Aunque es menos común que el uso de inyección de dependencias, puede ser útil en ciertos escenarios para evitar modificaciones mayores en el código existente.

Existen dos formas de hacerlo y vamos a ver cada una como si en el ejemplo anterior en lugar de haber usado inversión de dependencias usáramos shadowing:

Extendiendo la clase original y redefiniendo su comportamiento

En este ejemplo vemos que nos aprovechamos de la herencia para extender la clase original y sobreescribir el funcionamiento de su método. Después, cuando creamos la clase de alto nivel, le pasamos por constructor la clase que hace shadowing, algo que nos va a permitir establecer un comportamiento totalmente distinto en los test.

public class NotificadorEmail { public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class NotificadorEmailSMS extends NotificadorEmail { @Override public void enviarMensaje(String mensaje) { System.out.println("Enviando SMS: " + mensaje); } } public class ServicioAlerta { private final NotificadorEmail notificador; public ServicioAlerta(NotificadorEmail notificador) { this.notificador = notificador; } public void enviarAlerta(String mensaje) { notificador.enviarMensaje(mensaje); } } public class Main { public static void main(String[] args) { ServicioAlerta servicioEmail = new ServicioAlerta(new NotificadorEmail()); servicioEmail.enviarAlerta("Alerta de prueba por email!"); ServicioAlerta servicioSMS = new ServicioAlerta(new NotificadorEmailSMS()); servicioSMS.enviarAlerta("Alerta de prueba por SMS!"); } }

Shadowing en la carpeta de test

En este caso no hemos hecho uso de la herencia, sino que hemos creado una clase con el mismo nombre y en la misma localización que la clase original, pero en este caso, dentro de la carpeta de test. Esto funciona porque en el entorno de pruebas, el compilador y el classloader de Java priorizan la clase en src/test/java sobre la de src/main/java cuando comparten el mismo nombre y paquete.
Esto permite sustituir la implementación sin modificar el código de producción. Por este mismo hecho, hay que entender que es una solución de la cual no deberíamos abusar. No es una solución intuitiva para quien intente interpretar nuestro código. Por lo que úsala si te resulta necesario, con moderación, y entendiendo que es una solución transitoria.

En src/main/java

package com.ejemplo.notificaciones; public class ServicioAlerta { private final NotificadorEmail notificador; public ServicioAlerta(NotificadorEmail notificador) { this.notificador = notificador; } public void enviarAlerta(String mensaje) { notificador.enviarMensaje(mensaje); } } package com.ejemplo.notificaciones; public class NotificadorEmail { public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } }

En src/test/java

package com.ejemplo.notificaciones; public class NotificadorEmail { private String ultimoMensaje; public void enviarMensaje(String mensaje) { this.ultimoMensaje = mensaje; System.out.println("Mock: mensaje capturado - " + mensaje); } public String getUltimoMensaje() { return ultimoMensaje; } } package com.ejemplo.notificaciones; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class ServicioAlertaTest { @Test public void testEnviarAlertaConShadowing() { ServicioAlerta servicio = new ServicioAlerta(new NotificadorEmail()); servicio.enviarAlerta("Mensaje de prueba"); NotificadorEmail mock = (NotificadorEmail) servicio.getClass() .getDeclaredField("notificador") .get(servicio); assertEquals("Mensaje de prueba", mock.getUltimoMensaje()); } }

Elimina Dependencias Ocultas

En ocasiones nos podemos encontrar dependencias ocultas. Son dependencias de las cuales no se tiene una dependencia de forma explícita que sea perceptible en la construcción de nuestra clase.

Dependencia directa

Algunas son colaboradores de los que depende la clase pero en lugar de recibir su instancia por constructor se instancia directamente en la clase. Esto hace muy difícil hacer un test de dicha clase, ya que no nos permite modificar el comportamiento de la clase para los test y, por lo tanto, si volvemos al ejemplo de antes, estaríamos enviando emails o SMS a usuarios finales. También puedes mantener el constructor antiguo si es necesario para no romper el código existente. Por ejemplo:

Antes de mover la dependencia al constructor

public class ServicioAlerta { private final NotificadorEmail notificador; public ServicioAlerta() { this.notificador = new NotificadorEmail(); } public void enviarAlerta(String mensaje) { notificador.enviarMensaje(mensaje); } }

Después de mover la dependencia al constructor

public class ServicioAlerta { private final NotificadorEmail notificador; public ServicioAlerta(NotificadorEmail notificadorEmail) { this.notificador = notificadorEmail; } public void enviarAlerta(String mensaje) { notificador.enviarMensaje(mensaje); } }

Singleton

Los singletons son otro tipo de dependencias ocultas. Veamos cuáles son las técnicas más rápidas que podemos aplicar para poder testear una clase que se apoya en un singleton.

Añade un setter para testing

Antes

public class NotificadorEmail { private static final NotificadorEmail instancia = new NotificadorEmail(); private NotificadorEmail() {} public static NotificadorEmail getInstance() { return instancia; } public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class ServicioAlerta { public void enviarAlerta(String mensaje) { NotificadorEmail.getInstance().enviarMensaje(mensaje); } }

Después

public class NotificadorEmail { private static NotificadorEmail instancia = new NotificadorEmail(); private NotificadorEmail() {} public static NotificadorEmail getInstance() { return instancia; } public static void setInstance(NotificadorEmail nuevaInstancia) { instancia = nuevaInstancia; } public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class ServicioAlerta { public void enviarAlerta(String mensaje) { NotificadorEmail.getInstance().enviarMensaje(mensaje); } } public class NotificadorEmailMock extends NotificadorEmail { private String ultimoMensaje; @Override public void enviarMensaje(String mensaje) { this.ultimoMensaje = mensaje; } public String getUltimoMensaje() { return ultimoMensaje; } } public class ServicioAlertaTest { @Test public void testEnviarAlerta() { NotificadorEmailMock mock = new NotificadorEmailMock(); NotificadorEmail.setInstance(mock); ServicioAlerta servicio = new ServicioAlerta(); servicio.enviarAlerta("Mensaje de prueba"); assertEquals("Mensaje de prueba", mock.getUltimoMensaje()); } }

Crea una subclase con constructor protected

Antes

public class NotificadorEmail { private static final NotificadorEmail instancia = new NotificadorEmail(); private NotificadorEmail() {} public static NotificadorEmail getInstance() { return instancia; } public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class ServicioAlerta { public void enviarAlerta(String mensaje) { NotificadorEmail.getInstance().enviarMensaje(mensaje); } }

Después

public class NotificadorEmail { private static NotificadorEmail instancia = new NotificadorEmail(); protected NotificadorEmail() {} public static NotificadorEmail getInstance() { return instancia; } public static void usarMockParaPruebas(NotificadorEmail mock) { instancia = mock; } public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class NotificadorEmailMock extends NotificadorEmail { private String ultimoMensaje; protected NotificadorEmailMock() {} @Override public void enviarMensaje(String mensaje) { this.ultimoMensaje = mensaje; } public String getUltimoMensaje() { return ultimoMensaje; } } public class ServicioAlerta { public void enviarAlerta(String mensaje) { NotificadorEmail.getInstance().enviarMensaje(mensaje); } } public class ServicioAlertaTest { @Test public void testEnviarAlerta() { NotificadorEmailMock mock = new NotificadorEmailMock(); NotificadorEmail.usarMockParaPruebas(mock); ServicioAlerta servicio = new ServicioAlerta(); servicio.enviarAlerta("Mensaje de prueba"); assertEquals("Mensaje de prueba", mock.getUltimoMensaje()); } }

Extrae una interfaz

Antes

public class NotificadorEmail { private static final NotificadorEmail instancia = new NotificadorEmail(); private NotificadorEmail() {} public static NotificadorEmail getInstance() { return instancia; } public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class ServicioAlerta { public void enviarAlerta(String mensaje) { NotificadorEmail.getInstance().enviarMensaje(mensaje); } }

Después

public interface Notificador { void enviarMensaje(String mensaje); } public class NotificadorEmail implements Notificador { private static final Notificador instancia = new NotificadorEmail(); private NotificadorEmail() {} public static Notificador getInstance() { return instancia; } @Override public void enviarMensaje(String mensaje) { System.out.println("Enviando email: " + mensaje); } } public class ServicioAlerta { private final Notificador notificador; public ServicioAlerta(Notificador notificador) { this.notificador = notificador; } public void enviarAlerta(String mensaje) { notificador.enviarMensaje(mensaje); } } public class NotificadorMock implements Notificador { private String ultimoMensaje; @Override public void enviarMensaje(String mensaje) { this.ultimoMensaje = mensaje; } public String getUltimoMensaje() { return ultimoMensaje; } } public class ServicioAlertaTest { @Test public void testEnviarAlerta() { NotificadorMock mock = new NotificadorMock(); ServicioAlerta servicio = new ServicioAlerta(mock); servicio.enviarAlerta("Mensaje de prueba"); assertEquals("Mensaje de prueba", mock.getUltimoMensaje()); } }

Paso 4: Construye tu Red de Seguridad

Como hemos mencionado anteriormente, los tests son lo que necesitas para poder garantizar que las modificaciones que haces en el código no alteran comportamiento o estén rompiendo la aplicación. Necesitas tests unitarios, ya que son los más rápidos y precisos. Son tu primera línea de defensa. Nada de bases de datos o HTTP aquí. Sobre esto, Michael Feathers nos comenta que el grado de cuan seguro es realizar un cambio sobre una pieza es proporcional a la cercanía que tienen los test que cubren dicha parte. Con los test unitarios podemos cumplir esta premisa. También nos vendrán muy bien los tests de integración, para asegurar que funcionan correctamente nuestras piezas de infraestructura como bases de datos, API externas, etc. Finalmente, también debemos tener algunos test end to end que verifique que la funcionalidad en todo su conjunto, lógica de negocio, infraestructura y demás funcione perfectamente toda junta.

image

Es posible que te encuentres atascado en la creación de test porque aún no tienes el conocimiento suficiente de la lógica de negocio o el dominio del código como para poder empezar a escribir los primeros tests. Para solucionar esto, tenemos Approval Testing. Esta herramienta nos propone una alternativa al clásico unit test. En este caso no definimos una aserción concreta, ya que no conocemos la lógica de negocio involucrada. La herramienta se limita a sacar una instantánea del resultado actual código. Siempre que generemos la misma instantánea diremos que estamos manteniendo la misma lógica. Veo esto realmente útil para poder avanzar en las mejoras de un código legacy con seguridad y rapidez. Bajo mi propia experiencia, he visto que también es de bastante útil para realizar aserciones complejas, como por ejemplo, el típico test que comprueba que tu implementación de JDBC graba correctamente en la base de datos y así no tener que comprobar columna a columna el valor, sino que le saca la instantánea a toda la tabla. Veamos un ejemplo de cómo funciona approval testing:

Código que usa la librería Approvals

import org.approvaltests.Approvals; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; class ApprovalTest { @Test void testCustomerList() { List<Customer> customers = getCustomers(); Approvals.verify(customers); } public List<Customer> getCustomers() { Supplier<List<Customer>> customerSupplier = () -> { List<Customer> customers = new ArrayList<>(); customers.add(createCustomer("John Doe", 35)); customers.add(createCustomer("Jane Smith", 28)); customers.add(createCustomer("Bob Johnson", 42)); return customers; }; List<Customer> customerList = customerSupplier.get(); List<Customer> shuffledList = shuffleCustomers(customerList); List<Customer> sortedList = sortCustomersByAge(shuffledList); return sortedList.stream() .filter(customer -> customer.getAge() > 0) .collect(Collectors.toList()); } private Customer createCustomer(String name, int age) { return CustomerFactory.create(name, age); } private List<Customer> shuffleCustomers(List<Customer> customers) { List<Customer> shuffled = new ArrayList<>(customers); Collections.shuffle(shuffled); return shuffled; } private List<Customer> sortCustomersByAge(List<Customer> customers) { return customers.stream() .sorted(Comparator.comparingInt(Customer::getAge)) .collect(Collectors.toList()); } private static class CustomerFactory { public static Customer create(String name, int age) { return new CustomerBuilder().setName(name).setAge(age).build(); } } private static class CustomerBuilder { private String name; private int age; public CustomerBuilder setName(String name) { this.name = name; return this; } public CustomerBuilder setAge(int age) { this.age = age; return this; } public Customer build() { return new Customer(name, age); } } public static class Customer { private final String name; private final int age; public Customer(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } @Override public String toString() { return "Customer{name='" + name + "', age=" + age + "}"; } } }

El archivo que genera la ejecución de dicha librería:

----------------- usando verify -------------------------------- Customer{name='John Doe', age=35} Customer{name='Jane Smith', age=28} Customer{name='Bob Johnson', age=42} ----------------- o también usando verifyJson -------------------------------- [ { "name": "John Doe", "age": 35 }, { "name": "Jane Smith", "age": 28 }, { "name": "Bob Johnson", "age": 42 } ]

Otra herramienta que te será de utilidad es el medidor de cobertura de tu IDE. Puedes poner que se ejecute junto con los test y lo que hará es ver que partes del código están cubiertas por test. No te tomes esto como el indicador mágico, ya que un 100% no es sinónimo de no haya bugs, y de hecho tanto porcentaje es contraproducente. Hay cosas que sencillamente no tiene sentido testear, como un getter u otros métodos con una lógica extremadamente sencilla. En realidad, un porcentaje que este sobre el 70% y el 80% con test de calidad que verdaderamente prueben las diferentes casuísticas ya es más que suficiente. Ten en cuenta también que aunque la cobertura indique que hay algún test que cubre cierto bloque de código, es posible que el test este escrito para cubrir otra parte y, por lo tanto, aunque la herramienta te diga que esa parte está completa, deberías de escribir un test dedicado para esa parte y sus diferentes casuísticas. Si tienes en cuenta sus limitaciones y sus bondades, verás que es una gran herramienta para detectar qué código necesita más tests.

image

Paso 5: Hora de la Acción

Con los test en su lugar, es hora de mejorar el código.

Cuando estamos haciendo refactoring, aconsejo ir siempre al cambio más sencillo. Si estamos ante método que te resulta complicado de entender comienza por renombrar alguna variable, o extraerla. Comienza a darle semántica al método. Verás que poco a poco iras ganando en conocimiento sobre su cometido, y cuando menos te des cuenta ya lo estarás dominando. Es en ese momento de control cuando podemos venirnos arriba y comenzar a introducir algunas abstracciones o lo que se necesite para que el código sea más sencillo.

Si el objetivo no es un refactor como tal, sino añadir una nueva feature, y estamos antes un método monstruoso, quizá lo más rentable sea hacer la funcionalidad nueva en otro método que se llame desde el actual. Todo por no seguir añadiendo complejidad en un bloque donde ya hay demasiada. También se puede crear otra clase que haga dicha funcionalidad y que la clase actual la llame. La elección entre ambos dependerá de cuan cargada este la clase actual, o si lo nuevo que se quiere introducir no es de su responsabilidad. Ambas soluciones son muy buenas también cuando tenemos un método muy difícil de testear o una clase muy difícil de instanciar y preparar en los tests. Nos ayudará a ir sacando trabajo adelante.

Prioriza siempre en la medida de lo posible estar la menor cantidad de tiempo con los test en rojo para que no perdamos el control. Acostúmbrate a lanzar habitualmente los tests con cada cambio, y verificar que no se han roto. Organiza los cambios de la forma menos disruptiva posible. Si vas a introducir una abstracción nueva, plantéala de tal forma que sea compatible con el código actual para que los test sigan en verde, para luego seguir iterándola y mejorándola hasta que quede al gusto. Tienes que verlo como irle dando forma a la escultura (nuestro código), a base de pequeños golpes bien pensados.

Refactorizar es el arte de hacer una sola cosa a la vez. Si ves algo más que mejorar mientras trabajas, anótalo para después. No abras varios frentes a la vez, especialmente con código legacy. Esto quiere decir que tenemos que estar concentrados en el objetivo. Si nuestro foco está puesto en mejorar una clase y mientras navegamos llegamos a otra y vemos algo que mejorar, te lo apuntas, ya sea en una libreta o bloc de notas del ordenador, o bien con un TODO en el código, pero jamás lo modificarás en ese momento.

Conclusión

El código legacy puede ser intimidante, pero siguiendo estas técnicas y consejos espero que podrás dar un paso más en dominarlo. El libro “Working Effectively with Legacy Code” tiene aún más trucos y recomiendo encarecidamente su lectura para mejorar como profesional y porque creo que tiene muchas técnicas que yo no he mencionado en este post. Además, su lectura es muy amena y los capítulos están estructurados de tal forma que cada uno plantea resolver un problema concreto que se te puede dar afrontando un código legacy. El refactoring es un proceso continuo, no una tarea única, debe ser aplicado diariamente tanto en proyectos legacy para que sean mejorados, como en proyectos greenfield para que mantengan esa frescura.

Teniendo un IDE que hace refactors automáticos no vale la excusa de algo no se puede refactorizar porque no se le puede añadir test. Siempre se puede mover código molesto o lo que sea a un método, heredarlo en una clase de test y cambiar su comportamiento.

¡Ahora ve y mejora ese código!