Introduction: Why Does Legacy Go Code Require Refactoring?
Go (Golang) is renowned for its simplicity and performance. However, even in Go projects, legacy code accumulates over time — outdated constructs, unreadable functions, "magic" numbers, and a lack of tests. Working with such code becomes a nightmare: every change is risky, and adding new functionality takes weeks.
Refactoring is not rewriting a project from scratch. It is a safe reorganization of the internal code structure without changing its external behavior. In this article, we will break down a step-by-step plan for refactoring legacy Go code, from analysis to implementation. You will learn how to turn tangled "spaghetti code" into a clean, maintainable, and testable project.
Stage 1: Audit and Mapping of Legacy Code
Before changing code, you need to understand what you are dealing with. Skip this stage — and you risk breaking logic that no one remembers.
1.1. Finding "Red Flags"
Start by looking for typical anti-patterns in Go:
- God functions — functions longer than 100 lines.
- Global variables — high coupling and testing complexity.
- Magic numbers/strings — constants scattered throughout the code.
- Lack of error handling — ignoring the returned
error. - Panic instead of errors — using
panic/recoverfor business logic. - Mixing layers — SQL queries inside HTTP handlers.
1.2. Creating a Dependency Map
Use tools like go callgraph or guru to visualize function calls. This will help identify:
- Cyclic dependencies between packages.
- "Dead" code that is never called.
- Critical paths that should not be touched without prior test coverage.
Stage 2: Creating a "Safety Net" of Tests
Refactoring without tests is a jump without a safety net. Your task is to write characterization tests that capture the current behavior of the system, even if it seems incorrect.
2.1. Testing at the Function Level
Start with the most critical and isolated parts of the code. Example for a function planned for refactoring:
// Legacy codefunc CalculatePrice(base float64, discountCode string) float64 { if discountCode == "SALE10" { return base * 0.9 } if discountCode == "VIP20" { return base * 0.8 } return base}
// Characterization testfunc TestCalculatePrice(t *testing.T) { tests := []struct { name string base float64 code string want float64 }{ {"no discount", 100.0, "", 100.0}, {"10% discount", 100.0, "SALE10", 90.0}, {"20% discount", 100.0, "VIP20", 80.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := CalculatePrice(tt.base, tt.code) if got != tt.want { t.Errorf("CalculatePrice(%v, %v) = %v; want %v", tt.base, tt.code, got, tt.want) } }) }}2.2. Integration Tests via Golden Files
For complex structures (JSON responses, report generation), use the "golden files" technique. Record the current output to a file, and during refactoring, compare against it.
func TestHandler(t *testing.T) { // Execute request req := httptest.NewRequest("GET", "/api/data", nil) rec := httptest.NewRecorder() myHandler(rec, req)
// Compare with golden file expected, _ := os.ReadFile("testdata/golden.json") if !bytes.Equal(rec.Body.Bytes(), expected) { t.Errorf("Output mismatch") }\