Éviter les bugs et anticiper l'avenir avec des types

Il y a plus d'un an, Anthony écrivait sur le Type Driven Development : une approche mêlant simplicité, puissance et efficacité.

L'objectif ici est de voir, au travers d'un cas d'usage très simple, comment les types peuvent vraiment nous accompagner dans notre développement.

Le cas d'usage

Le cas d'usage dont il va être question est très courant en informatique de gestion : on va faire des factures !

Pour l'exemple, seules les valeurs monétaires nous intéressent. Commençons par nous mettre d'accord sur la terminologie et les règles métier :

  • invoice : C'est une facture ;
  • amount : C'est un montant qui doit être arrondi à deux décimales ;
  • currency : C'est une devise. Dans la première version de notre solution, nous ne prenons en compte que les euros, mais d'autres devises doivent pouvoir être supportées dans quelque temps (je vous laisse seul·e·s juges de ce que signifie "quelque temps") ;
  • fee : C'est un frais (amount et currency). Dans notre cas, ce sera la seule information dans les lines d'une invoice.

STOP : Prenez quelques minutes pour imaginer (ou implémenter) une solution à ce problème.

  • Qu'est-ce qu'un amount ?
  • Qu'est-ce qu'une currency ?
  • Comment se construit une invoice ?
  • Comment prenez-vous en compte ces "multiples" devises qui arrivent "bientôt" ?
  • Bref, comment qu'on fait ?

Ma version

Disclaimer : ce n'est certainement pas "la bonne manière de faire" c'est juste ma manière de résoudre ce problème très simple.

Je code uniquement en TDD, que ce soit Type Driven Development ou Test Driven Development. Dans ces approches, il n'y pas de design up-front, le design de la solution va émerger avec notre avancée dans le code.

Seulement, pour qu'un design émerge, il faut commencer par quelque chose, un premier élément sur lequel on va pouvoir construire. Identifier ce premier élément est très souvent problématique quand on débute sur ces approches. Mon conseil : en fait, c'est sans importance !

Commencez une implémentation d'un premier "truc", celui qui vous trotte dans la tête et qui prend votre énergie parce qu'il "ne faut pas que j'oublie ça". Dans mon cas, c'était : "il ne faut pas que je me plante sur les calculs, les arrondis c'est toujours chiant".

Bon, je suis développeur Java à titre professionnel depuis 2009, je sais que je vais stocker les informations de montants dans des BigDecimal MAIS, je ne veux pas utiliser la primitive parce qu'un BigDecimal c'est BEAUCOUP trop vaste, je vais créer un type :

public record Amount(BigDecimal amount) {
  public Amount {
    Assert.field("amount", amount).notNull().positive();
  }
}

Assert est une implémentation dont je parle dans mon article "Des objets, pas des data classes".

Bien, maintenant je vais faire cet arrondi, après quelques itérations j'ai ce(s) test(s) :

import static org.assertj.core.api.Assertions.*;
 
class AmountUnitTest {
 
  @ParameterizedTest
  @MethodSource("amountValues")
  void shouldScaleAmount(BigDecimal input, BigDecimal roundedValue) {
    assertThat(new Amount(input).amount()).isEqualTo(roundedValue);
  }
 
  private static Stream<Arguments> amountValues() {
    return Stream.of(
        Arguments.of(new BigDecimal("123.544"), new BigDecimal("123.54")),
        Arguments.of(new BigDecimal("123.545"), new BigDecimal("123.55")),
        Arguments.of(new BigDecimal("123.5"), new BigDecimal("123.50")),
        Arguments.of(new BigDecimal("123"), new BigDecimal("123.00")));
  }
}

Et cette implémentation :

public record Amount(BigDecimal amount) {
  public Amount(BigDecimal amount) {
    Assert.field("amount", amount).notNull().positive();
 
    this.amount = amount.setScale(2, RoundingMode.HALF_UP);
  }
}

À ce stade, je suis relativement confiant sur mon implémentation de Amount. Je passe maintenant à l'élément suivant qui me trotte dans la tête : invoice.

Cette étape peut paraître étrange : il est évident qu'il manque pas mal de "trucs" pour faire le code de invoice. C'est vrai, mais mon objectif ici est bien de faire l'implémentation de invoice, c'est donc en faisant cette implémentation que ces "trucs" vont apparaître. Je commence donc l'écriture d'un premier test :

class InvoiceUnitTest {
 
  @Test
  void shouldDoStuff() {
    Invoice.builder()
        .line(new Fee("12.373", Currency.EURO))
        .build();
 
    // TODO
  }
}

Stupid isn't it? Eh bien oui, mais ce n'est pas grave ! En écrivant ce début de test, je ne sais même pas ce que je veux tester, j'ai donc un magnifique shouldDoStuff en guise de nom de méthode de test. Ce que je sais, par contre, c'est que j'aime bien l'API de construction d'invoice qui est en train d'apparaître (oui, j'ai peut-être un problème avec les builders...).

Ce test, entièrement rouge car il ne compile absolument pas, va me permettre d'avancer, commençons par Currency :

public enum Currency {
  EURO
}

Je choisi ici un enum : un type fermé qui va très largement simplifier les traitements. Il me faut ensuite Fee (je ne fais que suivre les recommandations de mon IDE pour faire compiler les choses dans l'ordre) :

public record Fee(Amount amount, Currency currency) {
 
  public Fee(String amount, Currency currency) {
    this(new Amount(amount), currency);
  }
 
  public Fee {
    Assert.notNull("amount", amount);
    Assert.notNull("currency", currency);
  }
}

J'ai envie de pouvoir construire Fee en passant une String (primitive) comme amount, je crée donc le constructeur nécessaire dans Fee et dans Amount (toujours quand le compilateur me le "demande").

Je peux maintenant créer les signatures manquantes dans invoice pour faire compiler tout ça :

public class Invoice {
  private Invoice(InvoiceBuilder builder) {
    // TODO
  }
 
  public static InvoiceBuilder builder() {
    return new InvoiceBuilder();
  }
 
  public Collection<Fee> lines() {
    // TODO
 
    return null;
  }
 
  public Fee total() {
    // TODO
 
    return null;
  }
 
  public static class InvoiceBuilder {
 
    public InvoiceBuilder line(Fee fee) {
      // TODO
 
      return this;
    }
 
    public Invoice build() {
      return new Invoice(this);
    }
  }
}

A ce stade mon code compile, mes tests passent et je sais ce que je veux comme premier cas de test pour invoice :

@Test
void shouldGetInvoiceWithTwoLines() {
  Fee firstLine = new Fee("12.373", Currency.EURO);
  Fee secondLine = new Fee("150", Currency.EURO);
 
  Invoice invoice = Invoice.builder()
      .line(firstLine)
      .line(secondLine)
      .build();
 
  assertThat(invoice.lines()).containsExactly(firstLine, secondLine);
  assertThat(invoice.total()).isEqualTo(new Fee("162.37", Currency.EURO));
}

Place aux morceaux manquants de l'implémentation :

public class Invoice {
 
  private final List<Fee> lines;
 
  private Invoice(InvoiceBuilder builder) {
    lines = Collections.unmodifiableList(builder.lines);
  }
 
  public static InvoiceBuilder builder() {
    return new InvoiceBuilder();
  }
 
  public Collection<Fee> lines() {
    return lines;
  }
 
  public Fee total() {
    return lines.stream()
        .reduce(Fee.ZERO, Fee::add);
  }
 
  public static class InvoiceBuilder {
 
    private final List<Fee> lines = new ArrayList<>();
 
    public InvoiceBuilder line(Fee fee) {
      Assert.notNull("fee", fee);
 
      lines.add(fee);
 
      return this;
    }
 
    public Invoice build() {
      return new Invoice(this);
    }
  }
}

Ça compile presque ! "Seule" cette ligne pose soucis .reduce(Fee.ZERO, Fee::add), il faut ajouter des choses dans Fee :

public record Fee(Amount amount, Currency currency) {
 
  public static final Fee ZERO = new Fee(Amount.ZERO, Currency.EURO);
 
  // Constructors...
 
  public Fee add(Fee other) {
    Assert.notNull("otherFee", other);
 
    return new Fee(amount().add(other.amount()), currency());
  }
}

Et maintenant il manque des choses dans Amount :

public record Amount(BigDecimal amount) {
 
  public static final Amount ZERO = new Amount(BigDecimal.ZERO);
 
  // Constructors...
 
  public Amount add(Amount other) {
    Assert.notNull("otherAmount", other);
 
    return new Amount(amount().add(other.amount()));
  }
}

Et... c'est tout ! J'ai mon implémentation, j'en suis satisfait :

  • J'ai un degré de confiance relativement élevé dans cette implémentation ;
  • Je trouve qu'elle exprime bien le besoin métier : je peux simplement lire le code avec un expert du domaine.

Il semblerait que je n'ai pas pris en compte le fait qu'il va falloir gérer plusieurs currencies "bientôt" ! En réalité, ce changement à venir est pris en compte dans Fee et sa method public Fee add(Fee other). Lorsque j'aurai un nouvelle currency, je pourrai revoir cette method pour prendre en compte la conversion.

Anticiper un éventuel changement à venir en modélisant au mieux l'état actuel de nos connaissances dans le système est souvent une très bonne idée ! Modéliser les "peut-être" prendra un temps considérable pour un résultat rarement intéressant. Il est souvent bien plus rentable de faire une implémentation spécialisée et de se laisser la possibilité de la faire évoluer en toute sécurité.

Je n'ai volontairement pas parlé des détails des choix de design que j'ai pu faire, plus de détails dans ce talk de Cyrille Martraire sur les monoïdes

Mais... !!!

Ce type d'approche soulève régulièrement certaines questions, je vais essayer de répondre aux plus communes ici.

Les perfs ne vont pas être bonnes

Objectivement, on va créer plus d'objets, on pourrait donc se dire que les performances ne seront pas bonnes. Quelques points à prendre en compte :

Alors, oui, parfois cela pourra être un sujet. Dans certains cas extrêmes de manipulation de millions (milliards ?) d'objets de grande taille (faisant plusieurs kilo octets en mémoire), il faudra se poser des questions MAIS pour manipuler quelques dizaines de milliers d'objets contenant très peu d'informations franchement, ça ne vaut pas la peine de se poser la question.

On code moins vite !

Là aussi, la remarque paraît pertinente : on crée plus de fichiers, on fait donc davantage de manipulations. Maintenant, si on prend en compte le fait que :

  • Les implémentations sont évidemment correctes ;
  • On ne fait pas d'aller-retour sur le code, plus de "debug" ;
  • Les boucles de feedback sont instantanées ;
  • On peut simplement échanger avec les experts métier.

En fait, de mon ressenti personnel, on va BEAUCOUP plus vite quand on code de cette manière, que ce soit pour écrire le code une première fois ou pour le faire vivre.

Pour écrire le code de cet exemple j'ai mis une dizaine de minutes (j'ai oublié de me chronométrer pour avoir une donnée précise...).

Ce n'est certes pas fulgurant au vu de la simplicité des opérations. Intuitivement, on pourrait se dire qu'il serait beaucoup plus rapide de juste faire l'algorithme. Personnellement, je sais qu’il me faudrait beaucoup plus longtemps si j'essayais de "juste coder l'algo et le modèle" car je ferais de nombreuses petites erreurs chronophages en chemin.

Ici, en quelques minutes, j'ai vraiment réfléchi au nommage des éléments (que je ne connaissais pas), j'ai pensé à la manière dont je voulais décrire le plus clairement possible mon système, j'ai changé d'avis plusieurs fois pour arriver à un résultat qui me satisfasse.

C'est super compliqué à faire !

Oui ! Probablement ; quand on n'est pas encore à l'aise avec cette manière de faire, de penser, de concevoir. Il faut s'entraîner, et pas seulement apprendre pour comprendre la simplicité de ces approches !

Cependant, une fois que l'on en a pris l'habitude, revenir à des "services" manipulant des data classes (classes avec uniquement des attributs et des getters et setters) pleines de primitives et de mutations a un côté réellement effrayant (et complexe) !

Je ne peux pas le faire parce que...

Je pense qu'il existe une raison presque valable pour remplacer "..." : "je ne sais pas faire". Cependant, en considérant le fait que cette manière de coder demande de l'entraînement, il faut commencer à le faire pour apprendre à le faire donc ce n'est pas vraiment valable.

Pour les autres raisons souvent invoquées, rapidement :

  • "Je suis dans la version X du langage Y" : pour tous les langages mainstream, il est tout à fait possible d'adopter ce style depuis très longtemps (pas de soucis en Java 1.3 par exemple). Dans certains cas, vous ne bénéficierez pas de certains sucres syntaxiques mais ce n'est franchement pas essentiel ;
  • "J'ai pas le temps" : justement, c'est une approche pour gagner du temps, elle est parfaite pour les gens qui n'ont pas le temps ;
  • "Mon projet ne s'y prête pas" : je suis tout à fait prêt à parier que si (mais il y a peu de chances qu'on se croise en vrai pour que je tienne ma promesse...). Dans tous les cas, il faut regarder encore, il est toujours possible de typer les choses pour apporter de la clarté ;
  • "J'ai peur" : "la peur mène à la colère, ..." - Maître Yoda (pardon).

Du coup, vous fermez cet article et vous faîtes des types ?

Si vous en faisiez déjà, je ne doute pas que vous allez continuer. Si ce n'était pas le cas, je doute que vous allez vous y mettre simplement après avoir lu un article.

Je ne peux cependant que vous conseiller de garder cette idée en tête, d'aller à des conférences, des meetups, d'échanger sur ces sujets mais, surtout, surtout : d'essayer !