Retour au blog

La question qu'un développeur Java nous a posée

Quels patrons de conception utilisez-vous ? Notre frontend ne compte presque aucune classe, alors la réponse honnête a pris un moment. Elle est devenue une bonne description de la façon dont nous décidons quoi construire et dont nous en parlons entre nous.

engineeringhow-we-workdesign-patternsproduct

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.

Sa question suivante était plus pointue. Comment, alors que notre frontend ne déclare presque aucune classe ni interface ? Java exprime ces patrons à travers un type de base et les éléments qui l'implémentent. Notre code ne dispose pas de ce vocabulaire, alors il doit accomplir le même travail par un autre moyen.

C'est le cas. Un composant Vue contient déjà ce que contient un objet : ses propres données, ses propres méthodes, et la partie qu'un objet Java confie habituellement à autre chose, la façon dont il se présente. Un composable est la manière dont un composant obtient un comportement d'un autre, la même relation de composition qu'une classe obtient par composition, sans la classe de base. Un vocabulaire différent, le même argument, et il a tenu mieux que nous ne l'attendions une fois que nous nous sommes assis pour écrire exactement comment.

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émonstration. Cela se décide des années plus tôt, selon la forme du code. C'est pourquoi nous accordons une vraie attention à cette forme.

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],
},

Ajouter une notification, c'est une entrée. Le choix des canaux dépend des modèles de message qui existent pour elle, si bien qu'un message texte arrive en écrivant simplement la version texte du message. Le moteur qui fait l'envoi reste tel quel. Il compte une quarantaine de lignes, et il est resté à une quarantaine de lignes à travers chaque 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 ?

Les réponses se sont beaucoup développées par la suite. Certaines notifications devaient atteindre une personne qui avait fermé l'onglet, alors elles sont devenues des courriels. Certaines devaient atteindre une équipe sans ordinateur portable, alors elles sont devenues des messages texte. Certaines devaient arriver sur un téléphone à deux heures du matin, alors elles sont devenues des notifications push. Chacune a apporté un vrai travail, y compris une nouvelle interface pour choisir ce que l'on veut recevoir, sans jamais changer la réponse aux deux questions d'origine.

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.

Il existe une version où le code d'envoi demande « est-ce un courriel, un texte ou un push » et fait grandir une branche à chaque fois. Nous avons les deux formes dans notre code, parce qu'un logiciel écrit sur plusieurs années est honnête sur sa propre histoire. La version à branches est celle que nous prévoyons de convertir, et la raison n'est pas l'ordre. Une ligne, c'est un mardi. Une branche, c'est une conversation.

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 moitié de la réponse qui nous a le moins surpris une fois qu'on l'a regardée en face. Java exprime un objet comme une classe : un paquet de données, les méthodes qui agissent dessus, et rien sur son apparence à l'écran, puisque cela relève d'une préoccupation séparée 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 n'a nécessité le mot classe. Dans les patrons sur lesquels nous nous appuyons, l'objet n'a jamais vraiment été ce qui était en question. Ce qui varie, c'est la façon dont les objets se relient entre eux : si l'un hérite d'un autre, si une famille de comportements peut être interchangée, si un changement dans l'un doit atteindre les autres. Pour le type d'application que nous construisons, sur le framework dans lequel nous le construisons, cette mise en relation s'est mieux prêtée aux composables et aux tables qu'aux classes. C'est une déclaration sur ce qui nous convient en pratique, pas une affirmation que les classes cessent d'être utiles ailleurs. Les nôtres existent toujours, au seul endroit où le langage les exige, et c'est le sujet de la section suivante.

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, qui dans notre monde ressemble à 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é.

Le livre qui a enseigné ces patrons à la plupart d'entre nous en fait un principe : préférer la composition à l'héritage. L'héritage vous lie à un parent et vous donne tout ce qu'il possède. La composition prend les trois choses dont vous avez besoin. Le style même des composables de Vue correspond déjà de près à ce principe, si bien que notre code s'est installé là sans que personne n'ait eu à organiser de réunion. Une équipe construisant le même produit en Java aurait toujours raison de recourir à l'héritage à des endroits où nous ne le faisons pas, parce que l'adéquation dépend autant du framework que du patron.

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 apporte des problèmes que le manuel n'a jamais eu à résoudre. Une règle peut déclencher un événement qui en active une autre, alors chaque exécution porte un budget limitant la distance qu'une chaîne peut parcourir. Les règles ont aussi leurs propres limites de fréquence, si bien qu'un capteur bruyant laisse la file d'attente disponible pour tous les autres. Ces deux garde-fous existent parce que les clients, à juste titre, construisent des choses que nous n'avions pas anticipées.

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 idées en permanence, et vous ne les trouverez pas en cherchant les mots-clés, parce que notre langage les écrit différemment. Plus utile encore, nous avons un vocabulaire partagé pour elles, si bien qu'une décision de conception est en général une conversation courte.

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, si bien que 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. Cela n'a pas nécessité le mot classe, et ce n'est pas une affirmation que les classes cessent d'avoir de l'importance. Nous en utilisons encore une exactement là où le langage l'exige, et une équipe construisant le même produit en Java aurait raison de recourir à l'héritage à des endroits où nous ne le faisons pas.
  • Nous gardons le comportement dans des tables, si bien qu'une nouvelle capacité est une ligne et que 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.