Refactoring Legacy Go Code: A Step-by-Step Guide
Working with legacy Go code is an inevitable part of a developer's life. Such code is often written in a hurry, without tests, with global variables and monolithic functions. However, refactoring legacy Go code is not just rewriting, but a strategic process that requires caution and a systematic approach. In this article, we will break down how to safely and effectively improve existing code without breaking the product.
Why is Refactoring Legacy Go Code Difficult?
Legacy Go code often has characteristic problems:
- Global variables — complicate testing and parallel execution.
- Monolithic functions — functions hundreds of lines long that do everything at once.
- Lack of interfaces — tight coupling to specific implementations.
- No tests — refactoring without tests is like walking through a minefield.
But Go provides powerful tools for gradually improving code. The main thing is to move in small steps, keeping the system working at each stage.
Step 1: Preparation and Testing
Before changing the code, you need to create a "safety net" — tests. Even if the code hasn't been tested, you can write characterization tests. They capture the current behavior of the system so you can verify that nothing broke after refactoring.
package main
import "testing"
func TestCalculateTotal(t *testing.T) { // Suppose we have a legacy function CalculateTotal result := CalculateTotal(100, 20) expected := 120.0
if result != expected { t.Errorf("CalculateTotal(100, 20) = %f; want %f", result, expected) }}Run the tests and make sure they pass. Now you have a baseline for safe changes.
Step 2: Extracting Interfaces
One of the main problems with legacy Go code is tight coupling between components. The solution is extracting interfaces. This allows you to replace real dependencies with mocks during testing, and also simplifies replacing the implementation in the future.
Consider an example. Suppose we have a function that saves data directly to a database:
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}The problem: we cannot test this function without a real database. The solution is to extract an interface:
package main
// UserStorage defines the contract for storing userstype 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)}Now we can create a mock for testing:
package main
import "testing"
type mockStorage struct{}
func (m *mockStorage) Save(user User) error { return nil // always successful}
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) }}Step 3: Dependency Injection
After extracting interfaces, the next step is Dependency Injection (DI). Instead of creating dependencies inside a function (e.g., opening a database connection), we pass them in from the outside.
Consider typical legacy code:
package