george-andrikopoulos/domain-java

Discipline d'ingénierie pour travailler en Java. Couvre : un chemin optimisé se déoptimise lorsque l'entrée change ; une vue n'est pas une copie ; un type wrapper n'est pas gratuit ici ; ferme ce que tu ouvres ; conçois pour l'héritage ou interdis-le ; l'égalité est un contrat unique ; les exceptions nomment ce qui a échoué ; l'identité n'est pas l'égalité pour les boîtes ; aucune référence aux internes ne s'échappe ; null n'est pas une valeur ; publie en toute sécurité ou pas du tout ; scelle les alternatives ; Serializable est un second constructeur.

Qu'est-ce que domain-java ?

Cette compétence établit des règles strictes pour le développement Java, visant à éviter les pièges courants liés au JIT, aux collections, aux wrappers, à la gestion des ressources, à l'héritage, à l'égalité, aux exceptions, aux types boxés, à l'encapsulation, à la gestion de null, à la publication sécurisée, au sealing et à la sérialisation. Elle est conçue pour les agents IA comme Claude Code, Cursor ou Codex, et son contenu est dérivé de recommandations d'experts comme Aleksey Shipilev et de pratiques Java standard. L'objectif est de guider un agent à produire du code Java prévisible, performant et maintenable, en mettant l'accent sur la compréhension des optimisations dynamiques du JIT, des contrats des collections, et des conséquences des choix de conception. La compétence fournit des points de contrôle pour chaque règle afin de vérifier les échecs potentiels, rendant le débogage et la revue de code plus systématiques. Elle s'applique à tout projet Java où la rigueur technique est essentielle.

Compatible avec~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/george-andrikopoulos/relearn/tree/HEAD/claude-pack/skills/domain-java

Installed? Explore more Développement et programmation skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

domaine : règles java

Un chemin optimisé sur les entrées d'hier se déoptimise lorsque celles d'aujourd'hui arrivent [R:a-speculated-path-deoptimises-when-the-input-changes]

Écrit d'après Aleksey Shipilev, "JVM Anatomy Quarks", à propos de la pollution de profil, de l'inlining et de la déoptimisation ; le mécanisme d'uncommon-trap de HotSpot.

Un JIT ne compile pas votre code. Il compile un pari sur votre code, et le pari est réglé au moment où le pari cesse d'être vrai.

HotSpot compile sur un profil : ce site d'appel n'a jamais vu qu'un seul type de récepteur, donc inline-le et protège-le ; cette branche n'a jamais été prise, donc ne l'émet pas ; ce champ n'a jamais été null, donc saute la vérification ; cette classe n'a jamais été chargée, donc suppose qu'aucune sous-classe n'existe. Chaque hypothèse reçoit une garde, et le chemin compilé est rapide précisément parce que les gardes sont peu coûteuses et les corps sont absents. Lorsqu'une garde échoue, la frame est déoptimisée : un piège peu commun, les frames de l'interpréteur sont reconstruites à partir des frames compilées, l'exécution continue en mode interprété, et on attend d'être recompilé avec le nouveau profil.

Le timing est ce qui en fait une règle de latence plutôt que de débit. Les hypothèses se brisent lorsque l'entrée change -- le type d'ordre inhabituel, la première exception de la journée, le format de message d'un nouveau lieu, la deuxième implémentation d'une interface chargée à la sixième heure. Ainsi, la pire latence que le chemin produira jamais est délivrée lors de l'événement le moins ordinaire, qui est généralement l'événement qui comptait le plus. Les tests de charge avec un trafic représentatif de la moyenne n'en reproduisent rien.

C'est aussi silencieux par défaut. Rien n'est enregistré, aucune métrique ne bouge, et la seule trace est un point aberrant de latence impossible à distinguer d'une pause de collecte ou d'un délai d'ordonnancement. -XX:+PrintCompilation et -XX:+TraceDeoptimization disent ce qui s'est passé ; JFR transporte les événements avec moins de surcharge et est celui qui vaut la peine d'être laissé actif.

Alors, réchauffez le chemin avec les cas inhabituels ainsi que les cas courants -- c'est là que cela rencontre [R:a-measurement-matches-the-regime-it-reports] : un échauffement construit uniquement à partir d'un trafic typique produit un profil que le trafic typique confirme et que le trafic atypique détruit. Gardez les sites d'appel chauds délibérément monomorphes ; un appel d'interface mégamorphique sur le chemin chaud est une barrière d'inlining permanente plutôt qu'un piège ponctuel. Chargez les classes dont le chemin aura besoin avant que le chemin ne soit en direct. Et traitez une lambda ou un site d'appel dynamique ajouté à un chemin chaud comme un nouveau profil qui doit être à nouveau gagné.

Vérification du mode d'échec : qu'est-ce que le JIT a supposé à propos de ce chemin, et que se passe-t-il la première fois que l'une de ces hypothèses est fausse ? Si la réponse honnête est que personne ne sait ce qui a été supposé, c'est la conclusion -- les hypothèses sont lisibles, et les lire est le travail.

Une vue de collection est une fenêtre sur les données de quelqu'un d'autre [R:a-view-is-not-a-copy]

Sachez si vous détenez des données ou une fenêtre sur des données. Le type statique est List<T> dans les deux cas et ne vous le dira jamais.

La bibliothèque retourne des vues bien plus souvent que la plupart des codes ne le supposent, et chacune a sa propre façon de surprendre :

  • Arrays.asList(a) est un wrapper de taille fixe sur le tableau. set écrit à travers vers le tableau ; add et remove lancent UnsupportedOperationException. List.of(...) est immuable et indépendant, et est généralement ce qui était voulu.
  • list.subList(from, to) est une fenêtre en direct. Les écritures passent à travers, et tout changement structurel du parent fait que la sous-liste lance ConcurrentModificationException lors de sa prochaine utilisation -- y compris l'idiome courant de prendre une sous-liste puis de vider le parent.
  • map.keySet(), values(), entrySet() sont des vues. keySet().remove(k) supprime l'entrée de la carte. C'est parfois ce que vous voulez et vaut toujours la peine d'être fait délibérément.
  • Collections.unmodifiableList(l) encapsule sans copier : le wrapper refuse les écritures et l'original fonctionne toujours, donc il défend contre l'appelant et non contre vous. Ce n'est pas une copie défensive, c'est pourquoi [R:no-reference-to-internals-escapes] utilise List.copyOf à la place.
  • Stream.toList() est non modifiable ; collect(Collectors.toList()) n'offre aucune telle garantie et peut ou non l'être. Ne vous fiez pas à la mutabilité de l'un ou l'autre.

Alors, indiquez ce que vous voulez au moment où vous le créez. List.copyOf lorsque le récepteur doit être indépendant, la vue lorsque le partage est l'intention et la durée de vie est courte. Une vue stockée dans un champ est la forme à examiner le plus attentivement : elle survit à l'expression qui l'a créée, et la collection sous-jacente peut changer en dessous à tout moment par la suite.

La même distinction régit l'itération. Modifier une collection tout en l'itérant lance ConcurrentModificationException même en mono-thread, car l'itérateur est aussi une vue — utilisez removeIf, ou itérez une copie, ou collectez les suppressions et appliquez-les après.

Vérification du mode d'échec, pour toute collection reçue ou retournée : si quelqu'un écrit là-dedans, qu'est-ce qui change d'autre ? Si vous ne pouvez pas répondre sans ouvrir le Javadoc de la méthode d'usine, copiez-la.

Un type wrapper coûte un objet jusqu'à ce que l'analyse d'échappement le supprime [R:a-wrapper-type-is-not-free-here]

Donnez aux concepts du domaine leurs propres types ici aussi — un OrderId qui ne peut pas être passé là où un AccountId appartient vaut autant en Java qu'ailleurs. Mais ne transportez pas l'argument de coût avec l'argument de conception, car un seul d'entre eux voyage.

En Rust, un newtype est une construction au moment de la compilation : struct OrderId(u64) est un u64 au moment de l'exécution et le compilateur le prouve. En Java, record OrderId(long value) est un objet — un en-tête, un champ, une référence à poursuivre, et une ligne de cache séparée de ce qui pointait vers lui. Le JIT peut le supprimer : l'analyse d'échappement plus le remplacement scalaire peut aplatir un wrapper qui ne s'échappe probablement pas de son unité de compilation, ce qui couvre une grande partie du code ordinaire. Ce qu'il ne couvre pas, c'est le cas qui vous intéresse — un wrapper stocké dans un champ, mis dans une collection, passé à travers une méthode que le JIT a refusé d'inliner, ou dans un tableau, où OrderId[] est un tableau de pointeurs et long[] est un tableau de nombres.

Ainsi, la discipline se divise selon l'endroit où la valeur vit :

  • À une limite d'API, et partout où l'exactitude est la préoccupation, utilisez le wrapper. Le bogue d'argument échangé qu'il prévaut vaut une allocation que le JIT supprimera généralement de toute façon.
  • À l'intérieur d'une boucle chaude, dans un grand tableau, ou dans une structure par message, mesurez avant de supposer que le wrapper a disparu. C'est [R:verify-the-abstraction-compiled-away] arrivant dans un langage où la décision de l'optimiseur est dynamique : -XX:+PrintInlining, un profil d'allocation JFR, ou un dump d'analyse d'échappement dit ce qui s'est réellement passé, et la réponse peut différer entre les exécutions du même binaire.
  • Là où il ne disparaît pas et où le budget est réel, gardez le primitif et mettez la sécurité là où elle ne coûte rien — un paramètre nommé, un builder, une fabrique statique dont la signature ne peut pas être appelée de manière incorrecte.

La version de cette règle qui arrive vaut la peine d'être connue plutôt que d'être attendue : les classes de valeur de Project Valhalla sont conçues pour aplatir exactement ce wrapper par spécification plutôt que par la discrétion de l'optimiseur. Jusqu'à ce que le code s'exécute sur une JVM où c'est vrai, le coût est dynamique et doit être mesuré plutôt que supposé.

Vérification du mode d'échec, avant d'envelopper un primitif sur un chemin avec un budget : cet objet existe-t-il toujours après que le JIT a terminé, et qu'est-ce qui me l'a dit ? "C'est juste un wrapper" est la réponse Rust, et c'est celle qui ne se transfère pas.

Une ressource est fermée par la construction qui l'a ouverte, sur chaque chemin [R:close-what-you-open]

Ouvrez une ressource dans un en-tête try-with-resources et nulle part ailleurs. La construction ferme sur chaque sortie, dans l'ordre inverse, et — la partie qu'un finally écrit à la main ne peut pas faire — elle conserve l'exception d'origine lorsque la fermeture échoue également.

try (var conn = pool.take(); var stmt = conn.prepare(sql)) {
    return stmt.execute();
}

L'échec qu'un bloc finally introduit mérite d'être énoncé clairement car il semble correct. Si le corps lance et que close() lance également, l'exception du finally remplace celle du corps : le programme signale un échec de fermeture d'une connexion et ne dit rien sur la requête qui a échoué en premier. Try-with-resources à la place supprime l'échec de fermeture et l'attache à l'original, donc les deux survivent et la cause est celle que vous vouliez. Une fuite vous coûte un handle ; une cause perdue vous coûte l'investigation.

Trois cas où la construction ne s'applique pas et la discipline le fait toujours :

  • Une ressource dont la durée de vie est un champ, pas un bloc — un pool, un client, un exécuteur. L'objet propriétaire devient AutoCloseable et la discipline monte d'un niveau ; la chose à refuser est un champ qui est ouvert et jamais fermé par personne en particulier.
  • Un ExecutorService n'est pas fermé par shutdown() seul. shutdown() puis awaitTermination() puis shutdownNow() est la séquence, et la sauter laisse des threads non démons retenant la JVM.
  • Un Stream sur un fichier (Files.lines, Files.walk) tient un handle et doit être fermé. C'est le seul flux qui fuit, et il ressemble exactement à ceux qui ne le font pas.

N'utilisez pas un finaliseur ou Cleaner comme mécanisme principal. Ils s'exécutent à un moment non spécifié ou jamais, et ils existent pour attraper le cas où la discipline a déjà échoué.

Vérification du mode d'échec, pour chaque ressource : quelle construction ferme cela si la ligne suivante lance ? Si la réponse est un appel à close() plus loin dans la méthode, rien ne le fait.

Une classe est finale, ou son héritage est conçu et documenté [R:design-for-inheritance-or-forbid-it]

Rendez la classe final sauf si vous avez conçu pour une sous-classe, et si c'est le cas, dites exactement ce qu'une sous-classe peut surcharger et ce qu'elle ne doit pas.

L'héritage n'est pas le mécanisme de réutilisation qu'il semble être. Une sous-classe dépend de la façon dont les méthodes de la superclasse s'appellent les unes les autres — addAll appelant add est l'exemple canonique — et c'est un détail d'implémentation que la superclasse a le droit de modifier dans une version corrective. Ainsi, une sous-classe fonctionnelle se brise lorsque le parent est refactorisé, sans que l'un ou l'autre auteur fasse rien de mal. La composition n'a pas cette propriété : un wrapper dépend du contrat public et de rien d'autre.

Le cas du constructeur est pire que fragile, il est cassé dès le départ. Un constructeur qui appelle une méthode surchargeable exécute la surcharge de la sous-classe avant que les propres champs de la sous-classe ne soient assignés, donc la surcharge voit null et 0 dans des champs qu'elle a déclarés final et initialisés. Rien n'avertit. C'est le même risque de publication que [R:no-reference-to-internals-escapes] nomme comme this s'échappant, atteint par une route qui ressemble à une répartition de méthode ordinaire — et [R:publish-safely-or-not-at-all] est pourquoi cela compte même lorsqu'il n'y a qu'un seul thread.

Alors :

  • final par défaut sur les classes, et sur toute méthode qu'une sous-classe n'a pas à remplacer. Le scellement ([R:seal-the-alternatives]) est la forme plus forte où l'ensemble des sous-types est fermé et connu.
  • N'appelez jamais une méthode surchargeable depuis un constructeur, un initialiseur, ou clone() ou readObject(), qui sont des constructeurs portant d'autres noms.
  • Si une classe est conçue pour l'héritage, documentez l'auto-utilisation : quelles méthodes appellent lesquelles, lesquelles sont sûres à surcharger, ce qu'une sous-classe doit appeler. Cette documentation fait partie du contrat, et son coût est la raison honnête pour laquelle la plupart des classes devraient être final à la place.
  • Préférez la composition. Un wrapper qui contient l'instance et la transmet est plus long à écrire, immunisé contre les internes du parent, et c'est ce qu'il faut utiliser lorsque la motivation était la réutilisation plutôt que la substituabilité.

Vérification du mode d'échec, pour chaque classe non finale : lesquelles de mes méthodes s'appellent les unes les autres, et une sous-classe surchargeant l'une d'elles serait-elle encore correcte après que je les ai réorganisées ? Si cela ne peut pas être répondu, la classe était extensible par accident.

equals et hashCode sont une seule décision, et une clé mutable brise les deux [R:equality-is-one-contract]

Décidez de l'égalité une fois, implémentez les deux moitiés dans le même changement, et dérivez-les de champs qui ne peuvent pas changer tant que l'objet est accessible depuis une collection.

Le contrat est mécanique : des objets égaux doivent avoir des codes de hachage égaux. Brisez-le et un HashMap regarde dans le mauvais compartiment, donc get retourne null pour une clé qu'il contient et remove ne peut pas trouver d'entrée à supprimer -- une fuite dont la taille croît avec le trafic. Rien ne détecte cela. La collection se comporte exactement comme spécifié ; la spécification a été remise à un objet qui a menti.

La moitié de la clé mutable est celle qui survit à la revue, car la classe est correcte lorsqu'elle est écrite et devient incorrecte lorsque quelqu'un ajoute un setter. Un objet dont le hashCode dépend d'un champ qui est ensuite muté a changé de compartiment sans que la carte ne soit informée, et est maintenant inaccessible via la clé même sous laquelle il est classé. Donc un type utilisé comme clé est immuable, ou il n'est pas utilisé comme clé. Un record vous donne les deux moitiés générées à partir des composants et des champs finaux par construction, c'est pourquoi c'est la forme par défaut pour un type de valeur et pourquoi écrire ces deux méthodes à la main devrait sembler une décision plutôt qu'un code passe-partout.

Deux autres qui découlent du même contrat et qui valent la peine d'être énoncées car chacune a sa propre façon de mal tourner :

  • compareTo doit être cohérent avec equals partout où le type atteint un TreeMap ou un ensemble trié, qui utilisent l'ordre et n'appellent jamais equals du tout. Un comparateur disant que deux objets sont équivalents alors que equals dit qu'ils diffèrent produit un ensemble qui contient silencieusement l'un d'eux.
  • L'héritage et l'égalité ne composent pas. Un equals écrit avec instanceof accepte une sous-classe et est alors asymétrique ; un écrit avec getClass() est symétrique et rejette toute sous-classe. Il n'y a pas de troisième option, ce qui est l'une des raisons pour lesquelles [R:design-for-inheritance-or-forbid-it] préfère final.

Vérification du mode d'échec, avant qu'un type ne soit utilisé comme clé ou mis dans un ensemble : **sur quels champs son égalité

...

[suite tronquée]

... repose-t-elle, et l'un d'eux peut-il changer après la construction ?** Si la réponse n'est pas "aucun", le type n'est pas adapté comme clé de la manière dont il est actuellement écrit.

Les exceptions nomment ce qui a échoué, pas la chose qui a été tentée [R:exceptions-name-what-failed]

Le nom d'une exception doit dire ce qui n'a pas fonctionné, pas l'opération qui échouait. Le type définit la catégorie de l'échec ; le message doit permettre de le retrouver sans le message, si possible.

Les exceptions sont la partie de l'API qui est invisible jusqu'à ce qu'elle soit vue, et c'est une raison de s'arrêter plutôt qu'un simple signal. IllegalArgumentException ne signifie pas "l'argument n'est pas valide" — toute exception signifie cela — mais plutôt "l'argument viole une contrainte que javac n'a pas appliquée, et que le code appelant a choisi d'ignorer". NullPointerException lancée par une déréférence est une conséquence, pas une cause ; une IllegalArgumentException lancée délibérément parce qu'un paramètre était null lorsqu'il n'aurait pas dû l'être est un point d'arrêt où le programme s'est arrêté pour éviter d'agir sur des données invalides.

Les pratiques :

  • Utilisez les exceptions standard quand elles correspondent. IllegalArgumentException pour un paramètre invalide, IllegalStateException pour un objet dans un état invalide, UnsupportedOperationException pour une méthode qui ne peut pas être implémentée du tout, NullPointerException (Java 8+ peut l'inclure dans le message) pour un paramètre null qui était interdit — requireNonNull fait exactement cela et donne le nom du champ dans le message.
  • Donnez un message qui rend le point d'arrêt identifiable. Le nom de la méthode et le nom du paramètre, ou un résumé de l'état qui a échoué. "expected non null" ne dit rien à personne ; "accountId must not be null in getAccount" dit tout.
  • Ne lancez pas depuis un constructeur une exception qui ne soit pas initialisable. Un constructeur qui lance laisse l'objet partiellement construit. ExceptionInInitializerError vient implicitement des initialiseurs statiques qui échouent, et ce n'est jamais une exception métier.
  • Considérez une exception comme un point d'arrêt, pas un signal de flux de contrôle. Une exception lancée et attrapée dans le même module est généralement une utilisation incorrecte — [R:a-speculated-path-deoptimises-when-the-input-changes] explique le coût, et la lisibilité est également négative.

Vérification du mode d'échec, pour chaque exception lancée : si quelqu'un lit le message de cette exception dans un journal, peut-il dire exactement ce qui n'a pas fonctionné sans connaître le code ? Si le message est "An error occurred", la réponse est non.

L'identité n'est pas l'égalité pour les types boxés [R:identity-is-not-equality-for-boxes]

Utilisez .equals() pour comparer des objets ; utilisez == pour comparer des primitives, ou pour tester l'identité de référence lorsque vous voulez dire exactement cela. Les types boxés (Integer, Long, Boolean, et leurs frères) sont des objets, pas des valeurs, même lorsqu'ils se comportent comme tels sur les petits nombres à cause de caches internes.

// Peut être vrai, selon la JVM et le nombre.
Integer a = 100; Integer b = 100; System.out.println(a == b); // Very likely true
Integer x = 200; Integer y = 200; System.out.println(x == y); // Very likely false

Ce n'est pas aléatoire : Integer.valueOf met en cache les valeurs de -128 à 127 par défaut. Mais elle met en cache seulement par commodité, pas par contrat. Le résultat de == sur des types boxés est donc un comportement indéfini de la JVM — il fonctionne pendant les tests et échoue en production, ou l'inverse, selon le nombre. Long, Short, Byte ont leur propre cache ; Boolean.TRUE et Boolean.FALSE sont garantis d'être les mêmes références, donc == fonctionne toujours pour les booléens mais c'est une habitude qui ne se transfère pas.

// Comparaison correcte avec des types boxés
Integer a = 100; Integer b = 100;
if (a.equals(b)) { ... } // Toujours correct
// Ou déboîter d'abord
if (a.intValue() == b.intValue()) { ... } // Toujours correct

La règle est simple : les types boxés obtiennent .equals(). Les seules exceptions sont Boolean garanti par cache, et les types énumérés où == est sûr et préféré. Mais les énumérations ne sont pas boxées, donc elles sont couvertes par [R:identity-is-not-equality-for-boxes] seulement indirectement.

Ce qui est en jeu ici, c'est l'habitude de copier == depuis les primitives. int == int est sémantiquement correct ; Integer == Integer peut toujours être vrai pendant les tests et toujours faux en production, ce qui constitue un bogue qui dépend de l'heure de la journée. [R:measurement-matches-the-regime-it-reports] s'applique au profil de performance, mais ici, == est un bogue qui attend, et aucune mesure ne le révélera avant que les nombres dépassent 127.

Vérification du mode d'échec, pour toute comparaison impliquant un type boxé : est-ce que == est utilisé ou .equals() ? Si c'est ==, et que l'intention n'était pas l'identité de référence, échouez.

Aucune référence aux internes ne s'échappe [R:no-reference-to-internals-escapes]

Ne retournez pas une référence à un état mutable que vous possédez. Copiez-le, rendez-le immuable, ou refusez l'accès.

Une classe qui retourne son tableau interne est livrée à chaque appelant : getItems() retournant un List<T> que le champ sous-jacent a également créé signifie que l'appelant mute l'état interne de l'objet, et l'objet ne le sait pas. C'est une violation de l'encapsulation. Pire, c'est silencieux : la classe est correcte lorsqu'elle est seule et incorrecte en composition, le bogue venant de la classe propriétaire, pas de l'appelant.

// Mauvais : la référence interne s'échappe
public class Inventory {
    private final List<Item> items = new ArrayList<>();
    public List<Item> getItems() {
        return items; // L'appelant peut maintenant muter la liste interne
    }
}

// Correct : retourner une copie
public List<Item> getItems() {
    return new ArrayList<>(items); // Indépendant
}

// Correct : retourner une vue non modifiable
public List<Item> getItems() {
    return Collections.unmodifiableList(items); // Protège contre l'appelant
}

// Correct : retourner un flux (si seul l'accès en lecture est nécessaire)
public Stream<Item> items() {
    return items.stream(); // Stream est consommable, pas une référence persistante
}

Collections.unmodifiableList protège contre l'appelant mais pas contre le propriétaire. List.copyOf protège des deux côtés, et c'est typiquement ce que vous voulez après la construction. Si la collection interne doit être modifiable et que vous voulez toujours protéger les appelants, retournez une copie de la totalité ou une vue qui ne peut pas écrire.

La forme la plus subtile est this s'échappant via un constructeur : passer this à une autre méthode avant que le constructeur ne termine, ou enregistrer un auditeur qui a this comme état. C'est traité dans [R:design-for-inheritance-or-forbid-it] et [R:publish-safely-or-not-at-all], mais le principe est le même : ne partagez pas une référence à un objet qui n'est pas encore complètement construit, et ne partagez pas une référence à un état mutable que vous possédez.

Vérification du mode d'échec, pour chaque méthode retournant un objet : est-ce une copie défensive, une vue immuable, ou une référence directe ? Si c'est une référence directe à un état mutable, c'est un bogue.

null n'est pas une valeur, c'est l'absence de valeur [R:null-is-not-a-value]

Traitez null comme un état non valide pour les paramètres et les valeurs de retour, sauf indication contraire explicite. null n'est pas un signal de valeur spécial ; c'est l'absence de valeur.

Utilisez Optional<T> pour signaler l'absence d'une valeur de retour. Optional est une
déclaration de type explicite : cet appel peut ne rien retourner, et l'appelant doit décider quoi faire. Le type de retour, Optional<Account>, dit : le compte peut ne pas exister. Un type de retour Account qui peut être null n'est pas détectable par le type, donc le code appelant doit vérifier, mais le langage ne l'exige pas, donc il ne le fait pas.

// Bon : Optional déclare l'absence
public Optional<Account> findAccount(String id) {
    return Optional.ofNullable(repo.get(id));
}

// Mauvais : null retourné sans avertissement
public Account findAccount(String id) {
    return repo.get(id); // pourraie être null, mais le type ne le montre pas
}

Pour les paramètres, Optional n'est pas un type de paramètre idiomatique ; au lieu de cela, utilisez requireNonNull pour vérifier que les paramètres qui ne devraient pas être null ne le sont pas, et documentez quand null est autorisé. null n'est jamais un signal de valeur spécial car il ne peut pas être destructuré ; il faut le vérifier manuellement, et la vérification peut être oubliée. Utilisez des classes de valeur ([R:a-wrapper-type-is-not-free-here]) lorsqu'un signal de valeur spécial est vraiment nécessaire (par exemple, -1 pour "non trouvé" dans une méthode de recherche où la valeur zéro est valide).

La règle s'applique également aux collections : Map.get(key) retourne null si la clé n'existe pas. Ne comptez jamais sur une implémentation de Map qui ne permet pas null (comme ConcurrentHashMap), car cela signifie que le contrat d'interface promet une chose et l'implémentation en livre une autre. Utilisez containsKey ou gérez l'absence via computeIfAbsent ou une valeur par défaut.

Vérification du mode d'échec, à chaque fois qu'un paramètre ou une valeur de retour peut être null : si cela peut être null, est-ce documenté et l'appelant est-il obligé de le vérifier ? Si la réponse est non, la conception est peu fiable.

Publiez des objets en toute sécurité ou pas du tout [R:publish-safely-or-not-at-all]

Une référence à un objet mutable publiée sans synchronisation appropriée signifie que l'état que vous voyez peut être obsolète ou incohérent. La sécurité du thread ne s'applique pas seulement aux variables partagées ; elle s'applique également aux champs final qui ne sont pas encore, à la construction d'objets et à la publication.

Un objet publié en toute sécurité garantit que tous les threads voient le même état. Les mécanismes :

  • Champs final : un constructeur qui assigne des champs final et qui ne laisse pas this s'échapper garantit que tout thread voyant l'objet voit correctement ces champs. Ceci est garanti par le JMM pour les types correctement construits.
  • volatile : lire un champ volatile garantit que tous les threads voient la dernière écriture, mais implique une barrière mémoire à chaque accès.
  • AtomicReference : fournit une publication de référence sécurisée avec des méthodes comme set et get.
  • Synchronisation : les blocs ou méthodes synchronized fournissent une relation "se produit avant" pour tous les accès dans le bloc.
  • final + publication : un constructeur qui assigne des champs final et ne s'échappe pas (par exemple, ne passe pas this à un autre thread) garantit que l'objet est publié en toute sécurité. L'objet n'a pas besoin d'être verrouillé si tous les accès se font après la construction.
// Pas de publication sécurisée : un autre thread peut voir un état obsolète
public class Holder {
    private int value;
    public Holder(int v) { this.value = v; }
    public int get() { return value; }
}

// Publication sécurisée via final
public class Holder {
    private final int value;
    public Holder(int v) { this.value = v; }
    public int get() { return value; }
}

// Publication sécurisée via volatile
public class Holder {
    private volatile int value;
    public void set(int v) { this.value = v; }
    public int get() { return value; }
}

La distinction entre publication sécurisée et non sécurisée est cruciale pour l'immutabilité\ : un objet immuable (tous les champs sont final, aucun this ne s'échappe pendant la construction) est publié en toute sécurité par défaut. Un objet "effectivement immuable" (tous les champs sont final mais l'objet référence un état mutable) n'est pas en sécurité s'il n'est pas publié avec une barrière appropriée.

Vérification du mode d'échec, pour tout objet qui sera partagé entre threads : cet objet est-il publié en toute sécurité, et comment puis-je le savoir ? Si aucun mécanisme (final, volatile, synchronisation) n'est en place, c'est une course de données.

Scellez les alternatives [R:seal-the-alternatives]

Lorsqu'un ensemble de sous-types est fixe et connu au moment de la compilation, déclarez-le dans le type lui-même avec sealed. Cela rend le modèle de données complet et le compilateur le vérifie.

sealed est l'opposé de open pour les cas où l'ensemble des possibilités ne devrait pas changer : un type de statut, un format de message, un résultat. Il remplace les énumérations lorsque chaque alternative a un état différent, et il remplace le code qui fait des instanceof et jette quand aucun cas ne correspond.

// Sans sealed : le compilateur ne sait pas que l'ensemble est complet
public abstract class Notification { }
public class EmailNotification extends Notification { }
public class SmsNotification extends Notification { }

// Avec sealed : le compilateur connaît l'ensemble, et peut se plaindre des branches manquantes
public abstract sealed class Notification
    permits EmailNotification, SmsNotification { }
public final class EmailNotification extends Notification { }
public final class SmsNotification extends Notification { }

// Utilisation du switch (Java 17+)
Notification n = ...;
return switch (n) {
    case EmailNotification e -> sendEmail(e);
    case SmsNotification s -> sendSms(s);
};

sealed garantit que switch couvre tous les cas, ce qui élimine les chemins "par défaut" qui cachent des bogues. Il rend également explicite la sémantique de l'héritage : la classe a exactement autant de sous-classes que déclarées, toutes connues au moment de la compilation. Ceci est différent de final, qui interdit toute sous-classe ; sealed en autorise un ensemble limité.

Lorsque l'ensemble des alternatives est connu de l'auteur de l'API mais pas des implémenteurs ultérieurs, sealed est le mécanisme correct. Il rend le code robuste face aux modifications du modèle de données sans recourir à l'énumération de toutes les possibilités dans une énumération. Et il s'associe bien avec [R:design-for-inheritance-or-forbid-it] : la classe de base est scellée, les sous-classes sont conçues comme des types de valeur avec peu d'état mutable.

Vérification du mode d'échec, pour un type avec un ensemble fixe de possibilités : est-ce que sealed est utilisé, ou le code dépend-il de instanceof et de default ? Si le code utilise default, il a des branches manquantes non vérifiées.

Serializable est un second constructeur [R:serializable-is-a-second-constructor]

Serializable rend un objet instance vulnérable à la désérialisation, ce qui contourne complètement le constructeur. Méfiez-vous-en.

L'interface Serializable elle-même n'a pas de méthodes, mais elle transforme chaque objet de la classe en une cible potentielle pour un flux de sérialisation. La désérialisation construit l'objet sans appeler aucun constructeur, ce qui signifie que les invariants établis dans le constructeur ne sont pas exécutés. Les champs sont lus directement à partir du flux, ou via readObject s'il est présent. Cela fait de Serializable un second constructeur, qui peut être appelé avec des données arbitraires.

// Une classe qui semble sûre...
public class User implements Serializable {
    private final String name;
    private final int age;
    public User(String name, int age) {
        this.name = Objects.requireNonNull(name);
        if (age < 0) throw new IllegalArgumentException("negative age");
        this.age = age;
    }
    // ... mais la désérialisation contourne le constructeur, donc l'âge négatif ou name null est possible.
}

Pour se défendre :

  • Utilisez readObject pour valider l'état après désérialisation. Appelez des vérifications similaires à celles du constructeur.
  • Utilisez writeReplace pour remplacer l'objet sérialisé par un objet proxy ("proxy pattern"), transformant la désérialisation en un appel au constructeur du proxy.
  • Utilisez readResolve pour remplacer l'objet désérialisé par une instance canonique (couramment utilisé pour les singletons).
  • Ne rendez pas une classe Serializable pour la commodité de la sérialisation automatique. La sérialisation est un détail d'implémentation. Si vous le faites, traitez-la comme une API publique.

java.io.Serializable est un talon d'Achille : chaque classe qui l'implémente devient vulnérable aux attaques par désérialisation si elle ne vérifie pas l'état. C'est pourquoi les classes de la bibliothèque standard comme String, Integer et ArrayList le sont ; elles ont toutes été conçues en tenant compte de cela. Une classe de domaine ne devrait l'être que si elle est explicitement destinée à être sérialisée via ObjectOutputStream, et alors elle reçoit la même attention de conception que le constructeur public.

Vérification du mode d'échec, pour chaque classe implémentant Serializable : puis-je créer une instance non valide via la désérialisation (un objet avec un invariant violé, ou un état non vérifié) ? Si oui, readObject doit être implémenté pour le valider.

Individual skills in this repo

This repo contains 3 individual skills — each has its own dedicated page.

george-andrikopoulos/domain-low-latency

Engineering discipline for working in low-latency. Covers: attack the design in a second pass; a measurement matches the regime it reports; a queue without a bound has no overload behaviour; a structure keeps the regime it was proved under; allocated is not resident; answer the requirement at its layer; no allocation on the hot path; no coordinated omission; no false sharing on a hot line; no retry loop on a contended path; no stall inside a publication window; no syscall on a bounded path;...

george-andrikopoulos/domain-rust

Engineering discipline for working in rust. Covers: design types first; borrow in signatures; module visibility is deliberate; must use on consequential returns; newtype liberally; parse wide then range check; seal closed trait sets; typestate builder for required fields; typestate for protocols; verify the abstraction compiled away; async all the way; errors name what failed; justify every clone; no let underscore on a local; parse dont validate; private fields only; xplat fixtures; no...

george-andrikopoulos/global

Engineering discipline that applies to every project and language. Covers: guarantee needs a reader; verdict survives the channel; check the claim you inherit; doc currency; measure the claim not a subset; no silent spend; prefer by construction; repair the lying artefact; signal needs a consequence; verify through production path; detector excludes own definitions; an ambiguous write target is a question; anchor outside the checked partys reach; delegate a fan out; diff against the known...

Skills associés