La question qu'un développeur Java nous a posée
Quels patrons de conception utilisez-vous ? Notre frontend ne compte presque aucune classe. La réponse est devenue une bonne description de la façon dont nous décidons quoi construire et dont nous en parlons entre nous.
La question
Un développeur qui postulait chez nous venait de Java. Nous parlions de style de code, et il a demandé quels patrons de conception orientés objet nous utilisions.
Nous en utilisons la plupart en permanence, lui avons-nous répondu. Strategy pour une famille de comportements interchangeables. Observer pour un changement qui doit atteindre tout ce qui s'y intéresse. La composition préférée à l'héritage, davantage comme une habitude que comme une règle.
La question qu'ils ont posée ensuite était plus précise. Comment cela est-il possible, alors que notre partie frontale ne déclare pratiquement aucune classe ni aucune interface ? Java exprime ces modèles à travers un type de base et les éléments qui l'implémentent. Notre code remplit la même fonction par d'autres moyens.
C'est exact. Un composant Vue contient déjà tout ce qu'un objet contient : ses propres données, ses propres méthodes, ainsi que la partie qu'un objet Java délègue généralement à un autre élément, à savoir la manière dont il se présente. Un « composable » désigne la manière dont un composant obtient un comportement d'un autre composant ; il s'agit de la même relation « has-a » que celle qu'une classe tire de la composition, sans la classe de base. Un vocabulaire différent, mais le même argument.
Le problème commercial en dessous
Nous vendons un logiciel que nos clients configurent eux-mêmes. Un service des travaux publics construit ses propres formulaires de demande de service. Une entreprise de services publics définit ses propres catégories d'actifs. Une municipalité écrit ses propres règles d'automatisation pour ce qui se passe quand un signalement arrive.
Cela veut dire que les demandes arrivent sans arrêt, et qu'elles sont petites. Peut-on ajouter un champ de signature à ce formulaire. Peut-on récupérer des données depuis cet autre système. Peut-on envoyer cette alerte par message texte en plus du courriel.
Chacune de ces demandes est facile à accepter une fois. La question intéressante est ce que coûte la dixième. Si chaque nouveau type de champ signifie modifier les trois mêmes fichiers, la dixième demande est plus lente que la première, et la cinquantième devient un projet. Si chacune est un simple ajout, la cinquantième coûte à peu près ce qu'a coûté la première.
Rien de tout cela n'est visible dans une démo. Cela se décide des années à l'avance, en fonction de la manière dont le code est structuré. Nous accordons une attention particulière à cette structure.
Nommer la forme
La plupart des conseils sur la qualité du code se résument à deux idées. Ne vous répétez pas. Préférez la composition à l'héritage. Les deux sont vraies, et aucune ne règle grand-chose un jeudi à quatre heures de l'après-midi quand quelqu'un décide où placer une nouvelle fonctionnalité.
Ce qui règle la question, c'est un nom partagé.
Quand l'un de nous dit « fais-en un registre », toute l'équipe sait ce qui va arriver dans la revue de code : une table avec une ligne par type, une fonction par ligne, et une seule ligne qui va chercher la bonne entrée. Personne ne le dessine au tableau. Personne ne discute. La revue prend une minute, parce que la forme a été convenue avant même que le code soit écrit.
Ce vocabulaire est ce que l'étude des patrons nous a vraiment apporté. Il vit entre les personnes, et il se justifie deux fois. Il rend une bonne décision rapide, et il rend une mauvaise décision visible pendant qu'elle n'est encore qu'une phrase, avant qu'elle ne devienne trois fichiers.
Nous tenons une liste écrite des formes que nous utilisons et de leur place respective. Tout nouveau travail commence par nommer laquelle s'applique. Cette seule question règle l'essentiel de la conception, et le reste n'est que de l'écriture de code.
Ce que cela apporte, concrètement
Prenons les notifications. Quand quelque chose se produit qu'une personne doit savoir, nous le lui envoyons. Courriel, message texte, notification push sur son téléphone.
Un client nous a demandé d'ajouter les messages texte pour une alerte en particulier. C'est une petite demande, exactement le genre que nous recevons chaque semaine.
Nous tenons une seule table de ce que nous envoyons, avec une entrée par notification. Chaque entrée répond à une seule question en code simple : qui doit en entendre parler ?
'report.submitted': {
recipients: (args) => [args.report.assignee, ...args.report.watchers],
},
L'ajout d'une notification correspond à une seule entrée. Les modèles de message existants déterminent les canaux utilisés, et un SMS est envoyé dès qu'une personne rédige la version textuelle du message. Le moteur chargé de l'envoi reste inchangé. Il compte environ quarante lignes, et ce nombre est resté stable à chaque nouvelle notification que nous avons ajoutée.
Il n'a pas toujours eu cette forme. La toute première notification que nous ayons construite était un toast, ce petit message qui glisse dans le coin de l'écran pour dire que ce que vous veniez de faire a fonctionné. Un toast est aussi simple que possible, et il existait une version du travail où nous l'aurions écrit comme un toast et serions passés à autre chose.
Nous avons écrit la table à la place, parce qu'un toast répond déjà aux deux questions que tout le reste répondra. Quelque chose s'est produit. Qui doit le savoir, et que doit-il voir ?
Le nombre de réponses a considérablement augmenté par la suite. Certaines notifications devaient parvenir à une personne qui avait fermé l’onglet. Celles-ci ont alors pris la forme d’e-mails. D’autres devaient parvenir à une équipe ne disposant pas d’ordinateur portable. Celles-ci ont pris la forme de SMS. D’autres encore devaient arriver sur un téléphone à deux heures du matin. Celles-ci ont été transformées en notifications push. Chacune de ces solutions a nécessité un véritable travail, notamment la mise en place d’une nouvelle interface permettant aux utilisateurs de choisir les informations qu’ils souhaitent recevoir, mais aucune d’entre elles n’a modifié la réponse aux deux questions initiales.
La règle que nous suivons est que tout ce qui est inhabituel dans une notification vit à l'intérieur de l'entrée de cette notification. Dès qu'un cas particulier se déplace vers le moteur, celui-ci commence à accumuler un indicateur par cas, et la personne suivante doit tous les lire pour ajouter quoi que ce soit.
Une deuxième structure consiste à demander au code d'envoi : « S'agit-il d'un e-mail, d'un SMS ou d'une notification push ? », et à créer une branche à chaque fois. Notre base de code contient ces deux structures. Nous prévoyons du temps pour convertir la version à branches. Une ligne nécessite un mardi. Une branche nécessite une discussion.
Le développeur Java appellerait la première version Strategy. Il n'y a d'interface ni de classe nulle part, et elle fait exactement ce que le patron promet.
L'objet était déjà là
Commençons par la partie de la réponse qui nous a le moins surpris lorsque nous l'avons examinée de près. En Java, un objet est représenté par une classe : un ensemble de données et de méthodes qui agissent sur celles-ci. La manière dont il s'affiche à l'écran est une question distincte, traitée ailleurs.
Un composant Vue regroupe ces trois mêmes éléments, plus le troisième que Java confie habituellement à autre chose :
<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const user = ref<User | null>(null)
const isAdmin = computed(() => user.value?.role === 'admin')
async function load() { user.value = await getFetch(`/api/users/${props.userId}`) }
</script>
<template>
<div>{{ user?.name }} <span v-if="isAdmin">Admin</span></div>
</template>
props est un argument de constructeur. user et isAdmin sont des variables d'instance. load est une méthode. Le gabarit est la partie que Java laisse habituellement à autre chose : celle qui décide comment l'objet se présente. Placez-en deux sur le même écran pour deux utilisateurs différents, et chacun garde son propre user, privé, sans jamais voir l'autre. Ce sont deux objets.
Rien de tout cela ne nécessitait le terme « classe ». Dans les modèles sur lesquels nous nous appuyons, l’objet n’a jamais vraiment été l’élément en question. Ce qui varie, c’est la manière dont les objets interagissent entre eux : si l’un hérite d’un autre, si une famille de comportements peut être remplacée, si une modification apportée à l’un doit se répercuter sur les autres. Pour le type d’application que nous développons, sur le framework que nous utilisons, cette relation fonctionne le mieux sous forme de composants et de tableaux. Nos classes se trouvent à l’endroit unique où le langage les exige, et la section suivante aborde ce sujet.
Où l'héritage est passé
Par curiosité, nous avons cherché chaque endroit où notre code utilise l'héritage. Il y en a une poignée, et ce sont tous des types d'erreur, là où le langage l'exige.
Ce que nous utilisons à la place, c'est la composition. Dans notre code, elle prend la forme de petites fonctions qui rendent les éléments demandés.
const { record, canEdit, save } = useRecordEditor(recordId)
Cette ligne produit son propre objet de travail avec son propre état. Deux éditeurs ouverts en même temps portent deux enregistrements et ne se voient jamais l'un l'autre. Un développeur Java lit cela et reconnaît un objet avec des méthodes publiques, le reste restant privé.
L’ouvrage qui nous a appris, à la plupart d’entre nous, ces modèles énonce ce principe : privilégiez la composition plutôt que l’héritage. L’héritage vous lie à un parent et vous transmet tout ce qu’il possède. La composition ne retient que les trois éléments dont vous avez besoin. Le style « composable » de Vue correspond étroitement à ce principe, et notre code s’y est rapidement conformé.
Quand ce sont vos clients qui l'étendent
Le patron sur lequel nous nous appuyons le plus est celui où un changement s'annonce de lui-même et où tout ce qui s'y intéresse réagit.
Nos règles d'automatisation fonctionnent ainsi, et ce qui est intéressant, c'est qui les écrit. Un client ouvre l'interface et construit une règle : quand un signalement arrive pour ce type d'actif, prévenir cette équipe et ouvrir un ordre de travail. Cette règle est une ligne dans sa propre base de données. Quand l'événement se déclenche, le moteur trouve chaque règle qui l'observe et les exécute toutes, en isolant les échecs pour qu'une règle défaillante laisse les autres fonctionner.
L'Observer des manuels fait enregistrer les auditeurs par des programmeurs. Ici, c'est le client qui le fait, via un formulaire, un mardi après-midi, sans aucune mise en production impliquée.
Ce changement soulève des problèmes que le manuel n’a jamais eu à résoudre. Une règle peut déclencher un événement qui, à son tour, déclenche une autre règle. Chaque exécution dispose d’un budget déterminant la longueur maximale de la chaîne. Chaque règle est soumise à sa propre limite de débit, et lorsqu’un capteur est occupé, la file d’attente reste accessible à tous les autres. Les utilisateurs créent des structures que nous n’avions pas anticipées, et ces deux mécanismes de sécurité permettent à la file d’attente de continuer à fonctionner dans ces cas-là.
Pourquoi nous en parlons
La question de départ portait sur les patrons, et la vraie réponse porte sur notre façon de travailler.
Nous utilisons ces concepts en permanence, et notre langage les exprime différemment des mots-clés. Nous partageons un vocabulaire commun pour les désigner, et une décision de conception se résume généralement à une brève conversation.
Le toast en est l'exemple le plus clair. Il a été écrit dans un langage et un framework qui ressemblent à peine à ce qui fait tourner ce code aujourd'hui, par des personnes qui n'avaient pas lu les mêmes livres. La forme qui le sous-tend a tenu quand même, et elle tient encore aujourd'hui alors que la même notification peut arriver par courriel, par message texte ou par notification push à deux heures du matin.
C'est la partie qui mérite d'être retenue. Les patrons survivent au langage utilisé pour les décrire. Java appelle une chose une interface avec des classes qui l'implémentent, nous appelons cela une table de fonctions, et l'argument que chacun fait valoir est identique. Apprenez l'argument, et la syntaxe cesse d'avoir de l'importance.
Si vous vous demandez s'il faut travailler avec nous, quel que soit le côté de la table où vous êtes, voilà ce que nous voudrions que vous sachiez. Nous réfléchissons à la forme d'une chose avant de la construire, nous écrivons ce que nous apprenons, et nous revenons corriger les endroits où nous nous sommes trompés. Ce que voient nos clients, c'est que les petites demandes restent petites.
Résumé
- Nos clients configurent le produit eux-mêmes, et les petites demandes arrivent constamment. Le coût de la cinquantième est décidé par la forme du code, des années avant qu'elle ne soit formulée.
- La valeur durable des patrons de conception est un vocabulaire partagé. Convenir de la forme avant que le code existe transforme un débat de conception en une revue courte.
- Un composant Vue regroupe déjà ce que regroupe un objet : ses propres données, ses propres méthodes, et la façon dont il se présente. Nos classes se trouvent là où le langage les exige.
- Nous gardons le comportement dans des tables. Une nouvelle capacité est une ligne, et le moteur qui lit la table reste tel qu'il a été écrit. Notre moteur de notifications est resté à une quarantaine de lignes à travers chaque notification que nous avons ajoutée.
- Ce moteur a commencé comme un toast dans le coin d'un écran. Le courriel, le message texte et le push ont chacun apporté un vrai travail, y compris une nouvelle interface pour choisir ce que l'on veut recevoir, sans que rien de tout cela ne change la forme sous-jacente.
- L'héritage est pratiquement absent de notre frontend. De petites fonctions composables rendent les éléments dont un écran a besoin, chacune avec son propre état.
- Nos règles d'automatisation sont les propres auditeurs du client, stockés comme des données. Cela demande des garde-fous qu'une version de manuel n'a pas : une limite à la distance qu'une chaîne de règles peut parcourir, et une limite de fréquence par règle.
- Nous tenons une liste écrite des formes que nous utilisons, et nous revenons sur les endroits qui lui sont antérieurs.
- Les patrons survivent au langage utilisé pour les décrire. La syntaxe diffère entre Java et JavaScript. L'argument que fait valoir chaque patron ne diffère pas.