“Il faut tout réécrire !” Non. Voici comment améliorer progressivement une codebase legacy sans Big Bang rewrite (qui échoue 80% du temps).

Le mythe de la réécriture

La proposition classique

"Ce code est pourri, on va tout réécrire en 6 mois."

Réalité :

  • 6 mois → 18 mois
  • Budget x3
  • Features manquantes
  • Bugs nouveaux
  • Parfois : Abandon du projet

Exemples célèbres d’échecs :

  • Netscape (réécriture → mort de l’entreprise)
  • Basecamp (réécriture abandonnée)

Strangler Fig Pattern : La solution

Principe

Remplacer progressivement l’ancien système par du nouveau code, fonctionnalité par fonctionnalité.

Old System ████████████  (100%)
            ↓
Old ██████████░░  (80%) + New ░░ (20%)
            ↓
Old ████░░░░░░░░  (40%) + New ░░░░░░ (60%)
            ↓
Old ░░░░░░░░░░░░  (10%) + New ██████████ (90%)
            ↓
New ████████████  (100%)

Nom : Inspiré du figuier étrangleur qui pousse autour d’un arbre existant.

Stratégie : Par où commencer ?

Matrice de priorisation

Impact Business ↑
        │
     🟢 │ 🟢     ← Start here (High impact, easy)
   High │
        │
     🟡 │ 🔴     ← Avoid (High impact, hard = risky)
        │
     🟢 │ 🟡     ← Quick wins
    Low │
        │────────────→ Effort
       Easy    Hard

Identifier les quick wins

Exemples :

  1. Formatting : Prettier, ESLint (1 jour, impact énorme sur lisibilité)
  2. Dead code removal : Supprimer code inutilisé (Safe, libère mental)
  3. Dependencies update : Upgrade libs obsolètes (Sécurité)

Pattern 1 : Add, Don’t Modify

Principe

Ajouter du nouveau code plutôt que modifier l’ancien.

Exemple : Ajouter validation

// ❌ Modifier fonction legacy (risqué)
function createUser(data) {
  // 200 lignes de legacy code
  // Modifier ici = potentiel de bugs
}

// ✅ Wrapper avec validation
function createUserV2(data) {
  // Nouvelle validation propre
  validateUserData(data);

  // Appeler legacy
  return createUser(data);
}

// Migrer progressivement les appels
- createUser(data)
+ createUserV2(data)

Avantages :

  • Legacy intact (pas de régression)
  • Nouveau code testé indépendamment
  • Rollback facile

Pattern 2 : Branch by Abstraction

Principe

Créer une abstraction, puis migrer les implémentations.

Exemple : Changer de DB

// Étape 1 : Créer interface
interface UserRepository {
  findById(id: string): Promise<User>;
  save(user: User): Promise<void>;
}

// Étape 2 : Implémenter ancien système
class LegacyUserRepository implements UserRepository {
  async findById(id: string) {
    // Appeler ancien code MySQL legacy
    return legacyDb.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

// Étape 3 : Nouvelle implémentation
class PostgresUserRepository implements UserRepository {
  async findById(id: string) {
    return this.pg.query('SELECT * FROM users WHERE id = $1', [id]);
  }
}

// Étape 4 : Feature flag pour switcher
const userRepo: UserRepository =
  featureFlags.isEnabled('use-postgres')
    ? new PostgresUserRepository()
    : new LegacyUserRepository();

// Étape 5 : Rollout progressif, puis supprimer legacy

Pattern 3 : Seam Model

Principe

Identifier les “coutures” (points d’entrée/sortie) pour isoler le legacy.

Exemple : Legacy payment processing

// Legacy code (200 lignes, complexe)
function processPayment(order, card) {
  // Mélange de logique business, DB, external calls
  // Impossible à tester
}

// Identifier seam = point d'entrée
// Créer facade
class PaymentService {
  process(order: Order): PaymentResult {
    // Nouvelle logique propre
    const validated = this.validate(order);
    const charged = this.charge(validated);
    const recorded = this.record(charged);

    return recorded;
  }

  private validate(order: Order) {
    // Nouvelle validation
  }

  private charge(order: Order) {
    // Appel legacy temporairement
    return processPayment(order, order.paymentMethod);
  }

  private record(result: PaymentResult) {
    // Nouveau code de persistence
  }
}

// Progressivement, remplacer charge() par nouveau code

Refactoring tactiques

1. Boy Scout Rule

“Laisser le code plus propre qu’on l’a trouvé”

// Avant de fixer un bug
function calculateTotal(items) {
  var total = 0;  // var 😱
  for (var i = 0; i < items.length; i++) {  // for loop 😱
    total = total + items[i].price;  // pas de validation
  }
  return total;
}

// Après fix + mini refactoring
function calculateTotal(items: Item[]): number {
  if (!Array.isArray(items)) {
    throw new Error('Items must be an array');
  }

  return items.reduce((total, item) => {
    if (typeof item.price !== 'number') {
      throw new Error('Invalid item price');
    }
    return total + item.price;
  }, 0);
}

// 5 minutes de refactoring, impact énorme

2. Extract Method

// Avant : God function
function handleCheckout(cart, user, payment) {
  // 150 lignes de code
  // Validation
  // Calcul prix
  // Taxes
  // Shipping
  // Payment
  // Email
  // ...
}

// Après : Extraction progressive
function handleCheckout(cart, user, payment) {
  validateCheckout(cart, user);
  const pricing = calculatePricing(cart);
  const order = createOrder(cart, user, pricing);
  processPayment(order, payment);
  sendConfirmationEmail(user, order);
  return order;
}

// Chaque fonction < 20 lignes, testable

3. Introduce Parameter Object

// Avant : Trop de paramètres
function createInvoice(
  customerName,
  customerEmail,
  customerAddress,
  items,
  discount,
  taxRate,
  shippingCost
) {
  // ...
}

// Après : Regrouper
interface InvoiceData {
  customer: Customer;
  items: LineItem[];
  pricing: PricingInfo;
}

function createInvoice(data: InvoiceData) {
  // Beaucoup plus clair
}

Tests : Sécuriser le refactoring

Characterization Tests

Tests qui documentent le comportement actuel (même s’il est bugué).

// Legacy function (comportement bizarre mais utilisé)
function formatPrice(price) {
  return '$' + price.toFixed(2).replace('.00', '');
}

// Characterization test
describe('formatPrice (legacy behavior)', () => {
  it('removes .00 from whole numbers', () => {
    expect(formatPrice(10)).toBe('$10');  // Pas '$10.00'
  });

  it('keeps decimals for non-whole numbers', () => {
    expect(formatPrice(10.50)).toBe('$10.50');
  });

  it('formats negative numbers', () => {
    expect(formatPrice(-5)).toBe('$-5');  // Bug ? Mais c'est l'existant
  });
});

// Maintenant, refactorer en sécurité

Golden Master Testing

// Capturer outputs legacy
const inputs = loadTestInputs();
const goldenOutputs = inputs.map(input => legacyFunction(input));

// Après refactoring
const newOutputs = inputs.map(input => newFunction(input));

// Comparer
expect(newOutputs).toEqual(goldenOutputs);

Mesurer la dette technique

Metrics à tracker

// Code complexity (Cyclomatic)
// Tools: ESLint complexity rule
function complex() {
  if (a) {
    if (b) {
      if (c) {
        if (d) {
          // Complexity = 4 (trop élevé)
        }
      }
    }
  }
}

// Code churn (changements fréquents)
git log --format=format: --name-only | \
  sort | uniq -c | sort -rg | head -10

// Files with most bugs
git log --grep="fix\|bug" --name-only | \
  sort | uniq -c | sort -rg

Dashboard dette technique

┌────────────────────────────────────┐
│ Technical Debt Dashboard           │
├────────────────────────────────────┤
│ Code Coverage: 45% → 68% (+23%)   │
│ Complexity: 8.2 → 5.4 (-34%)      │
│ Duplicated Code: 18% → 9% (-50%)  │
│ Security Vulnerabilities: 23 → 3  │
│                                    │
│ Most Complex Files:                │
│ 1. legacy/checkout.js (CC: 42)    │
│ 2. legacy/pricing.js (CC: 38)     │
│ 3. api/orders.js (CC: 28)         │
└────────────────────────────────────┘

Cas réel : E-commerce legacy (150k LOC)

Situation initiale

  • Monolithe PHP : 8 ans d’âge
  • Tests : 5% coverage
  • Deployment : Manuel, 2h
  • Bugs : 40/mois

Stratégie (12 mois)

Mois 1-3 : Stabiliser

  • Setup CI/CD (automatiser deploy)
  • Tests critiques (checkout, payment)
  • Documentation architecture

Mois 4-6 : Quick wins

  • Formatter (PSR-12)
  • Dead code removal (-15k LOC)
  • Dependencies update

Mois 7-9 : Extract services

  • Service “Inventory” extrait (nouveau code)
  • Service “Notifications” extrait
  • Monolithe appelle via API

Mois 10-12 : Refactoring continu

  • Boy Scout Rule systématique
  • Coverage : 5% → 45%
  • Complexity réduite

Résultats (12 mois)

  • Bugs : 40/mois → 8/mois (-80%)
  • Deploy : 2h → 15min (-87%)
  • Coverage : 5% → 45%
  • Velocity : +60% (features plus rapides)
  • Team happiness : 4/10 → 8/10

Coût : 0.5 ETP dédié = ~$50k ROI : Économie bugs + velocity = ~$200k/an

Erreurs à éviter

Erreur 1 : Big Bang rewrite

❌ "On réécrit tout en 6 mois"

✅ "On extrait 1 service en 1 mois, on évalue, puis on continue"

Erreur 2 : Refactoring sans tests

❌ Refactorer code sans tests = roulette russe

✅ Tests d'abord, refactoring ensuite

Erreur 3 : Refactoring sans business value

❌ "Je vais refactorer ce module qu'on n'utilise plus"

✅ Refactorer code critique / fréquemment modifié

Erreur 4 : Perfectionnisme

❌ "Je vais rendre ce code parfait"

✅ "Je vais rendre ce code 20% meilleur"

Checklist refactoring safe

Avant de refactorer :

✅ Tests existants passent ✅ Nouveau test ajouté (si possible) ✅ Change small (< 400 lignes) ✅ Rollback plan (feature flag ?) ✅ Monitoring en place

Pendant :

✅ Tests passent à chaque étape ✅ Commit fréquents ✅ Code review

Après :

✅ Tests passent ✅ Performance maintenue ✅ Monitoring OK (pas de spike errors)

Conclusion

Refactorer legacy n’est pas un luxe.

C’est un investissement :

  • Vélocité améliorée
  • Bugs réduits
  • Team happier

Approche pragmatique :

  1. Commencer petit (formatting, dead code)
  2. Tests de sécurité (characterization tests)
  3. Refactoring progressif (Strangler Fig)
  4. Mesurer l’impact

En 12 mois : Codebase transformée, sans Big Bang.

Et vous, comment gérez-vous votre legacy ?