Introduction: Why Legacy Go Code Requires a Special Approach
Go (Golang) is renowned for its simplicity and performance, but even in Go, legacy code emerges over time: monolithic packages, global variables, a lack of interfaces and tests. Refactoring such code is not just about "rewriting it nicely," but a strategic task that determines the stability and speed of development.
In this article, we will explore advanced techniques for refactoring legacy Go code: from module extraction to safe interface injection. You will learn how to transform "spaghetti code" into a maintainable architecture without stopping production.
1. Diagnosis and Mapping of Legacy Code
Before refactoring, it is necessary to understand the current structure. Go provides built-in tools and approaches for this:
- Dependency Analysis: use
go mod graphandgo list -jsonto visualize relationships between packages. - Finding Global Variables:
grep -r "var " *.go— these are often the root of problems. - Identifying "Code Smells": long functions, duplication, lack of error handling.
// Example: finding all global variables in a projectpackage main
import ( "fmt" "go/parser" "go/token" "os" "path/filepath")
func findGlobals(root string) { filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || filepath.Ext(path) != ".go" { return nil } fset := token.NewFileSet() f, _ := parser.ParseFile(fset, path, nil, parser.AllErrors) for _, decl := range f.Decls { if genDecl, ok := decl.(*ast.GenDecl); ok && genDecl.Tok == token.VAR { fmt.Printf("Global var in %s: %v", path, genDecl.Specs) } } return nil })}Important: before refactoring, create a full snapshot of tests (even if there are none — write at least smoke tests for critical paths).
2. Strategic Module Extraction: From Monolith to Packages
Legacy code often contains one giant main or utils package. Advanced refactoring begins with granular extraction:
- Extraction by Responsibility: split the code into layers (handler, service, repository).
- Creating Internal Packages: use
internal/to hide implementation. - Interfaces as Contracts: don't create interfaces in advance — extract them from concrete implementations.
// Before refactoring: everything in one filepackage main
type User struct { ID int Name string}
func SaveUser(db *sql.DB, u User) error { _, err := db.Exec("INSERT INTO users ...", u.ID, u.Name) return err}
// After refactoring: separation into packages// internal/repository/user.gopackage repository
type User struct { ID int Name string}
type UserRepository struct { db *sql.DB}
func (r *UserRepository) Save(u User) error { _, err := r.db.Exec("INSERT INTO users ...", u.ID, u.Name) return err}Tip: use the "Strangler Fig" pattern — gradually replace old functions with new ones, without deleting the old code immediately.
3. Eliminating Global States and Dependency Injection
Global variables in Go are the main cause of instability and untestability. An advanced technique is Inversion of Control (IoC) through constructors and interfaces.
// Problem: global logger and configvar logger *log.Loggervar config *Config
func Init() { logger = log.New(os.Stdout, "", log.LstdFlags) config = loadConfig()}
// Solution: dependency injection