Parse don't validate
Dans les langages fonctionnels tels que java ou haskell, il est d'usage d'utiliser au maximum le compilateur pour éviter le plus tôt possible les bugs.
Avec l'arrivée du jdk 17, et notamment des sealed class et des record, java nous propose de nouvelles fonctionnalités pour utiliser encore plus le système de type et donc le compilateur.
L'approche "parse, don't validate" propose de créer des types riches pour représenter les données plutôt que d'utiliser les types primitifs comme String, Boolean etc et ainsi rendre impossible les états incohérents. Dans le cas d'une API, une fois le payload d'une requête parsé, c'est le compilateur qui reprend la main et valide le code pour vous.
Dans cet article, je vous propose, de suivre cette pratique en java et de voir où ça nous mène.
L'instant musical
L'approche classique
Une approche traditionnelle consiste à avoir un POJO représentant les données ou chaque attribut est validé par les "bean validation".
Exemple
@Data
@Builder(toBuilder = true)
public class Colis {
public String reference;
@NotNull
public TypeColis type;
@NotNull
public LocalDateTime dateDEnvoi;
public LocalDateTime dateReception;
public Double latitude;
public Double longitude;
@Email
@NotNull
public String email;
public Adresse adresse;
}
Ce pojo est validé en utilisant l'annotation @Valid
dans le contrôleur ou dans le service.
@PostMapping
public Mono<ResponseEntity<Colis>> prendreEnChargeLeColis(@RequestBody @Valid Colis colis) {
return this.livraisonDeColis
.prendreEnChargeLeColis(colis)
.map(ResponseEntity::ok)
.onErrorResume(ColisNonTrouve.class, e ->
Mono.just(ResponseEntity.notFound().build())
);
}
Ici, on peut déjà remarquer que
- Il est facile de se tromper en instanciant l'objet colis. Ex inverser
dateDEnvoi
etdateReception
. - Les validations conditionnelles ne sont pas simples à faire. Ex valider que la date de reception est non null lorsque le type de colis a la valeur
ColisRecu
. - On peut avoir des états incohérents. Ex
TypeColis.EnCours
avec unedateReception
renseignée. - Niveau de confiance faible sur le fait qu'une instance de pojo soit valide ou non. Elle ne sera valide que si l'instance est passée par le controller.
Un peu de théorie
Une solution pour répondre aux problématiques énoncées précédemment est d'utiliser des ADT "type algébrique de données" (algebraic data type) pour représenter les différents états gérés par notre application.
Un type algébrique est soit un "type produit" (product type), soit un "type somme" (sum type).
Type produit
Un produit peut être vu comme un n-uplet ou un pojo. La cardinalité d'un type produit est le produit des cardinalités de chaque type "contenus".
En java, les records permettent facilement de représenter un type produit.
Par exemple :
record Voiture (Marque marque, String modele, Integer prix) {}
Type somme
Un type somme est une union de types. La cardinalité d'un type somme est la somme des cardinalités des types "contenus".
En java, on pensera à un enum.
enum Marque {
PEUGEOT, RENAULT, CITROEN
}
Type algébrique de données
Un type algébrique combine les types somme et les types produit.
En java 17 on peut représenter ça par une interface scellée.
sealed interface Vehicule permits Vehicule.Voiture, Vehicule.Scooter, Vehicule.Bus {
record Voiture (Marque marque, String modele, Integer prix) implements Vehicule {}
record Scooter(String couleur) implements Vehicule {}
record Bus(String couleur, Integer nbPlaces) implements Vehicule {}
}
Le mot clé sealed
indique que l'interface ne peut être implémentée que par une liste finie de classes ou de records. La liste est déclarée avec le mot clé permits
.
Dans l'illustration proposée, on pourrait enlever le mot clé permits
puisque la liste des implémentations est dans le même fichier.
Avoir des types précis
Comme exprimé plus tôt, la validation fonctionne mais n'apporte pas un fort niveau de confiance. Plutôt que de valider des types primitifs, pourquoi ne pas créer des types dédiés pour représenter précisément les notions manipulées dans le code.
à la place de :
class MonPojo {
@NotNull
@Email
String email;
}
On pourrait avoir :
record MonPojo(Email email) { }
avec :
record Email(String value) {
public Email {
throwInvalid(nonNull(value).andThen(() -> emailValid(value)));
}
}
Avec cette approche, il est impossible d'avoir une instance d'email qui ne soit pas valide.
Autre avantage, il n'est plus possible d'inverser plusieurs String
dans un constructeur car chaque donnée à son propre type.
Le niveau de confiance devient alors plus fort.
Rendre les états incohérents impossibles
L'étape suivante est de rendre les états incohérents impossibles, on va bien sûr utiliser les types algébriques de données.
Dans le cas du colis, on pourra avoir une représentation comme ceci :
public sealed interface Colis {
sealed interface ColisExistant extends Colis {
ReferenceColis reference();
}
record NouveauColis(DateDEnvoi dateDEnvoi, Email email, Adresse adresse) implements Colis {
@Builder
public NouveauColis {
throwInvalid(nonNull(dateDEnvoi)
.and(nonNull(email))
.and(nonNull(adresse))
);
}
public ColisPrisEnCharge toColisPrisEnCharge(ReferenceColis reference) {
return new ColisPrisEnCharge(reference, dateDEnvoi, email, adresse);
}
}
record ColisPrisEnCharge(ReferenceColis reference, DateDEnvoi dateDEnvoi, Email email,
Adresse adresse) implements ColisExistant {
@Builder
public ColisPrisEnCharge {
throwInvalid(nonNull(reference)
.and(nonNull(dateDEnvoi))
.and(nonNull(email))
.and(nonNull(adresse))
);
}
}
record ColisEnCoursDAcheminement(ReferenceColis reference, DateDEnvoi dateDEnvoi, PositionGps position, Email email,
Adresse adresse) implements ColisExistant {
@Builder
public ColisEnCoursDAcheminement {
throwInvalid(nonNull(reference)
.and(nonNull(dateDEnvoi))
.and(nonNull(email))
.and(nonNull(adresse))
);
}
}
record ColisRecu(ReferenceColis reference, DateDEnvoi dateDEnvoi, DateDeReception dateDeReception, Email email,
Adresse adresse) implements ColisExistant {
@Builder
public ColisRecu {
throwInvalid(nonNull(reference)
.and(nonNull(dateDEnvoi))
.and(nonNull(email))
.and(nonNull(adresse))
.andThen(() ->
doitEtreAvant(dateDEnvoi, dateDeReception, "La date d'envoi doit être avant la date de reception")
)
);
}
}
}
De cette façon, on a la liste exhaustive de chaque état. Il n'est plus possible de créer un état incohérent.
En utilisant le switch
de java 17 il est possible de "pattern matcher" sur les types et le compilateur va nous alerter s'il y a un oubli :
var texte = switch(colis) {
case NouveauColis c -> "c'est un nouveau colis";
case ColisPrisEnCharge c -> "c'est un colis pris en charge";
case ColisEnCoursDAcheminement c -> "c'est un colis en cours d'acheminement";
case ColisRecu c -> "c'est un colis reçu";
};
Si un case
est oubliée, il y aura une erreur de compilation.
Parse don't validate
Maintenant qu'on a des classes qui représentent nos états et nos données de façon stricte, comment passe-t-on d'un monde http / json unsafe au monde ADT safe? Et bien en écrivant des parsers.
Dans la solution proposée, la lib functional-json
est utilisée. Dans cette approche on parse chaque attribut du json dans le bon format. La librairie permet d'empiler toutes les erreurs rencontrées lors de la lecture d'un objet.
Par exemple ici, on définit un reader et writer pour créer une instance de ColisPrisEnCharge
:
static JsonFormat<ColisPrisEnCharge> colisPrisEnChargeFormat() {
return JsonFormat.of(
__("dateDEnvoi", dateDEnvoiFormat(), ColisPrisEnCharge.builder()::dateDEnvoi)
.and(__("reference", referenceFormat()), ColisPrisEnChargeBuilder::reference)
.and(__("email", emailFormat()), ColisPrisEnChargeBuilder::email)
.and(__("adresse", adresseFormat()), ColisPrisEnChargeBuilder::adresse)
.map(ColisPrisEnChargeBuilder::build),
(ColisPrisEnCharge colis) -> Json.obj(
$$("reference", colis.reference(), referenceFormat()),
$$("type", colis.getClass().getSimpleName()),
$$("dateDEnvoi", colis.dateDEnvoi(), dateDEnvoiFormat()),
$$("email", colis.email(), emailFormat()),
$$("adresse", colis.adresse(), adresseFormat())
)
);
}
Dans notre base de code on a donc 2 zones :
- une zone fortement typée avec un fort degré de confiance : le code métier
- une zone faiblement typée sans garanties :
- controller http
- accès à la base de données
L'approche "parse don't validate" est un bon complément de l'architecture hexagonale.
En conclusion
L'approche "parse don't validate" rend le code bien plus robuste, mais elle a un coût de mise en œuvre. Il faudra également être vigilant lors des évolutions car, il se pourrait qu'en ajoutant des contraintes supplémentaires dans son code, on ne puisse plus relire nos données stockées en base.
Quoi qu'il soit, il est clair que cette approche rendra votre code moins sujet aux bugs. A vous de choisir le niveau de contraintes que vous souhaitez appliquer !
Vous pourrez trouver un projet d'exemple ici
PS
Vous avez des questions, des retours, des critiques C'est ici que ça se passe