Refactoring Legacy Go Code: A Complete A-to-Z Guide

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Introduction: Why Does Legacy Go Code Need Refactoring?

\n

Go (Golang) is renowned for its simplicity, performance, and built-in concurrency tools. However, any project accumulates "technical debt" over time: global variables, lack of interfaces, magic numbers, unreadable functions, and missing tests. Such code is called legacy. Refactoring is not about rewriting everything from scratch, but about gradually improving the structure without changing external behavior. In this article, we will break down a step-by-step plan for refactoring legacy Go code, from analysis to implementation.

\n\n

Step 1: Diagnosing Legacy Code

\n

Before changing anything, you need to understand what you're dealing with. The main "red flags" in Go projects are:

\n
    \n
  • Global variables (var db *sql.DB) — complicate testing and concurrent work.
  • \n
  • Monster functions (over 50-100 lines) — make code unreadable.
  • \n
  • Lack of interfaces — tight coupling to specific implementations.
  • \n
  • Magic strings/numbers — make searching and changing difficult.
  • \n
  • Lack of tests — refactoring without tests is like walking on thin ice.
  • \n
\n

Analysis tools: use go vet and staticcheck to find suspicious constructs. gocyclo is also useful for calculating cyclomatic complexity.

\n

Example of problematic code:

\n
package main\n\nimport "fmt"\n\nvar config map[string]string // global variable\n\nfunc main() {\n    config = map[string]string{"port": "8080"}\n    startServer()\n}\n\nfunc startServer() {\n    // magic string "port"\n    fmt.Println("Starting on port:", config["port"])\n    // ... 100 lines of code\n}
\n\n

Step 2: Creating a Safety Net of Tests

\n

Without tests, refactoring becomes guesswork. Your task is to cover the key logic with tests before making changes. Use the Golden Files approach for complex outputs or integration tests.

\n

Example of adding a test for a function from legacy code:

\n
// legacy.go (original)\nfunc CalculateDiscount(price float64, isPremium bool) float64 {\n    if isPremium {\n        return price * 0.9\n    }\n    return price * 0.95\n}\n\n// legacy_test.go\npackage main\n\nimport "testing"\n\nfunc TestCalculateDiscount_Premium(t *testing.T) {\n    result := CalculateDiscount(100.0, true)\n    expected := 90.0\n    if result != expected {\n        t.Errorf("Expected %f, got %f", expected, result)\n    }\n}\n\nfunc TestCalculateDiscount_Regular(t *testing.T) {\n    result := CalculateDiscount(100.0, false)\n    expected := 95.0\n    if result != expected {\n        t.Errorf("Expected %f, got %f", expected, result)\n    }\n}
\n

Tip: use go test -cover to evaluate coverage. Aim to cover at least 70% of business logic before starting the refactoring.

\n\n

Step 3: Gradual Decomposition and Interface Extraction

\n

The main goal of refactoring is to make the code testable and flexible. Start by extracting interfaces for external dependencies (databases, HTTP clients, file systems).

\n

3.1 Extracting an Interface Instead of Direct Database Usage

\n

Suppose we have a function that directly works with *sql.DB:

\n
// legacy code\nfunc GetUser(db *sql.DB, id int) (*User, error) {\n    row := db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)\n    // ...\n}
\n

Refactoring: create a UserRepository interface:

\n
type UserRepository interface {\n    GetByID(id int) (*User, error)\n}\n\ntype userRepo struct {\n    db *sql.DB\n}\n\nfunc (r *userRepo) GetByID(id int) (*User, error) {\n    row := r.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)\n                

Blogs

Book Recommendations