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 :

SuperCanarde

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'annonce
  • description La description de l'annonce
  • price le prix de vente
  • deliveryMethods le type d'échange permis. Choix possibles : "irl", "delivery", "relay", "teletransporation"
  • ref Une référence du canard
  • make La marque du canard
  • condition l'état du canard : "with-blister", "good-as-new", "excellent", "good", "used"
  • email l'email du vendeur
  • sellerName le nom affiché du vendeur
  • zipCode 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

TakiVeille

TakiVeille