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.

01Comprendrecomportement et risques
02Protégertest de caractérisation
03Séparercontrat et effets de bord
04Modifierpetite tranche verticale
05Confirmermétrique et rollback

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.

ContratQue promet actuellement le module ?

Ce sur quoi les consommateurs s’appuient réellement, pas ce qui était censé fonctionner.

Effets de bordOù le système agit-il sur le monde extérieur ?

Base de données, réseau, fichiers, file de messages, cloud, e-mail et télémétrie.

PreuveQue protègent les tests ?

Le comportement métier ou seulement l’ordre des appels aux mocks ?

Le plus petit pasOù créer un point de séparation sûr ?

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.

AvantUn contrat trop large

Lecture, écriture et suppression sont imposées à chaque classe dérivée, même lorsqu’elles n’ont aucun sens.

→
AprèsDes capacités plutôt que des étiquettes

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.

EntréeReleaseContextenvironnement, artefact, dépendances
01Secrets
02Database
03Contract
RésultatReleaseDecisionpass, warn ou block

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

BaseReleaseCheck
CloudReleaseCheck
AzureReleaseCheck
ProductionAzureReleaseCheck

Composition explicite

ReleaseGate
IReleaseCheck[]IClockIAuditWriterIReleasePolicy

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

SSingle Responsibility

Combien de raisons indépendantes ce module a-t-il de changer ?

OOpen/Closed

Un nouveau cas impose-t-il de modifier le cœur stable ?

LLiskov Substitution

Chaque implémentation tient-elle la promesse du contrat ?

IInterface Segregation

Le client dépend-il uniquement de la capacité dont il a besoin ?

DDependency Inversion

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

01
Définir le résultat

« 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.

02
Écrire des exemples

Réussite, blocage, données absentes, timeout, annulation : les exemples révèlent le contrat.

03
Séparer la décision des entrées-sorties

Une règle pure et un adaptateur de fournisseur distinct rendent les tests plus rapides et les erreurs plus claires.

04
Choisir le contrat le plus étroit

Transmettez les données utiles à la décision, pas tout le client cloud ni le conteneur de services.

05
Construire une tranche verticale

Règle, entrée, audit, métrique et test. La deuxième implémentation seulement confirmera l’abstraction commune.

06
Définir les preuves de réception

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.

Les noms expriment l’intention

EvaluateRelease en dit davantage que ProcessData.

Les données circulent dans un sens

Moins d’état caché signifie moins de conditions à garder en tête.

Les exceptions restent exceptionnelles

Une décision métier négative doit être un résultat explicite.

Les effets de bord sont visibles

Le nom et les dépendances indiquent que le code utilise le réseau ou la base.

Une règle a un seul responsable

Ne recopiez pas la même validation dans le contrôleur, le job et le frontend.

La télémétrie a son propre modèle

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.

1Exécuter

Notez les versions, la configuration, les migrations et un smoke test minimal.

2Cartographier

Entrées, sorties, données, intégrations, files et planifications.

3Nommer les invariants

Qu’est-ce qui ne doit jamais arriver ?

4Protéger

Ajoutez des tests caractérisant le comportement actuel.

5Trouver un point de séparation

Adaptateur, handler, interface ou feature flag.

6Modifier et mesurer

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é

SignalCe qu’il peut indiquerPremière action sûre
Arbre d’héritage profondÉtat caché et dépendances fragilesTest de contrat pour les classes dérivées
switch selon le typePolymorphisme apparentRegistre d’implémentations derrière un contrat
NotSupportedExceptionViolation du LSPSéparer le contrat en capacités
Service utilisant de nombreux SDKMultiples raisons de changerSéparer la décision des adaptateurs
Beaucoup de mocks, aucune intégrationContrat système mal connuTest de caractérisation à la frontière
Utils et CommonResponsabilité sans propriétaire clairDéplacer l’opération vers le bon concept
Booléen qui pilote une méthodeDeux opérations sous un même nomCommandes ou stratégies distinctes
Journalisation d’objets entiersRisque pour les données personnelles et les secretsModè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.

IntentionC#Java/KotlinTypeScriptPython
Petit contratinterfaceinterfaceinterface ou type structurelProtocol / ABC
Ensemble fermé de résultatsrecord + enumsealed classdiscriminated uniondataclass + enum
Composition des comportementsconstructeurconstructeurfonctions ou objetsdépendances explicites
Test de contratfixture communecontract testsuite de tests communetests paramétrés
Adaptateur d’infrastructureimplémentation du portimplémentation du portobjet respectant le typeobjet 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

1Observerentrées et effets de bord
2Protégertest du comportement actuel
3Trouver un point de séparationcontrat et adaptateur
4Remplacerpetite tranche verticale
5Confirmermétrique et rollback

Prêt à démarrer.

Liste de vérification avant modification

  1. Je peux décrire le comportement en une phrase.
  2. Je connais l’invariant à préserver.
  3. Je sais quelle entrée déclenche ce code.
  4. Je vois tous les effets de bord.
  5. J’ai un test du comportement actuel.
  6. J’ai choisi consciemment la composition ou l’héritage.
  7. Chaque implémentation respecte le même contrat.
  8. La nouvelle abstraction répond à un véritable axe de variation.
  9. Les logs et les métriques permettront de vérifier la production.
  10. 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

01

Quelle décision métier ce code protège-t-il ?

02

Peut-on substituer les types sans exception au contrat ?

03

Où la règle s’arrête-t-elle et où commence l’infrastructure ?

04

Quel test protège le comportement pendant le refactoring ?

05

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