Du code que l’on peut modifier : héritage, polymorphisme, SOLID et reprise d’un système existant
Un atelier pour créer et reprendre du code : choisir l’héritage à bon escient, concevoir le polymorphisme, appliquer SOLID sans excès et refactoriser un système inconnu en sécurité.
L’article complet est disponible ci-dessous. Activez JavaScript pour les quiz et les simulations.
Atelier pratique d’architecture
Un code est bon quand la prochaine modification ne demande plus de courage.
Nous allons reprendre un module inconnu de contrôle des déploiements. Nous retrouverons d’abord son contrat, distinguerons l’héritage de la composition, utiliserons le polymorphisme, puis accompagnerons la modification jusqu’aux tests et à sa validation en production.
Il est facile d’écrire du code que personne n’utilise encore. Ajouter une fonction à un système qui génère des revenus, envoie des messages, traite des paiements et porte plusieurs années de décisions prises dans l’urgence est une autre affaire.
Cet atelier n’est pas un catalogue de définitions. Nous partirons d’un cas concret : vous reprenez un module de contrôle des déploiements. Il contient des classes de base, des méthodes redéfinies, un grand switch, quelques exceptions utilisées dans le déroulement « normal » et des tests qui vérifient surtout les mocks. Vous devez ajouter un contrôle de sécurité sans arrêter accidentellement la production.
1. Une petite modification dans du code inconnu
La demande est simple : « ajouter un contrôle qui vérifie, avant le déploiement, que les secrets de production proviennent du coffre ». Dans le dépôt, vous trouvez une classe ReleaseCheckBase, dont huit autres classes héritent. L’une redéfinit une méthode et renvoie null, une autre lève NotSupportedException, et une troisième exige d’initialiser un champ protégé de la classe de base.
Ce sur quoi les consommateurs s’appuient réellement, pas ce qui était censé fonctionner.
Base de données, réseau, fichiers, file de messages, cloud, e-mail et télémétrie.
Le comportement métier ou seulement l’ordre des appels aux mocks ?
Un adaptateur, une petite interface, un handler ou un feature flag plutôt qu’une réécriture massive.
Décision 1 sur 4
Par quoi commencez-vous ?
Vous avez deux heures, un module inconnu et une tâche destinée à la production.
Je réorganise les dossiers et les noms
Je reconstitue le parcours et protège le comportement
Je réécris le module
Choisissez une action et découvrez sa conséquence.
2. L’héritage est un engagement fort
L’héritage crée une relation bien plus forte que le simple partage de code. La classe dérivée reçoit le cycle de vie, l’état, les hypothèses et les futures modifications de la classe de base. La question n’est donc pas « ces classes ont-elles des champs communs ? », mais « chaque classe dérivée peut-elle remplacer le type de base partout où il est attendu, sans surprise ? ».
public abstract class FileStore
{
public abstract Task<byte[]> Read(string key, CancellationToken ct);
public abstract Task Save(string key, byte[] data, CancellationToken ct);
}
public sealed class ReadOnlyArchiveStore : FileStore
{
public override Task<byte[]> Read(string key, CancellationToken ct)
=> archive.Read(key, ct);
public override Task Save(string key, byte[] data, CancellationToken ct)
=> throw new NotSupportedException("Archiwum jest tylko do odczytu.");
}
ReadOnlyArchiveStore n’est pas un véritable FileStore, puisqu’une partie de la promesse du type de base n’est plus tenue. Le code appelant doit connaître le type concret ou intercepter une exception : le polymorphisme n’est alors qu’apparent.
Lecture, écriture et suppression sont imposées à chaque classe dérivée, même lorsqu’elles n’ont aucun sens.
IFileReader et IFileWriter décrivent exactement ce dont le consommateur a besoin.
public interface IFileReader
{
Task<byte[]> Read(string key, CancellationToken ct);
}
public interface IFileWriter
{
Task Save(string key, byte[] data, CancellationToken ct);
}
public sealed class ArchiveStore : IFileReader { /* ... */ }
public sealed class BlobStore : IFileReader, IFileWriter { /* ... */ }
Quand l’héritage est-il un bon choix ?
- La relation « est un » est vraie dans le domaine, pas seulement dans la structure du code.
- Le contrat de base est petit, stable et couvert par un test de contrat.
- La classe dérivée n’affaiblit pas le résultat et n’exige pas de conditions d’entrée supplémentaires.
- La classe de base expose peu d’état mutable protégé.
- Chaque implémentation conserve la même sémantique pour les erreurs, l’annulation et les valeurs absentes.
Un bon cas : le squelette stable d’un processus
Template Method convient lorsque l’ordre des étapes fait partie d’un contrat stable et que la portion extensible est petite et précisément décrite. Il faut toujours un test commun à toutes les classes dérivées. Si elles commencent à contourner des étapes ou à les réordonner, la composition sera plus lisible.
Décision 2 sur 4
Une implémentation lève « opération non prise en charge ». Qu’est-ce que cela indique généralement ?
Il suffit de documenter l’exception
Le contrat est trop large
Il faut une classe de base supplémentaire
Vérifiez si la relation respecte le principe de substitution de Liskov.
3. Polymorphisme : une décision, plusieurs exécutions valides
Le polymorphisme est utile lorsque le code appelant connaît le contrat sans devoir connaître les types concrets. Il ne devrait pas être doublé d’un autre mécanisme de sélection caché dans un switch.
public interface IReleaseCheck
{
string Code { get; }
Task<CheckResult> Evaluate(ReleaseContext context, CancellationToken ct);
}
public sealed class SecretSourceCheck : IReleaseCheck
{
public string Code => "secret_source";
public async Task<CheckResult> Evaluate(
ReleaseContext context,
CancellationToken ct)
{
var bindings = await context.Secrets.GetBindings(ct);
var invalid = bindings
.Where(x => x.Environment == "production" && !x.FromVault);
return invalid.Any()
? CheckResult.Block(Code, "Les secrets de production doivent provenir du coffre.")
: CheckResult.Pass(Code);
}
}
public sealed class ReleaseGate
{
private readonly IReadOnlyList<IReleaseCheck> checks;
public ReleaseGate(IEnumerable<IReleaseCheck> checks)
=> this.checks = checks.ToList();
public async Task<ReleaseDecision> Evaluate(
ReleaseContext context,
CancellationToken ct)
{
var results = new List<CheckResult>();
foreach (var check in checks)
results.Add(await check.Evaluate(context, ct));
return ReleaseDecision.From(results);
}
}
ReleaseGate ne connaît ni le contrôle des secrets, ni celui des migrations, ni celui du contrat API. Il connaît un contrat unique et rassemble les résultats. Un nouveau comportement devient une nouvelle implémentation, sans ajouter une branche au cœur stable du système.
4. La composition plutôt qu’un arbre profond
Dans un arbre d’héritage, la modification de la classe de base se propage verticalement aux classes dérivées. Avec la composition, un objet reçoit de petits collaborateurs et exprime explicitement ses besoins.
Arbre profond
Composition explicite
La composition ne signifie pas « une interface pour chaque classe ». Introduisez des frontières là où il existe une variation, un effet de bord ou un besoin de substitution contrôlée dans un test.
5. SOLID sans dogmatisme
Combien de raisons indépendantes ce module a-t-il de changer ?
Un nouveau cas impose-t-il de modifier le cœur stable ?
Chaque implémentation tient-elle la promesse du contrat ?
Le client dépend-il uniquement de la capacité dont il a besoin ?
La règle dépend-elle d’un concept ou d’un SDK concret ?
Si ReleaseService valide une politique, appelle Azure, enregistre un audit, met en forme un e-mail et génère du HTML, il a de nombreuses raisons de changer. Les séparer n’est pas une question d’esthétique : cela réduit le périmètre à comprendre et à tester lors de la prochaine évolution.
6. Construire un nouveau module de zéro
« Le système bloque la release si un secret de production ne vient pas d’une source approuvée. » Ne commencez pas par le nom d’une classe.
Réussite, blocage, données absentes, timeout, annulation : les exemples révèlent le contrat.
Une règle pure et un adaptateur de fournisseur distinct rendent les tests plus rapides et les erreurs plus claires.
Transmettez les données utiles à la décision, pas tout le client cloud ni le conteneur de services.
Règle, entrée, audit, métrique et test. La deuxième implémentation seulement confirmera l’abstraction commune.
Test, log corrélé, métrique, message pour l’opérateur et procédure de retour arrière.
Décision 3 sur 4
Vous n’avez qu’une implémentation et aucune variation confirmée. Que faites-vous ?
Je construis immédiatement un système de plugins
Je commence simplement et garde un point de séparation clair
Je relie directement la règle au SDK
Choisissez un niveau d’abstraction adapté à ce que vous savez aujourd’hui.
7. Un code propre n’est pas nécessairement court
Une fonction de douze lignes peut mélanger trois responsabilités. Une autre, de trente lignes, peut guider le lecteur dans un seul algorithme cohérent. Évaluez le cheminement des décisions plutôt que le nombre de lignes.
EvaluateRelease en dit davantage que ProcessData.
Moins d’état caché signifie moins de conditions à garder en tête.
Une décision métier négative doit être un résultat explicite.
Le nom et les dépendances indiquent que le code utilise le réseau ou la base.
Ne recopiez pas la même validation dans le contrôleur, le job et le frontend.
Ne journalisez pas des objets entiers contenant des secrets ou des données personnelles.
Le meilleur nom décrit la décision prise par le code, pas la construction du langage utilisée.
8. Reprendre le code d’une autre personne
Ne commencez pas par reformater le code ou déplacer des dossiers. Construisez d’abord une carte des comportements. À ce stade, la patience vaut davantage que la vitesse de frappe.
Notez les versions, la configuration, les migrations et un smoke test minimal.
Entrées, sorties, données, intégrations, files et planifications.
Qu’est-ce qui ne doit jamais arriver ?
Ajoutez des tests caractérisant le comportement actuel.
Adaptateur, handler, interface ou feature flag.
Petit commit, release contrôlée et observation.
[Fact]
public async Task Legacy_gate_blocks_when_any_required_check_fails()
{
var checks = new IReleaseCheck[]
{
StubCheck.Pass("contract"),
StubCheck.Block("secrets", "missing vault binding")
};
var decision = await new ReleaseGate(checks)
.Evaluate(Context.Valid(), CancellationToken.None);
decision.Allowed.Should().BeFalse();
decision.Blockers.Should().ContainSingle(x => x.Code == "secrets");
}
Un test de caractérisation n’affirme pas que l’ancien code est parfait. Il confirme que nous comprenons son comportement actuel. C’est une ceinture de sécurité pendant la réorganisation de la structure.
Décision 4 sur 4
Le refactoring et la nouvelle fonctionnalité touchent les mêmes fichiers. Comment réduisez-vous le risque ?
Je regroupe tout dans une grande pull request
Je sépare protection, refactoring et fonctionnalité
Je modifie le code maintenant et écrirai les tests plus tard
Choisissez une stratégie qui facilite la revue et le retour arrière.
9. Signaux d’alerte dans un système hérité
| Signal | Ce qu’il peut indiquer | Première action sûre |
|---|---|---|
| Arbre d’héritage profond | État caché et dépendances fragiles | Test de contrat pour les classes dérivées |
switch selon le type | Polymorphisme apparent | Registre d’implémentations derrière un contrat |
NotSupportedException | Violation du LSP | Séparer le contrat en capacités |
| Service utilisant de nombreux SDK | Multiples raisons de changer | Séparer la décision des adaptateurs |
| Beaucoup de mocks, aucune intégration | Contrat système mal connu | Test de caractérisation à la frontière |
Utils et Common | Responsabilité sans propriétaire clair | Déplacer l’opération vers le bon concept |
| Booléen qui pilote une méthode | Deux opérations sous un même nom | Commandes ou stratégies distinctes |
| Journalisation d’objets entiers | Risque pour les données personnelles et les secrets | Modèle explicite des données télémétriques |
10. Les mêmes décisions dans différents langages
La syntaxe change, mais les frontières de responsabilité et les contrats restent essentiels.
| Intention | C# | Java/Kotlin | TypeScript | Python |
|---|---|---|---|---|
| Petit contrat | interface | interface | interface ou type structurel | Protocol / ABC |
| Ensemble fermé de résultats | record + enum | sealed class | discriminated union | dataclass + enum |
| Composition des comportements | constructeur | constructeur | fonctions ou objets | dépendances explicites |
| Test de contrat | fixture commune | contract test | suite de tests commune | tests paramétrés |
| Adaptateur d’infrastructure | implémentation du port | implémentation du port | objet respectant le type | objet respectant Protocol |
Les questions essentielles sont universelles : où est la décision, où est l’effet de bord, quel est le contrat et comment prouver qu’un remplacement de comportement est sûr ?
11. Tester le polymorphisme
Chaque implémentation renvoie un résultat avec son propre code.
Chacune respecte l’annulation de l’opération.
Aucune ne renvoie null à la place d’un résultat.
Aucune ne modifie le contexte d’entrée.
L’absence de données possède un statut commun explicite.
Le message ne divulgue ni secrets ni données personnelles.
L’erreur du fournisseur est distincte d’un blocage métier.
Le test de contrat commun fonctionne pour chaque type.
12. Ne surdimensionnez pas l’architecture
Ne créez pas une interface simplement parce qu’une classe existe. N’ajoutez pas de fabrique si le constructeur et le conteneur DI suffisent. Ne construisez pas un système de plugins pour deux cas stables. Ne découpez pas une méthode uniquement pour que chaque fragment fasse cinq lignes.
Une abstraction devient rentable lorsqu’elle réduit le coût d’une évolution réelle ou isole un véritable effet de bord. Une bonne architecture est un peu ennuyeuse : les dépendances sont explicites, le parcours est traçable et une nouvelle personne peut prévoir où se fera la prochaine modification.
Parcours interactif
Faites passer une modification par cinq contrôles de sécurité
Lancez l’animation et découvrez l’ordre qui réduit le risque lorsque vous reprenez du code inconnu.
Lancer le parcours
Prêt à démarrer.
Liste de vérification avant modification
- Je peux décrire le comportement en une phrase.
- Je connais l’invariant à préserver.
- Je sais quelle entrée déclenche ce code.
- Je vois tous les effets de bord.
- J’ai un test du comportement actuel.
- J’ai choisi consciemment la composition ou l’héritage.
- Chaque implémentation respecte le même contrat.
- La nouvelle abstraction répond à un véritable axe de variation.
- Les logs et les métriques permettront de vérifier la production.
- Le rollback n’impose pas d’annuler une migration destructrice.
L’idée à retenir
Un bon code n’est pas impossible à casser. C’est un code qui rend le risque de modification visible assez tôt. L’héritage peut bien modéliser un contrat stable. Le polymorphisme peut supprimer des sélections centralisées. SOLID peut aider à nommer les couplages. Aucune de ces techniques ne remplace toutefois la compréhension du comportement, les tests et l’observation après déploiement.
Quand vous partez de zéro, construisez à partir du résultat et des frontières. Quand vous reprenez un système existant, observez d’abord, protégez son comportement, trouvez un point de séparation, puis changez la structure.
À emporter dans votre projet
Cinq questions plus importantes que le langage choisi
Quelle décision métier ce code protège-t-il ?
Peut-on substituer les types sans exception au contrat ?
Où la règle s’arrête-t-elle et où commence l’infrastructure ?
Quel test protège le comportement pendant le refactoring ?
Quel signal confirmera la modification après déploiement ?
Votre code est difficile à modifier sans risque ?
Commencez par cartographier le comportement et les risques, avant de réécrire le système.
Une brève description du module, de l’incident ou du changement prévu suffit pour définir une première étape technique.
Décrire le problème technique