Refatoração de código legado em Go: um guia passo a passo
Trabalhar com código legado em Go é uma parte inevitável da vida do desenvolvedor. Muitas vezes, esse código foi escrito às pressas, sem testes, com variáveis globais e funções monolíticas. No entanto, a refatoração de código legado em Go não é apenas reescrever, mas um processo estratégico que requer cautela e uma abordagem sistêmica. Neste artigo, vamos analisar como melhorar o código existente de forma segura e eficiente, sem quebrar o produto.
Por que a refatoração de código legado em Go é difícil?
O código legado em Go geralmente apresenta problemas característicos:
- Variáveis globais — dificultam os testes e a execução paralela.
- Funções monolíticas — funções com centenas de linhas que fazem tudo de uma vez.
- Ausência de interfaces — forte acoplamento a implementações específicas.
- Sem testes — refatorar sem testes é como andar em um campo minado.
Mas o Go fornece ferramentas poderosas para a melhoria gradual do código. O principal é avançar em pequenos passos, mantendo a funcionalidade do sistema em cada etapa.
Passo 1: Preparação e testes
Antes de alterar o código, é necessário criar uma "rede de segurança" — os testes. Mesmo que o código não tenha sido testado, você pode escrever testes de caracterização (characterization tests). Eles fixam o comportamento atual do sistema para que você possa garantir que nada quebrou após a refatoração.
package main
import "testing"
func TestCalculateTotal(t *testing.T) { // Suponha que temos uma função legada CalculateTotal result := CalculateTotal(100, 20) expected := 120.0
if result != expected { t.Errorf("CalculateTotal(100, 20) = %f; want %f", result, expected) }}Execute os testes e verifique se eles passam. Agora você tem uma base para alterações seguras.
Passo 2: Extração de interfaces
Um dos principais problemas do código legado em Go é o forte acoplamento entre componentes. A solução é a extração de interfaces. Isso permite substituir dependências reais por mocks durante os testes, além de facilitar a troca de implementações no futuro.
Vamos considerar um exemplo. Suponha que temos uma função que salva dados diretamente no banco de dados:
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: não podemos testar esta função sem um banco de dados real. Solução — extrair uma interface:
package main
// UserStorage define o contrato para armazenamento de usuáriostype 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)}Agora podemos criar um mock para testes:
package main
import "testing"
type mockStorage struct{}
func (m *mockStorage) Save(user User) error { return nil // sempre bem-sucedido}
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) }}Passo 3: Injeção de dependências
Após a extração de interfaces, o próximo passo é a injeção de dependências (Dependency Injection, DI). Em vez de criar dependências dentro da função (por exemplo, abrir uma conexão com o banco de dados), nós as passamos de fora.
Vamos considerar um código legado típico:
package