Blog
Pourquoi une réécriture complète crée souvent plus de risques qu’une refactorisation progressive
Une réécriture complète promet une architecture propre, mais oblige l’équipe à redécouvrir règles métier, intégrations et exceptions de production. Une refactorisation progressive réduit le risque en remplaçant une frontière à la fois.
Une réécriture commence avec une spécification incomplète
Une application PHP existante contient souvent des connaissances métier qui n’ont jamais été documentées : un ancien accord client, une exception dans un import, un contrôle d’accès ajouté après un incident. Remplacer le code suppose de décider ce que devient ce comportement, pas simplement de transposer les classes dans un framework plus récent.
Cela ne rend pas toutes les règles historiques indispensables. Certaines restent nécessaires ; d’autres sont des contournements ou des erreurs. Le premier travail consiste à distinguer les comportements à conserver de ceux que le métier souhaite faire évoluer.
Ce que le chiffrage d’une réécriture doit couvrir
Un framework qui n’est plus maintenu, des modules étroitement couplés ou des déploiements peu fiables justifient d’envisager un remplacement. Une nouvelle implémentation peut lever des contraintes coûteuses à contourner. Mais le chiffrage doit dépasser le développement : retrouver les exigences, reproduire les intégrations et les droits d’accès, migrer les données, vérifier les rapports, former les utilisateurs et préparer la reprise après incident.
L’ancienne et la nouvelle implémentation peuvent coexister pendant cette période. Cela peut imposer des corrections à deux endroits, une synchronisation, des environnements supplémentaires et davantage de connaissances à maintenir pour l’exploitation. La durée et le coût dépendent du périmètre et du plan de bascule. Ni le gel de toutes les nouvelles fonctionnalités ni le retard permanent du nouveau système ne sont inévitables. La modernisation progressive a aussi un coût : les adaptateurs temporaires et le code de migration doivent avoir des responsables et une date de retrait prévue.
Partir du calcul du prix d’une commande
Voici un extrait hypothétique d’une application Symfony ou Laravel. Les types comme Order et Money appartiennent au projet d’exemple ; les méthodes et dépendances omises ne sont pas des API du framework. Les classes readonly utilisées plus loin nécessitent PHP 8.2 ou une version ultérieure.
final class LegacyOrderPriceCalculator
{
public function calculate(Order $order): Money
{
$total = $order->lineTotal();
if ($order->customer()->usesHistoricContract()) {
$total = $total->subtract(
$this->historicDiscount($order),
);
}
if (
$order->country() === 'DE'
&& $order->wasImportedBeforeTaxMigration()
) {
return $this->legacyGermanTaxCalculation(
order: $order,
amount: $total,
);
}
return $this->standardTaxCalculation(
order: $order,
amount: $total,
);
}
}La remise historique est déduite avant les deux branches de calcul fiscal. La branche allemande représente une exception fictive dans un ancien système, pas une règle fiscale allemande réelle. Les méthodes de calcul sont volontairement omises.
Avant de remplacer ce code, il faut déterminer quelles commandes suivent chaque branche, comment fonctionnent les arrondis et si les anciens documents doivent rester reproductibles. Les responsables métier doivent décider quelles règles restent nécessaires. Une nouvelle implémentation ne devrait pas remplacer discrètement une règle mal comprise par une autre.
Les tests de caractérisation décrivent un comportement, pas sa validité
Un test de caractérisation fige un résultat existant pour qu’une refactorisation ne le modifie pas à notre insu. Le premier exemple porte sur une commande importée :
it('conserve le résultat de la commande importée', function (): void {
$order = OrderBuilder::new()
->forCountry('DE')
->importedBeforeTaxMigration()
->withNetAmount('199.95')
->build();
$result = $this->calculator()->calculate($order);
expect($result->currency())->toBe('EUR')
->and($result->amount())->toBe('237.94');
});Le second concerne un client titulaire d’un contrat historique :
it('conserve le résultat du contrat historique', function (): void {
$order = OrderBuilder::new()
->forHistoricContractCustomer()
->withNetAmount('1000.00')
->build();
$result = $this->calculator()->calculate($order);
expect($result->amount())->toBe('950.00');
});Les montants 237.94 et 950.00 sont des valeurs de référence hypothétiques, pas des résultats comptables vérifiés chez un client. Ils ne peuvent pas être déduits de cet extrait : les méthodes de calcul et les valeurs par défaut des données de test manquent. Dans un véritable test, OrderBuilder et calculator() sont des utilitaires du projet. Les données doivent préciser le traitement fiscal, la devise, la remise et les règles d’arrondi.
On consigne le comportement observé, puis on examine les résultats surprenants avec la personne responsable du processus. Les tests qui préservent le comportement actuel doivent se distinguer de ceux qui vérifient une évolution métier approuvée. La réussite de ces deux tests ne démontrerait pas la justesse de tous les prix.
Migrer les données sans oublier les modifications en cours
Les données historiques peuvent contenir des valeurs null incohérentes, des doublons, des références manquantes ou des champs dont le sens a changé. Ce sont des points à examiner, pas des défauts à supposer dans chaque base. Une adresse corrigée manuellement peut être plus fiable que le résultat d’un nouveau parseur ; deux fiches clients similaires peuvent correspondre à des personnes différentes.
La migration demande donc un rapprochement des données, pas seulement une transformation. Il faut vérifier la couverture, les références et les totaux pertinents, avec une procédure convenue pour les cas ambigus.
Expand-and-contract sépare l’ajout de structures compatibles du retrait ultérieur des structures obsolètes. Une migration d’adresses pourrait suivre ces étapes :
Étape Condition pour poursuivre
Ajouter les structures Ancien code toujours compatible
Reprendre les données Anciens enregistrements traités par lots
Rapprocher Modifications et exceptions traitées
Basculer la lecture Nouvelles données conformes aux critères
Retirer les anciennes Plus aucun accès aux anciennes structures
Pendant la transition : attribuer chaque responsabilité d’écriture.Ajouter des colonnes acceptant null ne convient que si le modèle autorise une valeur absente. Une modification de schéma peut aussi verrouiller des tables ou charger la base. Il faut préparer l’opération réelle sur la base, pas seulement le déploiement applicatif.
La double écriture est une option, pas une obligation. Un seul chemin d’écriture avec un adaptateur de compatibilité, une capture des changements ou une interruption planifiée des écritures peut mieux convenir. Si les deux représentations sont mises à jour, les échecs partiels et les modifications concurrentes exigent une politique de cohérence et de rapprochement. Une reprise historique qui avance par identifiant ne repasse pas automatiquement sur un enregistrement antérieur modifié ensuite.
Ce service illustre un lot de taille limitée. Le constructeur et les déclarations des propriétés customers et legacyAddressParser sont omis :
final class CustomerAddressMigrationService
{
// Constructeur et propriétés de dépendances omis.
public function migrateBatch(
int $afterId,
int $limit,
): MigrationBatchResult {
if ($afterId < 0 || $limit < 1) {
throw new \InvalidArgumentException(
'Curseur ou taille de lot non valide.',
);
}
$customers = $this->customers
->findLegacyAddressBatch(
afterId: $afterId,
limit: $limit,
);
foreach ($customers as $customer) {
if ($customer->hasStructuredAddress()) {
continue;
}
$address = $this->legacyAddressParser
->parse($customer->legacyAddress());
$customer->setStructuredAddress($address);
}
$this->customers->saveAll($customers);
return MigrationBatchResult::fromCustomers($customers);
}
}Ici, findLegacyAddressBatch() doit renvoyer un lot entièrement chargé, trié selon un identifiant entier stable et unique, avec id > afterId et une limite. MigrationBatchResult::fromCustomers() doit calculer la progression à partir du dernier enregistrement examiné, y compris ceux que la boucle ignore, et reconnaître un lot vide.
La condition d’exclusion évite de répéter une transformation, mais ne prouve pas que l’adresse enregistrée est correcte ou à jour. saveAll() ne signifie pas non plus qu’une transaction a été validée. Ces méthodes propres au projet doivent définir la persistance, la responsabilité transactionnelle et le traitement des erreurs. Un point de reprise ne doit être enregistré qu’après la validation des écritures correspondantes. Des reprises sûres nécessitent aussi une protection contre les écrasements concurrents et un moyen de réparer ou de rapprocher un travail partiellement effectué. Cet extrait ne garantit à lui seul ni l’idempotence ni une migration reprenable sans risque.
Remplacer une fonction derrière un contrat utile
Pour le calcul du prix, une petite interface appartenant à l’application peut fournir une dépendance stable aux appelants :
interface CustomerPricingInterface
{
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult;
}Un adaptateur rend l’implémentation existante accessible par ce contrat :
final class LegacyCustomerPricingAdapter
implements CustomerPricingInterface
{
public function __construct(
private readonly LegacyPricingManager $legacy,
) {
}
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
$legacyResult = $this->legacy->calculate(
customerId: $customer->getId(),
lines: $order->lines(),
);
return PriceResult::fromLegacyResult($legacyResult);
}
}Cette frontière n’est utile que si les appelants passent réellement par l’interface au lieu de la contourner pour accéder à l’ancien gestionnaire. PriceResult::fromLegacyResult() doit conserver la devise, la précision et le sens convenus du résultat. Une interface commune ne garantit pas l’équivalence des calculs.
La nouvelle implémentation respecte le même contrat. Ce squelette lève explicitement une exception, car le nouvel algorithme n’est pas décrit ici :
final class ModernCustomerPricingService
implements CustomerPricingInterface
{
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
throw new \LogicException(
'Le nouveau calcul reste à implémenter.',
);
}
}Il ne faut pas sélectionner ce squelette dans une application déployée. Le calcul doit être implémenté et vérifié avant son activation.
Branch by Abstraction et Strangler Fig interviennent à des frontières différentes
Branch by Abstraction permet à plusieurs implémentations de coexister derrière un contrat commun, le temps de rediriger les appelants puis d’introduire le remplacement. Le schéma décrit les dépendances et les implémentations, pas l’ordre d’exécution des méthodes :
Dépendance des appelants : CustomerPricingInterface
Implémentations du contrat :
LegacyCustomerPricingAdapter
ModernCustomerPricingService
Choix :
configuration de déploiement -> injection de dépendances
contexte client -> CustomerPricingFactoryLe choix à l’exécution est utile lorsque la politique de migration dépend réellement du client ou de la requête :
final class CustomerPricingFactory
{
public function __construct(
private readonly LegacyCustomerPricingAdapter $legacy,
private readonly ModernCustomerPricingService $modern,
private readonly PricingMigrationPolicy $policy,
) {
}
public function forCustomer(
Customer $customer,
): CustomerPricingInterface {
if ($this->policy->usesModernPricing($customer)) {
return $this->modern;
}
return $this->legacy;
}
}La fabrique choisit l’implémentation ; l’interface définit le contrat du calcul. Si la configuration de déploiement fixe le même choix pour tous, l’injection de dépendances suffit généralement. Une bascule par client exige aussi un aiguillage cohérent et des données compatibles. Revenir sur un indicateur d’activation ne suffit pas si l’ancien code ne sait plus lire les données nouvellement écrites.
Strangler Fig remplace des parties de l’application à une frontière de routage ou de façade. Par exemple, GET /api/orders/{id} peut être progressivement confié à un nouveau service de lecture tout en conservant le point d’accès public. Un contrat de requête interne pourrait être :
interface OrderDetailsQueryInterface
{
public function get(
int $orderId,
string $locale,
): ?OrderDetailsDto;
}null représente ici une commande absente, pas une décision d’autorisation. Les deux chemins doivent préserver les contrôles d’accès et le contrat public de réponse. Branch by Abstraction porte sur une frontière interne de dépendances ; Strangler Fig concerne le remplacement progressif des fonctions de l’application. Les deux peuvent se combiner, mais leurs noms ne sont pas interchangeables. Ce sont des techniques établies, pas des inventions de GiSoft ; les références figurent en fin d’article.
Comparer les calculs sans exécuter deux fois les actions métier
Pour un calcul sans effet de bord, exécuter les deux implémentations peut révéler des écarts avant de changer le résultat officiel. Il faut des instantanés d’entrée équivalents, les mêmes données de référence et les mêmes règles d’arrondi. Sinon, une variation de taux de change ou une entrée modifiée peut être prise pour un défaut d’implémentation.
Ce service de diagnostic synchrone renvoie le résultat officiel si les deux calculs et le signalement des écarts aboutissent :
final class ComparingCustomerPricingService
implements CustomerPricingInterface
{
public function __construct(
private readonly CustomerPricingInterface $official,
private readonly CustomerPricingInterface $candidate,
private readonly PricingComparisonReporter $reporter,
) {
}
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
$officialResult = $this->official->calculate(
customer: $customer,
order: $order,
);
$candidateResult = $this->candidate->calculate(
customer: $customer,
order: $order,
);
if (!$officialResult->equals($candidateResult)) {
$this->reporter->reportDifference(
customerId: $customer->getId(),
official: $officialResult,
candidate: $candidateResult,
);
}
return $officialResult;
}
}equals() doit comparer ce qui compte pour le métier : devise, montants, arrondis et détail pertinent du résultat, plutôt que l’identité des objets ou une tolérance arbitraire sur des nombres flottants. Les implémentations ne doivent modifier ni les entrées ni les résultats partagés.
Ce code n’isole pas les défaillances. Une exception du candidat, un calcul lent ou un échec du reporter peut encore faire échouer ou ralentir la requête. Une utilisation en production demande une politique explicite pour ces erreurs et des limites d’exécution. Si une isolation est nécessaire, une tâche de comparaison séparée, alimentée par un instantané des entrées, peut être préférable, avec ses propres règles de livraison et limites de ressources. Ne pas renvoyer le résultat candidat ne rend pas son calcul sans conséquence.
Le reporter reçoit des données client et tarifaires potentiellement sensibles. Leur contenu, leur accès et leur durée de conservation doivent être limités ; des copies intégrales d’objets ne sont pas nécessaires.
Une décision de paiement illustre la séparation entre calcul et action :
final readonly class PaymentDecision
{
public function __construct(
public bool $allowed,
public string $reason,
public Money $amount,
) {
}
}Les deux implémentations peuvent calculer une décision. Seul le chemin officiel autorisé doit effectuer le paiement, après les contrôles d’accès et les règles métier existants. allowed ne constitue pas en soi une autorisation. Une livraison répétée ou un délai d’attente dépassé chez le prestataire avec un résultat incertain exige toujours un suivi durable de l’opération et une gestion adaptée de l’idempotence. Comparer deux valeurs de retour ne fournit ni l’un ni l’autre.
La même séparation vaut pour les courriels, les mouvements de stock et les remboursements. Par ailleurs, readonly ne rend pas immuable un objet Money contenu dans une propriété : ce type doit assurer ses propres garanties.
Rendre explicite la responsabilité de la persistance
Une frontière de dépôt peut permettre aux services métier de travailler avec un modèle stable pendant que le stockage évolue :
interface CustomerRepositoryInterface
{
public function get(int $id): Customer;
public function save(Customer $customer): void;
}Un adaptateur effectue la correspondance entre ce modèle et l’ancienne représentation persistée :
final class LegacyCustomerRepository
implements CustomerRepositoryInterface
{
public function __construct(
private readonly LegacyCustomerGateway $gateway,
private readonly LegacyCustomerMapper $mapper,
) {
}
public function get(int $id): Customer
{
$row = $this->gateway->findRequired($id);
return $this->mapper->toDomain($row);
}
public function save(Customer $customer): void
{
$this->gateway->save(
$this->mapper->toLegacyData($customer),
);
}
}Dans ce contrat illustratif, get() doit prévoir une exception définie pour un enregistrement absent, tandis que save() ne précise pas le moment de validation d’une transaction. La passerelle et le mapper sont des dépendances du projet, pas des API Doctrine.
La conversion doit préserver les champs gérés par d’autres processus et protéger contre l’écriture d’un état périmé. Pendant la coexistence, chaque champ doit avoir un système responsable. Un modèle de domaine distinct et une interface de dépôt sont utiles lorsqu’ils réduisent une vraie contrainte de migration, pas comme couches obligatoires pour chaque entité.
Protéger le contrat public de l’API séparément
Un OrderDetailsDto interne peut être converti en réponse publique, par exemple PublicOrderDto. Cette séparation aide à éviter que des changements de stockage se répercutent dans l’API :
final readonly class PublicOrderDto
{
/**
* @param list<PublicOrderLineDto> $lines
*/
public function __construct(
public int $id,
public string $number,
public string $status,
public string $currency,
public array $lines,
public string $createdAt,
) {
}
}Le type string de createdAt n’impose aucun format de date. Le PHPDoc décrit les éléments de la liste pour l’analyse statique ; il ne valide pas les données entrantes.
Un petit test de réponse reste utile :
it('renvoie les champs publics requis', function (): void {
$client = static::createClient();
$client->request('GET', '/api/en/orders/1001');
self::assertResponseIsSuccessful();
$payload = json_decode(
(string) $client->getResponse()->getContent(),
true,
512,
JSON_THROW_ON_ERROR,
);
expect($payload)
->toBeArray()
->toHaveKeys([
'id',
'number',
'status',
'currency',
'lines',
'createdAt',
]);
});Cet extrait Pest suppose une configuration fondée sur Symfony WebTestCase, une commande connue dans les données de test et l’authentification éventuellement requise. Il vérifie une réponse réussie, un JSON valide et la présence de clés. Il ne protège pas l’ensemble du contrat.
Les tests de compatibilité peuvent aussi devoir couvrir les codes de statut exacts, les types de valeurs, la possibilité de null, les formats de date, les refus d’accès, les réponses d’erreur, l’ordre, la pagination et les champs traduits. Le contrat existant reste la référence, sauf changement explicitement convenu avec ses consommateurs.
Utiliser les types et les tests d’architecture à bon escient
PHPStan peut révéler des incohérences aux frontières des adaptateurs. Les extraits suivants sont des déclarations de méthodes d’interfaces, pas des fonctions autonomes. Le premier décrit une liste d’objets représentant des lignes de données propres au projet :
/**
* @return list<LegacyCustomerRow>
*/
public function findLegacyCustomersForMigration(
int $afterId,
int $limit,
): array;Un contrat de mapper peut plutôt décrire une structure précise de tableau :
/**
* @return array{
* id: int,
* email: string,
* legacy_status: string|null,
* created_at: string
* }
*/
public function toLegacyData(Customer $customer): array;Le mapper doit toujours valider et interpréter correctement les données sources. Des annotations précises facilitent l’analyse, mais ne prouvent pas qu’un ancien statut a reçu le bon sens métier. PHPStan a aussi besoin d’une configuration adaptée ou de règles supplémentaires pour vérifier les choix architecturaux du projet.
Les tests d’architecture Pest peuvent exprimer certaines restrictions de dépendances :
arch('le cœur ne dépend pas de l’ancienne infrastructure')
->expect('App\Core')
->not->toUse('App\Infrastructure\Legacy');
arch('les contrôleurs API excluent EntityManagerInterface')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('le cœur ne dépend pas du SDK du fournisseur')
->expect('App\Core')
->not->toUse('Vendor\ExternalSdk');Les espaces de noms sont illustratifs ; Vendor\ExternalSdk est un nom de substitution. Il faut les adapter au code réel et à la version installée des outils de test d’architecture Pest. La règle des contrôleurs interdit une dépendance directe à EntityManagerInterface, pas toute forme d’accès à la base. La règle du SDK l’exclut uniquement de App\Core ; elle ne démontre pas que tous ses usages restent dans l’infrastructure. La résolution dynamique de services et les effets indirects nécessitent encore une revue.
Mesurer tout le chemin de lecture, pas seulement compter les requêtes
Les chiffres suivants pour GET /api/en/products sont entièrement hypothétiques. Ils illustrent un compromis, pas un benchmark GiSoft :
Mesure Existant Candidat
Requêtes SQL 8 3
Temps en base 75 ms 110 ms
Sérialisation 60 ms 190 ms
Temps de réponse P95 280 ms 430 ms
Mémoire 42 Mo 118 MoDes jointures plus volumineuses, davantage d’hydratation ou des conversions plus coûteuses peuvent annuler le bénéfice d’un nombre réduit de requêtes. Les temps partiels présentés ne doivent pas être additionnés pour calculer le P95 : ce percentile décrit la distribution des temps de réponse complets.
Une comparaison utile repose sur des données, des volumes de réponse, un profil de trafic, une concurrence, un état de cache et un environnement comparables. Il faut mesurer des cas historiques représentatifs en plus du chemin courant, et convenir des taux d’erreur et délais acceptables avant le déploiement.
Délimiter les changements assistés par IA
Un assistant de programmation peut aider à extraire un adaptateur ou à modifier les appelants. Un code plausible peut pourtant changer le sens de null, une branche de remise ou un champ d’API. La revue doit porter sur le comportement réel, pas seulement sur la netteté des modifications.
Une tâche bien définie précise les contrats publics et métier, renvoie aux tests de caractérisation et limite la modification à une frontière. Les tests manquants doivent être ajoutés avant de modifier le comportement qu’ils sont censés protéger. Les changements de requêtes demandent des mesures. Les tests ciblés et l’analyse statique doivent inclure les dépendances et appelants concernés, pas automatiquement les seuls fichiers modifiés.
Les changements de logique financière, de permissions ou de schéma nécessitent l’accord d’une personne responsable. Il faut consigner ce qui a été vérifié et ce qui reste incertain. L’assistance par IA ne remplace pas cette décision.
Quand une réécriture complète mérite d’être envisagée
Une réécriture peut être préférable si le produit a profondément changé, si un petit système est bien compris ou si la plateforme actuelle ne peut satisfaire des exigences opérationnelles importantes à un coût acceptable. À l’inverse, des règles inconnues et une migration de données difficile peuvent rendre le nouveau départ coûteux. Aucun de ces constats ne tranche la question à lui seul.
Des questions concrètes sont plus utiles qu’une notation binaire :
- Que faut-il garder compatible ? Un remplacement volontairement réduit n’est pas une promesse de tout reproduire.
- Où peut-on isoler le changement ? Des frontières utiles favorisent le remplacement par étapes ; leur création a aussi un coût.
- Comment déplacer les données et les écritures ? Une coexistence difficile complique les deux approches, tandis qu’une bascule unique concentre le risque.
- Que peut exploiter et vérifier l’organisation ? Il faut considérer les effectifs, les contraintes réglementaires, la recette utilisateur, les évolutions courantes et les possibilités de reprise.
Une sauvegarde de base n’est pas un plan complet de retour arrière : la restaurer peut perdre des écritures ultérieures ou contredire des actions externes déjà effectuées. La procédure de reprise ou de correction sur le nouveau système doit être répétée avant la bascule, avec des critères d’acceptation définis.
Un premier pas concret
Choisissez une fonction importante et bien délimitée, puis documentez son comportement avec les développeurs et les responsables du processus. Protégez les cas nécessaires, attribuez la responsabilité des données et introduisez uniquement la frontière requise pour le remplacement. Essayez le candidat sur un périmètre limité, avec des conditions explicites d’acceptation et de reprise.
Les enseignements de cet essai doivent faire évoluer le plan global. Le travail sur le produit reste visible aux côtés de la migration ; le code transitoire est retiré quand plus aucun appelant ni aucune dépendance de données ne l’exige. Une petite étape vaut par la question qu’elle permet de résoudre, pas par sa taille seule.
La modernisation doit préserver les comportements métier nécessaires et l’intégrité des données tout en facilitant les changements à venir. Elle peut conduire à un remplacement progressif ou à une nouvelle application. Le choix doit reposer sur ce que l’on sait de ce système, pas sur une préférence pour l’ancien ou le nouveau code.
Références
Ces sources décrivent les techniques de migration citées et la syntaxe des outils. Les exemples de commandes et d’adresses sont illustratifs ; ils ne décrivent pas une réalisation client vérifiée.
- Martin Fowler, Branch by Abstraction : https://martinfowler.com/bliki/BranchByAbstraction.html
- Martin Fowler, Strangler Fig : https://martinfowler.com/bliki/StranglerFigApplication.html
- Danilo Sato, Parallel Change (expand-and-contract) : https://martinfowler.com/bliki/ParallelChange.html
- PHPStan, types PHPDoc : https://phpstan.org/writing-php-code/phpdoc-types
- Pest, tests d’architecture : https://pestphp.com/docs/arch-testing
