Maîtrisez vos règles métier - Intégrez Drools dans Spring Boot

Publié le 21/05/2025 Source : sfeir.dev

Dans le développement d’applications modernes, la gestion des règles métier complexes peut devenir un défi, surtout lorsqu’elles doivent être fréquemment mises à jour sans modifier le code source.
Drools, un moteur de règles open-source, offre une solution pratique pour externaliser la logique métier dans des fichiers de règles facilement modifiables.
Lorsqu’il est intégré à Spring Boot, Drools permet de combiner la puissance d’un framework web robuste avec une gestion dynamique des règles.
Cet article explore comment intégrer Drools dans une application Spring Boot, en s’appuyant sur un cas pratique de gestion bancaire, et détaille les étapes, les avantages, et les pièges à éviter.

Présentation de Drools

Drools est un système de gestion de règles métier (BRMS) développé par Red Hat. Il permet de définir des règles dans un langage déclaratif (souvent en fichiers .drl) qui sont ensuite évaluées par un moteur d’inférence basé sur l’algorithme Rete. Drools s’intègre parfaitement avec Java, offrant une API pour manipuler les règles et les faits (objets métier).
Dans un contexte Spring Boot, Drools peut être utilisé pour appliquer des règles complexes, comme la validation de transactions bancaires ou la gestion de politiques de tarification, tout en restant découplé de la logique applicative.

Fonctionnalités clés :

⚖️ Avantages et inconvénients

➕ Avantages

  1. Modularité : Les règles sont externalisées, permettant aux analystes métier de les modifier sans toucher au code Java.
  2. Flexibilité : Drools s’adapte à des cas d’usage variés, comme la validation, les calculs dynamiques, ou la détection de fraudes.
  3. Intégration Spring : Avec Spring Boot, Drools peut être configuré comme un bean, utilisé avec AOPpour appliquer des règles de manière transparente.
  4. Performance : Le moteur Rete optimise l’évaluation des règles, même pour des ensembles complexes.

➖ Inconvénients

  1. Complexité initiale : La configuration de Drools et l’écriture de règles nécessitent une courbe d’apprentissage, surtout pour les développeurs non familiers avec les BRMS.
  2. Performance à grande échelle : La création répétée de sessions Drools peut être coûteuse si elle n’est pas optimisée (par exemple, via un pool).
  3. Maintenance des règles : Sans tests rigoureux, les modifications des règles peuvent introduire des erreurs difficiles à détecter.
  4. Dépendance au classpath : Les fichiers de règles doivent être correctement inclus dans le build, ce qui peut poser des problèmes si mal configuré.

Installation du plugin

Pour intégrer Drools dans une application Spring Boot, ajoutez les dépendances nécessaires dans votre fichier pom.xml (pour Maven) ou build.gradle (pour Gradle). Voici un exemple pour Maven :

 <dependencies>
  <dependency>
      <groupId>org.drools</groupId>
      <artifactId>drools-core</artifactId>
      <version>${drools.version}</version>
  </dependency>
  <dependency>
      <groupId>org.drools</groupId>
      <artifactId>drools-compiler</artifactId>
      <version>${drools.version}</version>
  </dependency>
  <dependency>
      <groupId>org.drools</groupId>
      <artifactId>drools-xml-support</artifactId>
      <version>${drools.version}</version>
  </dependency>
  <dependency>
      <groupId>org.drools</groupId>
      <artifactId>drools-mvel</artifactId>
      <version>${drools.version}</version>
  </dependency>
</dependencies>

Cas pratique : Gestion bancaire avec Drools

Pour illustrer l’intégration, nous allons examiner une application Spring Boot qui utilise Drools pour gérer des transactions bancaires (dépôts, retraits) en appliquant des règles comme la vérification du solde ou la détection de transactions suspectes. Voici les composants clés et leur explication.

Modèles métier

Les classes Account et Transaction représentent les faits utilisés par Drools. Elles implémentent une interface DroolsFact pour faciliter leur identification.

public interface DroolsFact {
    // Marqueur pour les objets utilisés dans Drools
}

public class Account implements DroolsFact {
    @NotBlank(message = "Le numéro de compte ne peut pas être vide")
    private String accountNumber;
    private double balance;

    // Getters et setters
}

public class Transaction implements DroolsFact {
    @NotBlank(message = "Le numéro de compte ne peut pas être vide")
    private String accountNumber;
    @NotNull(message = "Le type de transaction est requis")
    private String type;
    @Positive(message = "Le montant doit être positif")
    private double amount;
    private boolean approved;
    private String message;

    // Getters et setters
}

Explication :

Annotation personnalisée

Une annotation @DroolsRule marque les méthodes où les règles Drools doivent s’appliquer.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DroolsRule {
}

Explication :

Configuration Drools

La classe DroolsConfig configure le moteur Drools, charge les fichiers de règles, et crée un KieContainer.

@Configuration
@EnableAspectJAutoProxy
public class DroolsConfig {

    private static final Logger logger = LoggerFactory.getLogger(DroolsConfig.class);
    private static final KieServices kieServices = KieServices.Factory.get();
    private static final String RULES_PATH = "rules/*.drl";

    @Bean
    public KieContainer kieContainer() {
        try {
            logger.info("Initialisation de la configuration Drools");

            // Créer un KieFileSystem
            KieFileSystem kieFileSystem = kieServices.newKieFileSystem();
            logger.info("KieFileSystem créé");

            // Charger tous les fichiers .drl du dossier rules
            ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
            Resource[] ruleResources = resolver.getResources("classpath:" + RULES_PATH);
            if (ruleResources.length == 0) {
                logger.error("Aucun fichier de règles trouvé dans {}", RULES_PATH);
                throw new IllegalStateException("Aucun fichier de règles trouvé dans " + RULES_PATH);
            }

            // Convertir les ressources Spring en ressources Drools
            for (Resource springResource : ruleResources) {
                // Utiliser le chemin relatif complet (rules/nom_du_fichier)
                String resourcePath = "rules/" + springResource.getFilename();
                logger.info("Tentative de chargement de la ressource : {}", resourcePath);

                // Créer une ressource Drools à partir du chemin relatif
                org.kie.api.io.Resource droolsResource = kieServices.getResources()
                        .newClassPathResource(resourcePath, "UTF-8");
                kieFileSystem.write(droolsResource);
                logger.info("Fichier de règles chargé : {}", resourcePath);
            }

            // Créer un kmodule.xml programmatique
            KieModuleModel kieModuleModel = kieServices.newKieModuleModel();
            KieBaseModel kieBaseModel = kieModuleModel.newKieBaseModel("bankRules");
            kieBaseModel.addPackage("rules");
            kieBaseModel.setDefault(true);
            KieSessionModel kieSessionModel = kieBaseModel.newKieSessionModel("bankSession");
            kieSessionModel.setDefault(true);
            logger.info("Configuration de kmodule : kbase=bankRules, ksession=bankSession");

            // Écrire kmodule.xml dans le KieFileSystem
            String kmoduleXml = kieModuleModel.toXML();
            kieFileSystem.writeKModuleXML(kmoduleXml);
            logger.info("kmodule.xml généré : {}", kmoduleXml);

            // Construire le KieModule
            logger.info("Démarrage de la compilation des règles");
            KieBuilder kieBuilder = kieServices.newKieBuilder(kieFileSystem);
            kieBuilder.buildAll();
            logger.info("Compilation terminée");

            // Vérifier les erreurs de compilation
            if (kieBuilder.getResults().hasMessages(Message.Level.ERROR)) {
                logger.error("Erreurs lors de la compilation des règles :");
                for (Message message : kieBuilder.getResults().getMessages(Message.Level.ERROR)) {
                    logger.error("Erreur : {}", message.getText());
                }
                throw new IllegalStateException("Échec de la compilation des règles Drools : " + kieBuilder.getResults().getMessages());
            }
            if (kieBuilder.getResults().hasMessages(Message.Level.WARNING)) {
                logger.warn("Avertissements lors de la compilation des règles :");
                for (Message message : kieBuilder.getResults().getMessages(Message.Level.WARNING)) {
                    logger.warn("Avertissement : {}", message.getText());
                }
            }
            logger.info("Compilation des règles réussie");

            // Créer le KieContainer
            KieModule kieModule = kieBuilder.getKieModule();
            KieContainer kieContainer = kieServices.newKieContainer(kieModule.getReleaseId());
            logger.info("KieContainer créé avec succès, ReleaseId : {}", kieModule.getReleaseId());

            return kieContainer;
        } catch (IOException e) {
            logger.error("Erreur lors du chargement des fichiers de règles", e);
            throw new IllegalStateException("Impossible de charger les fichiers de règles", e);
        } catch (Exception e) {
            logger.error("Erreur lors de la création du KieContainer", e);
            throw new IllegalStateException("Impossible de créer le KieContainer", e);
        }
    }
}

Explication :

Aspect Drools

L’aspect DroolsAspect applique les règles aux méthodes annotées avec @DroolsRule.

@Aspect
@Component
public class DroolsAspect {
    private static final Logger logger = LoggerFactory.getLogger(DroolsAspect.class);
    private final KieContainer kieContainer;

    public DroolsAspect(KieContainer kieContainer) {
        this.kieContainer = kieContainer;
    }

    @Around("@annotation(fr.eletutour.annotation.DroolsRule)")
    public Object applyDroolsRules(ProceedingJoinPoint joinPoint) throws Throwable {
        logger.info("Interception de la méthode avec @DroolsRule: {}, arguments: {}",
                joinPoint.getSignature(), Arrays.toString(joinPoint.getArgs()));

        // Récupérer les faits Drools parmi les arguments
        List<DroolsFact> facts = new ArrayList<>();
        for (Object arg : joinPoint.getArgs()) {
            if (arg instanceof DroolsFact) {
                facts.add((DroolsFact) arg);
            }
        }

        // Vérifier qu'il y a des faits
        if (facts.isEmpty()) {
            logger.error("Aucun fait Drools trouvé dans les arguments");
            throw new IllegalArgumentException("Au moins un fait Drools est requis pour appliquer les règles");
        }
        logger.info("Faits trouvés: {}", facts);

        // Créer une session Drools
        logger.info("Création de la KieSession 'bankSession'");
        KieSession kieSession = kieContainer.newKieSession("bankSession");
        if (kieSession == null) {
            logger.error("Échec de la création de la KieSession 'bankSession'");
            throw new IllegalStateException("KieSession 'bankSession' non trouvée");
        }

        // Configurer le logger comme global
        Logger rulesLogger = LoggerFactory.getLogger("RulesLogger");
        kieSession.setGlobal("logger", rulesLogger);

        try {
            // Insérer les faits
            for (DroolsFact fact : facts) {
                kieSession.insert(fact);
            }
            logger.info("Faits insérés: {}", facts);

            // Exécuter les règles
            logger.info("Exécution des règles Drools");
            int rulesFired = kieSession.fireAllRules();
            logger.info("Nombre de règles exécutées: {}", rulesFired);

            // Continuer l'exécution de la méthode originale
            logger.info("Poursuite de l'exécution de la méthode originale");
            return joinPoint.proceed();
        } finally {
            // Libérer la session
            logger.info("Libération de la KieSession");
            kieSession.dispose();
        }
    }
}

Explication :

Règles Drools

package rules

import fr.eletutour.model.Account
import fr.eletutour.model.Transaction

dialect "mvel"

global org.slf4j.Logger logger

// Règle 0 : Valider les données de la transaction
rule "Validate Transaction"
    salience 100
    when
        $t: Transaction(
            accountNumber == null || accountNumber == "",
            approved == false
            ||
            amount <= 0,
            approved == false
            ||
            type == null || type == "",
            approved == false
        )
    then
        $t.setApproved(false);
        $t.setMessage("Transaction invalide : numéro de compte, montant ou type manquant ou incorrect.");
        logger.error("Règle 'Validate Transaction' déclenchée pour une transaction invalide");
end

// Règle 1 : Vérifier si le retrait est possible
rule "Check Sufficient Balance for Withdrawal"
    salience 50
    when
        $t: Transaction(type in ("WITHDRAW", "WITHDRAWAL"), $amount: amount, approved == false)
        $a: Account(accountNumber == $t.accountNumber, balance >= $amount)
    then
        $t.setApproved(true);
        $a.setBalance($a.getBalance() - $amount);
        $t.setMessage("Retrait de " + $amount + " approuvé.");
        logger.info("Règle 'Check Sufficient Balance' déclenchée pour " + $t.getAccountNumber());
end

// Règle 2 : Refuser le retrait si solde insuffisant
rule "Deny Withdrawal Insufficient Balance"
    salience 50
    when
        $t: Transaction(type in ("WITHDRAW", "WITHDRAWAL"), $amount: amount, approved == false)
        $a: Account(accountNumber == $t.accountNumber, balance < $amount)
    then
        $t.setApproved(false);
        $t.setMessage("Retrait de " + $amount + " refusé : solde insuffisant.");
        logger.info("Règle 'Deny Withdrawal' déclenchée pour " + $t.getAccountNumber() + ", solde=" + $a.getBalance());
end

// Règle 3 : Appliquer des frais de découvert
rule "Apply Overdraft Fee"
    salience 30
    when
        $a: Account(balance < 0)
    then
        double fee = 10.0;
        $a.setBalance($a.getBalance() - fee);
        logger.info("Frais de découvert de " + fee + " appliqués au compte " + $a.getAccountNumber());
end

// Règle 4 : Détecter une transaction suspecte
rule "Flag Suspicious Transaction"
    salience 20
    when
        $t: Transaction(type in ("WITHDRAW", "WITHDRAWAL"), amount > 1000, approved == true)
    then
        $t.setMessage($t.getMessage() + " [ATTENTION : Transaction suspecte détectée !]");
        logger.info("Transaction suspecte : " + $t);
end

// Règle 5 : Gérer les dépôts
rule "Process Deposit"
    salience 50
    when
        $t: Transaction(type == "DEPOSIT", $amount: amount, approved == false)
        $a: Account(accountNumber == $t.accountNumber)
    then
        $t.setApproved(true);
        $a.setBalance($a.getBalance() + $amount);
        $t.setMessage("Dépôt de " + $amount + " approuvé.");
        logger.info("Règle 'Process Deposit' déclenchée pour " + $t.getAccountNumber());
end

Explication :

Service et contrôleur

Le TransactionProcessor applique les règles via @DroolsRule, et le BankController expose une API REST.

@Component
public class TransactionProcessor {
    private static final Logger logger = LoggerFactory.getLogger(TransactionProcessor.class);
    private final AccountRepository accountRepository;
    private final TransactionRepository transactionRepository;

    public TransactionProcessor(AccountRepository accountRepository, TransactionRepository transactionRepository) {
        this.accountRepository = accountRepository;
        this.transactionRepository = transactionRepository;
    }

    /**
     * Exécute une transaction en appliquant les règles Drools.
     *
     * @param transaction La transaction à traiter.
     * @param account     Le compte associé.
     * @return La transaction mise à jour.
     * @throws TransactionFailedException Si la transaction est refusée ou invalide.
     */
    @Transactional
    @DroolsRule
    public Transaction doTransaction(Transaction transaction, Account account) {
        logger.info("Exécution de la transaction: type={}, accountNumber={}, amount={}",
                transaction.getType(), transaction.getAccountNumber(), transaction.getAmount());

        // La logique Drools est appliquée via l'aspect
        logger.info("Après Drools - Transaction: approved={}, message={}",
                transaction.isApproved(), transaction.getMessage());

        // Valider l'état après Drools
        if (transaction.getMessage() == null || transaction.getMessage().isEmpty()) {
            logger.error("La transaction n'a pas de message après l'exécution des règles");
            throw new TransactionFailedException("Échec de la transaction : aucun message fourni par les règles");
        }

        // Vérifier si la transaction est approuvée
        if (!transaction.isApproved()) {
            logger.warn("Transaction non approuvée: {}", transaction.getMessage());
            transactionRepository.save(transaction);
            throw new TransactionFailedException(transaction.getMessage());
        }

        // Sauvegarder les modifications
        logger.info("Sauvegarde de Account: balance={}", account.getBalance());
        accountRepository.save(account);
        transactionRepository.save(transaction);

        return transaction;
    }
}

/**
 * Service pour gérer les comptes et les transactions bancaires.
 */
@Service
public class BankService {
    private static final Logger logger = LoggerFactory.getLogger(BankService.class);
    private final AccountRepository accountRepository;
    private final TransactionRepository transactionRepository;
    private final TransactionProcessor transactionProcessor;

    public BankService(AccountRepository accountRepository, TransactionRepository transactionRepository,
                       TransactionProcessor transactionProcessor) {
        this.accountRepository = accountRepository;
        this.transactionRepository = transactionRepository;
        this.transactionProcessor = transactionProcessor;
    }

    /**
     * Traite une transaction en récupérant le compte associé.
     *
     * @param transaction La transaction à traiter.
     * @return La transaction mise à jour.
     * @throws AccountNotFoundException Si le compte n'existe pas.
     */
    @Transactional
    public Transaction getAccountAndDoTransaction(Transaction transaction) {
        logger.info("Récupération du compte pour la transaction: {}", transaction.getAccountNumber());
        Account account = accountRepository.findById(transaction.getAccountNumber())
                .orElseThrow(() -> {
                    logger.error("Compte non trouvé : {}", transaction.getAccountNumber());
                    return new AccountNotFoundException("Compte non trouvé : " + transaction.getAccountNumber());
                });
        return transactionProcessor.doTransaction(transaction, account);
    }

    /**
     * Crée un nouveau compte.
     *
     * @param account Le compte à créer.
     * @return Le compte créé.
     */
    @Transactional
    public Account createAccount(Account account) {
        logger.info("Création du compte: {}", account.getAccountNumber());
        return accountRepository.save(account);
    }

    /**
     * Récupère un compte par son numéro.
     *
     * @param accountNumber Le numéro du compte.
     * @return Le compte trouvé.
     * @throws AccountNotFoundException Si le compte n'existe pas.
     */
    public Account getAccount(String accountNumber) {
        return accountRepository.findById(accountNumber)
                .orElseThrow(() -> {
                    logger.error("Compte non trouvé : {}", accountNumber);
                    return new AccountNotFoundException("Compte non trouvé : " + accountNumber);
                });
    }
}
@RestController
@RequestMapping("/api/bank")
public class BankController {
    private static final Logger logger = LoggerFactory.getLogger(BankController.class);
    private final BankService bankService;

    public BankController(BankService bankService) {
        this.bankService = bankService;
    }

    @Operation(summary = "Créer un nouveau compte", description = "Crée un compte bancaire avec les informations fournies.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "Compte créé avec succès"),
            @ApiResponse(responseCode = "400", description = "Données invalides")
    })
    @PostMapping("/accounts")
    public ResponseEntity<Account> createAccount(@Valid @RequestBody Account account) {
        logger.info("Requête pour créer le compte: {}", account.getAccountNumber());
        return ResponseEntity.ok(bankService.createAccount(account));
    }

    @Operation(summary = "Récupérer un compte", description = "Récupère les détails d'un compte par son numéro.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "Compte trouvé"),
            @ApiResponse(responseCode = "404", description = "Compte non trouvé")
    })
    @GetMapping("/accounts/{accountNumber}")
    public ResponseEntity<Account> getAccount(@PathVariable String accountNumber) {
        logger.info("Requête pour récupérer le compte: {}", accountNumber);
        return ResponseEntity.ok(bankService.getAccount(accountNumber));
    }

    @Operation(summary = "Traiter une transaction", description = "Traite une transaction (dépôt ou retrait) pour un compte.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "Transaction traitée avec succès"),
            @ApiResponse(responseCode = "400", description = "Transaction invalide ou non approuvée"),
            @ApiResponse(responseCode = "404", description = "Compte non trouvé")
    })
    @PostMapping("/transactions")
    public ResponseEntity<Transaction> processTransaction(@Valid @RequestBody Transaction transaction) {
        logger.info("Requête pour traiter la transaction: type={}, accountNumber={}, amount={}",
                transaction.getType(), transaction.getAccountNumber(), transaction.getAmount());
        Transaction result = bankService.getAccountAndDoTransaction(transaction);
        return ResponseEntity.ok(result);
    }
}

Conclusion

L’intégration de Drools dans Spring Boot offre une approche puissante pour gérer des règles métier complexes tout en maintenant une architecture modulaire.

Notre cas pratique montre comment utiliser Drools pour valider et traiter des transactions bancaires, avec une configuration flexible et une séparation claire des responsabilités.

Bien que la configuration initiale puisse sembler complexe, les avantages en termes de flexibilité et de maintenabilité sont significatifs, surtout pour les applications nécessitant des mises à jour fréquentes des règles.