Design Pattern : Valider des règles métier
Cette semaine, c'est Loïc qui vous propose un #KataOfTheWeek : Design pattern : valider des règles
Briefing du Kata : Si tu aimes les collections, tu dois forcément connaitre LebonCoinCoin.fr. LeBonCoinCoin, c'est le fameux site de petites annonces permettant de vendre et acheter de superbes canards en plastique, comme celui-ci :
Oui. Je sais. Toi aussi tu le veux.
Moi, je m'appelle Patrick, mais mes amis m'appellent Pato (les hispaniques comprendront). Je collectionne les canards en plastique depuis 17,73 années.
J'en ai exactement 42 123, et je suis toujours à la recherche de LA nouveauté.
Comme LeBonCoinCoin est assez réputé et que je ne veux jamais manquer une bonne occasion, j'ai voulu créer un petit crawler qui tourne et qui filtre les annonces pour trouver celles susceptibles de m'intéresser.
Lorsque je crawl un article, j'ai créé le modèle suivant :
title
Le titre de l'annoncedescription
La description de l'annonceprice
le prix de ventedeliveryMethods
le type d'échange permis. Choix possibles : "irl", "delivery", "relay", "teletransporation"ref
Une référence du canardmake
La marque du canardcondition
l'état du canard : "with-blister", "good-as-new", "excellent", "good", "used"email
l'email du vendeursellerName
le nom affiché du vendeurzipCode
le code postal du vendeur }
Voici un petit exemple :
Article :
{
"title": "Vend canard SuperWoman flambant neuf cause séparation",
"description": "Bonjour à tous, comme je sors un peu de l'univers DC Comics, je vends mon canard SuperWoman (référence Duck Inc 14234) pour la modique somme de 10 €. J'accepte aussi de l'échanger pour un canard Iron Man ou Hulk.",
"price": 10.0,
"deliveryMethods": ["irl", "delivery", "relay"],
"ref": 14234,
"make": "DuckInc",
"condition": "good-as-new"
"email": "superduck@collection.co",
"sellerName": "Harry Covaire",
"zipCode": "75014"
}
Comme je ne suis pas le dernier caneton, j'ai imposé un certain nombre de règles pour être certain qu'une annonce m'intéresserait :
- La description ne doit pas mentionner l'expression "Western Union"
- Le prix de vente doit être inférieur à 20 €, sauf s'il est encore sous blister ("with-blister"), dans ce cas là j'accepte de monter à 35 €.
- Il existe aussi des super-canards (en édition limitée). Leur numéro de référence est toujours < 100. Pour ceux là, je peux monter jusqu'à 120 €. Quand on aime, on ne compte pas.
- Je ne suis pas intéressé par les canards génériques, seulement ceux issus de DuckInc.
- Je n'accepte pas les articles de condition en dessous de "good-as-new".
- L'article doit pouvoir être envoyé, sauf si le vendeur habite dans le même département que moi (75).
- Je n'accepte aucun canard des gens qui s'appellent Kévin (email ou sellerName). Question de religion.
Trouvez une manière élégante de valider l'ensemble des règles dans une fonction qui prend en paramètre un Article
et qui renverra soit un Objet {"valid" : true}
soit {"valid": false, "reasons": [...]}
.
Saurez-vous résoudre le problème ?
Bon courage !
Et voici une solution proposée par l'auteur :
Evidemment, il y a plein de manières de répondre à notre ami Pato.
Vous auriez pu faire une méthode de 50 lignes avec toutes ces conditions dans un seul validateur, ou plusieurs méthodes statiques dans une classe ValidationUtils, que vous auriez appelé dans l'ordre. Bref un truc du genre :
class Validation {
public static boolean checkValidDescription(Article a) {
// CODE ICI...
}
public static boolean isFromDuckInc(Article a) {
// CODE ICI...
}
class Validator {
public Result validate(Article a) {
if (!checkValidDescription(Article a)) {
return Result.create(false, "wrong-description");
}
// ...
}
}
}
Si vous avez fait ça… Franchement, lisez la suite !
On aurait aussi pu considérer que notre use-case est parfait pour implémenter une spécification. Sauf que se faire un design pattern composite propre en créant tout le boilerplate pour gérer les compositions (et / ou / non etc…)… Alors qu'on est juste en train de filtrer des canards en plastique… Franchement, overkill, et puis le monde souffre suffisamment avec Criteria pour qu'on n'ait pas forcément envie d'augmenter l'entropie de l'univers avec une API conçue sur un bout de feuille après 2 pintes…
Je tiens donc à m'excuser auprès des aficionados de boolean isSatisfiedBy(...)
, mais la personne qui vous interview en entretien technique me remerciera.
Ici, on va plutôt chercher la simplicité (merci KISS), avec le trade-off suivant: chaque "règle" sera susceptible de concerner plusieurs champs. On va jouter donc avec le design pattern Strategy, et privilégier donc une validation des règles "métier".
Ici, j'ai découpé la spec en 5 "règles" :
- Une règle qui s'assure de l'honnêteté des vendeurs par rapport à mes critères métier (c'était ma blague nulle de Western Union et sur Kévin. Je sais. C'est cheap.).
- Une règle plus complexe sur les prix de vente
- Une règle sur la marque du canard
- Une règle sur la condition des articles
- Une règle sur les moyens de livraison
On va créer une jolie petite interface :
public interface ValidationRule {
fun validate(a: Article, errors: MutableList<String>)
}
Et on va se créer nos 5 petites implems (j'ai mis des magic numbers pour ne pas prendre 2 pages, mais vous voyez l'idée ^^.
import java.math.BigDecimal
class SellerTrustworthinessValidator : ValidationRule {
override fun validate(a: Article, errors: MutableList<String>) {
val couldBeAKevin = a.sellerName.containsIgnoreCase("Kevin") || a.email.containsIgnoreCase("Kevin")
val couldBeAScam = a.description.containsIgnoreCase("Western Union")
if (couldBeAKevin || couldBeAScam) {
errors.add("seller_not_trustworthy")
}
}
}
class SalePriceValidator : ValidationRule {
override fun validate(a: Article, errors: MutableList<String>) {
if (a.price <= BigDecimal.valueOf(10.0)) {
return
}
if (a.condition == "with-blister" && a.price <= BigDecimal.valueOf(35.0)) {
return
}
if (a.ref < 100 && a.price <= BigDecimal.valueOf(120.0)) {
return
}
errors.add("price_too_expensive")
}
}
class MakeValidator : ValidationRule {
override fun validate(a: Article, errors: MutableList<String>) {
if (a.make != "DuckInc") {
errors.add("wrong_make")
}
}
}
class ConditionValidator : ValidationRule {
override fun validate(a: Article, errors: MutableList<String>) {
val isGoodCondition = a.condition == "with-blister" || a.condition == "good-as-new"
if (!isGoodCondition) {
errors.add("bad_condition")
}
}
}
class DuckDeliveryValidator : ValidationRule {
override fun validate(a: Article, errors: MutableList<String>) {
val isShippable = a.deliveryMethods.filter { m -> m == "delivery" || m == "relay" }.isNotEmpty()
if (isShippable) {
return
}
val isSameDepartment = a.zipCode.startsWith("75")
if (isSameDepartment) {
return
}
errors.add("wrong_delivery_conditions")
}
}
private fun String.containsIgnoreCase(s: String): Boolean {
return this.contains(s, true)
}
Voici nos petites data classes
import java.math.BigDecimal
data class Article(val title: String,
val description: String,
val price: BigDecimal,
val deliveryMethods: List<String>,
val ref: Integer,
val make: String,
val condition: String,
val email: String,
val sellerName: String,
val zipCode: String)
data class Result(val valid: Boolean, val reasons: List<String>) {
companion object {
fun create(valid: Boolean, reasons: List<String>): Result {
return Result(valid, reasons)
}
}
}
On peut même mettre les règles ensembles pour tout avoir au même endroit, avec la création d'un BigAssValidator (all rights reserved)
class BigAssValidator : ValidationRule {
private val validators: List<ValidationRule> = listOf(
ConditionValidator(),
DuckDeliveryValidator(),
MakeValidator(),
SalePriceValidator(),
SellerTrustworthinessValidator()
)
override fun validate(a: Article, errors: MutableList<String>) {
validators.forEach { v -> v.validate(a, errors) }
}
}
class Main {
fun validate(a: Article): Result {
val reasons = ArrayList<String>()
BigAssValidator().validate(a, reasons)
return Result.create(reasons.isEmpty(), reasons)
}
}
Evidemment, ce n'est qu'une solution parmi d'autres.
Votre équipe TakiVeille