Refactorización de código legacy en Go: guía paso a paso
Trabajar con código legacy en Go es una parte inevitable de la vida del desarrollador. A menudo, dicho código se escribió con prisas, sin pruebas, con variables globales y funciones monolíticas. Sin embargo, la refactorización de código legacy en Go no es simplemente reescribir, sino un proceso estratégico que requiere cuidado y un enfoque sistemático. En este artículo, analizaremos cómo mejorar el código existente de manera segura y eficiente sin romper el producto.
¿Por qué es difícil la refactorización de código legacy en Go?
El código legacy en Go a menudo tiene problemas característicos:
- Variables globales — complican las pruebas y la ejecución paralela.
- Funciones monolíticas — funciones de cientos de líneas que lo hacen todo a la vez.
- Falta de interfaces — acoplamiento rígido a implementaciones concretas.
- Sin pruebas — refactorizar sin pruebas es como caminar por un campo minado.
Pero Go proporciona herramientas potentes para la mejora gradual del código. Lo principal es avanzar en pequeños pasos, manteniendo la funcionalidad del sistema en cada etapa.
Paso 1: Preparación y pruebas
Antes de cambiar el código, es necesario crear una «red de seguridad»: las pruebas. Incluso si el código no ha sido probado, puedes escribir pruebas de caracterización (characterization tests). Estas fijan el comportamiento actual del sistema para que puedas asegurarte de que nada se rompe después de la refactorización.
package main
import "testing"
func TestCalculateTotal(t *testing.T) { // Supongamos que tenemos una función legacy CalculateTotal result := CalculateTotal(100, 20) expected := 120.0
if result != expected { t.Errorf("CalculateTotal(100, 20) = %f; want %f", result, expected) }}Ejecuta las pruebas y asegúrate de que pasen. Ahora tienes una base para realizar cambios seguros.
Paso 2: Extracción de interfaces
Uno de los principales problemas del código legacy en Go es el fuerte acoplamiento entre componentes. La solución es la extracción de interfaces. Esto permite reemplazar las dependencias reales por mocks durante las pruebas, y también simplifica el reemplazo de la implementación en el futuro.
Consideremos un ejemplo. Supongamos que tenemos una función que guarda datos directamente en una base de datos:
package main
import "database/sql"
type User struct { ID int Name string}
func SaveUser(db *sql.DB, user User) error { _, err := db.Exec("INSERT INTO users (name) VALUES ($1)", user.Name) return err}Problema: no podemos probar esta función sin una base de datos real. Solución: extraer una interfaz:
package main
// UserStorage define el contrato para almacenar usuariostype UserStorage interface { Save(user User) error}
type UserSaver struct { storage UserStorage}
func NewUserSaver(storage UserStorage) *UserSaver { return &UserSaver{storage: storage}}
func (s *UserSaver) SaveUser(user User) error { return s.storage.Save(user)}Ahora podemos crear un mock para las pruebas:
package main
import "testing"
type mockStorage struct{}
func (m *mockStorage) Save(user User) error { return nil // siempre exitoso}
func TestUserSaver_SaveUser(t *testing.T) { saver := NewUserSaver(&mockStorage{}) err := saver.SaveUser(User{ID: 1, Name: "Alice"}) if err != nil { t.Errorf("unexpected error: %v", err) }}Paso 3: Inyección de dependencias
Después de extraer interfaces, el siguiente paso es la inyección de dependencias (Dependency Injection, DI). En lugar de crear dependencias dentro de la función (por ejemplo, abrir una conexión a la BD), las pasamos desde el exterior.
Consideremos un código legacy típico:
package