Introduction au Refactoring du Code Go Hérité
Travailler avec du code hérité est une étape inévitable dans la vie de tout développeur Go. Avec le temps, les projets accumulent de la dette technique : fonctions monolithiques, duplication de code, manque de tests et gestion d'erreurs. Le refactoring du code Go hérité n'est pas une simple réécriture, mais un processus stratégique d'amélioration de l'architecture sans modifier le comportement externe du système.
Dans cet article, nous allons décomposer les principales approches de refactoring des projets Go, fournir des exemples de code spécifiques et montrer comment rendre une base de code ancienne plus propre, plus sûre et plus performante.
Pourquoi le Code Go Hérité Nécessite-t-il une Approche Spéciale ?
Go est connu pour sa simplicité et sa rigueur, mais même dans Go, des problèmes s'accumulent :
- Manque d'interfaces — couplage fort des composants ;
- Ignorer les erreurs — retour sans vérifier err ;
- Variables globales — dépendances implicites ;
- Duplication de logique — copier-coller au lieu de la composition ;
- Fonctions longues — plus de 50 lignes avec plusieurs responsabilités.
Le refactoring sans tests, c'est comme une chirurgie sans anesthésie. Par conséquent, la première étape consiste à assurer une couverture de test pour les zones critiques.
Stratégie de Refactoring : Du Monolithe aux Modules
1. Extraction des Interfaces et Inversion de Dépendances
Commençons par un code hérité typique où la logique métier est fortement couplée à l'implémentation du stockage :
// Avant le 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}Le problème : un tel code ne peut pas être testé sans une base de données réelle. La solution est d'introduire une interface :
// Après le 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)}Maintenant, nous pouvons facilement substituer l'implémentation par un mock dans les tests.
2. Division des Grandes Fonctions (Responsabilité Unique)
Code hérité typique — une fonction qui fait tout à la fois :
// Avant le refactoringfunc processOrder(order Order) error { // Validation if order.Amount <= 0 { return errors.New("invalid amount") } // Calcul de la réduction discount := 0.0 if order.CustomerType == "vip" { discount = 0.1 } // Sauvegarde en BDD _, err := db.Exec("INSERT INTO orders ...") if err != nil { return err } // Envoi d'email return sendEmail(order.Email, "Order confirmed")}Après le refactoring, nous décomposons en petites fonctions testables :
// Après le 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. Remplacement des Variables Globales par l'ID (Injection de Dépendances)
Les variables globales sont le principal ennemi des test