Einführung in das Refactoring von Legacy-Code in Go
Die Arbeit mit Legacy-Code ist ein unvermeidlicher Teil des Lebens eines jeden Go-Entwicklers. Im Laufe der Zeit sammeln Projekte technische Schulden an: Es entstehen monolithische Funktionen, Code-Duplizierung, fehlende Tests und Fehlerbehandlung. Das Refactoring von Legacy-Code in Go ist nicht einfach nur Umschreiben, sondern ein strategischer Prozess zur Verbesserung der Architektur, ohne das externe Verhalten des Systems zu ändern.
In diesem Artikel werden wir die grundlegenden Ansätze zum Refactoring von Go-Projekten untersuchen, konkrete Codebeispiele liefern und zeigen, wie man eine alte Codebasis sauberer, sicherer und leistungsfähiger macht.
Warum erfordert Legacy-Code in Go einen besonderen Ansatz?
Go ist bekannt für seine Einfachheit und Strenge, aber selbst darin sammeln sich Probleme an:
- Fehlende Interfaces — starke Kopplung von Komponenten;
- Ignorieren von Fehlern — return ohne Prüfung von err;
- Globale Variablen — implizite Abhängigkeiten;
- Logik-Duplizierung — Copy-Paste anstelle von Komposition;
- Lange Funktionen — mehr als 50 Zeilen mit vielen Verantwortlichkeiten.
Refactoring ohne Tests ist wie eine Operation ohne Betäubung. Daher ist der erste Schritt, die Testabdeckung kritischer Bereiche sicherzustellen.
Refactoring-Strategie: Vom Monolithen zu Modulen
1. Herauslösen von Interfaces und Dependency Inversion
Beginnen wir mit typischem Legacy-Code, bei dem die Geschäftslogik fest an die Speicherimplementierung gebunden ist:
// Vor dem Refactoringtype UserService struct { db *sql.DB}
func (s *UserService) GetUser(id int) (*User, error) { row := s.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id) var u User err := row.Scan(&u.ID, &u.Name) return &u, err}Problem: Solcher Code kann ohne eine echte Datenbank nicht getestet werden. Lösung — Einführung eines Interfaces:
// Nach dem Refactoringtype Storage interface { GetUser(id int) (*User, error)}
type UserService struct { storage Storage}
func NewUserService(storage Storage) *UserService { return &UserService{storage: storage}}
func (s *UserService) GetUser(id int) (*User, error) { return s.storage.GetUser(id)}Jetzt können wir die Implementierung in Tests einfach durch einen Mock ersetzen.
2. Aufteilung großer Funktionen (Single Responsibility)
Typischer Legacy-Code — eine Funktion, die alles auf einmal erledigt:
// Vor dem Refactoringfunc processOrder(order Order) error { // Validierung if order.Amount <= 0 { return errors.New("invalid amount") } // Rabattberechnung discount := 0.0 if order.CustomerType == "vip" { discount = 0.1 } // Speichern in der DB _, err := db.Exec("INSERT INTO orders ...") if err != nil { return err } // E-Mail senden return sendEmail(order.Email, "Order confirmed")}Nach dem Refactoring teilen wir es in kleine, testbare Funktionen auf:
// Nach dem Refactoringtype OrderProcessor struct { db Database notifier Notifier}
func (p *OrderProcessor) Process(order Order) error { if err := validateOrder(order); err != nil { return err } order = applyDiscount(order) if err := p.db.SaveOrder(order); err != nil { return fmt.Errorf("save order: %w", err) } return p.notifier.SendConfirmation(order.Email)}
func validateOrder(order Order) error { if order.Amount <= 0 { return errors.New("amount must be positive") } return nil}
func applyDiscount(order Order) Order { if order.CustomerType == "vip" { order.Amount *= 0.9 } return order}3. Ersetzen globaler Variablen durch DI (Dependency Injection)
Globale Variablen sind der Hauptfeind von Tests