diff --git a/pom.xml b/pom.xml index b856945f3b1e..b4169285af7a 100644 --- a/pom.xml +++ b/pom.xml @@ -208,6 +208,7 @@ resource-acquisition-is-initialization retry role-object + rule-engine saga separated-interface serialized-entity diff --git a/rule-engine/README.md b/rule-engine/README.md new file mode 100644 index 000000000000..53106a71d524 --- /dev/null +++ b/rule-engine/README.md @@ -0,0 +1,199 @@ +--- +title: "Rule Engine Pattern in Java: Replacing Tangled Conditionals with Composable Rules" +shortTitle: Rule Engine +description: "Learn the Rule Engine design pattern in Java. Encapsulate each business rule as an independent object and let an engine evaluate and execute them against a shared context, replacing large nested if/else blocks with small, testable, composable rules." +category: Behavioral +language: en +tag: + - Business + - Decoupling + - Domain + - Encapsulation + - Extensibility +--- + +## Intent of Rule Engine Design Pattern + +Encapsulate each business rule as an independent, self-contained object and let an engine evaluate and execute a collection of those rules against a shared context, so that decision logic can grow and change without rewriting a large nested conditional block. + +## Detailed Explanation of Rule Engine Pattern with Real-World Examples + +Real-world example + +> A bank decides whether to approve a loan by checking several independent criteria: the applicant must be old enough, earn enough, and have a good enough credit score. Instead of hard-coding one enormous condition, the bank keeps each criterion as a separate policy on a checklist. A clerk walks the whole checklist for every application, ticks off the criteria that pass, and writes down the ones that fail. Adding a new criterion means adding a line to the checklist, not rewriting the whole approval procedure. + +In plain words + +> The Rule Engine pattern turns each branch of a giant `if/else` into its own object and hands a collection of them to an engine that runs them all against the same input, collecting which passed and which failed. + +## Programmatic Example of Rule Engine Pattern in Java + +Each rule separates the **decision** (`evaluate`) from the **action** (`execute`). `evaluate` reports whether a rule's condition holds; `execute` performs the side effect associated with a satisfied rule. + +```java +public interface Rule { + + String name(); + + boolean evaluate(T context); + + void execute(T context); +} +``` + +The context is an immutable value object that carries the data the rules inspect. + +```java +public record LoanApplication( + int age, double monthlyIncome, double loanAmount, int creditScore) {} +``` + +Concrete rules encapsulate one criterion each. New criteria are added by writing new classes — the engine never changes. + +```java +public class MinimumAgeRule implements Rule { + + private final int minimumAge; + + public MinimumAgeRule(int minimumAge) { + this.minimumAge = minimumAge; + } + + @Override + public String name() { + return "MinimumAgeRule"; + } + + @Override + public boolean evaluate(LoanApplication context) { + return context.age() >= minimumAge; + } + + @Override + public void execute(LoanApplication context) { + LOGGER.info("Applicant age {} meets the minimum age of {}.", context.age(), minimumAge); + } +} +``` + +The engine holds a defensively copied, immutable list of rules. It evaluates every rule in insertion order, executes the ones that pass, and reports the combined outcome. It never stops at the first failure, so a single run reports *all* the reasons an application was rejected. + +```java +public class RuleEngine { + + private final List> rules; + + public RuleEngine(List> rules) { + this.rules = List.copyOf(rules); + } + + public RuleEngineResult run(T context) { + Objects.requireNonNull(context, "context must not be null"); + List passed = new ArrayList<>(); + List failed = new ArrayList<>(); + for (Rule rule : rules) { + if (rule.evaluate(context)) { + rule.execute(context); + passed.add(rule.name()); + } else { + failed.add(rule.name()); + } + } + return new RuleEngineResult(failed.isEmpty(), passed, failed); + } +} +``` + +The result is an immutable value object describing the run. + +```java +public record RuleEngineResult( + boolean approved, List passedRules, List failedRules) { + + public RuleEngineResult { + passedRules = List.copyOf(passedRules); + failedRules = List.copyOf(failedRules); + } +} +``` + +Putting it together, the `App` builds an engine and runs two applications through it. + +```java +var engine = + new RuleEngine<>( + List.of( + new MinimumAgeRule(18), + new MinimumIncomeRule(2000.0), + new CreditScoreRule(650))); + +engine.run(new LoanApplication(30, 3500.0, 15000.0, 720)); // approved +engine.run(new LoanApplication(17, 1500.0, 15000.0, 720)); // rejected: age and income +``` + +Program output: + +``` +Applicant age 30 meets the minimum age of 18. +Applicant income 3500.0 meets the minimum income of 2000.0. +Applicant credit score 720 meets the minimum score of 650. +Loan approved. Passed rules: [MinimumAgeRule, MinimumIncomeRule, CreditScoreRule] +Applicant credit score 720 meets the minimum score of 650. +Loan rejected. Failed rules: [MinimumAgeRule, MinimumIncomeRule] +``` + +### Behavioral decisions + +The example commits to the following semantics, each covered by tests: + +* `evaluate` returning `true` means the rule **passes** (its condition is satisfied); a passing rule then has its `execute` action run. +* The engine evaluates **every** rule; it does not stop after the first failure, so all rejection reasons are reported. +* Outcomes are reported as an immutable `RuleEngineResult` carrying the overall `approved` flag plus the names of the passed and failed rules. +* Rules execute in **insertion order**. +* A `null` context passed to `run` throws `NullPointerException`; a `null` rule collection or a `null` rule element is rejected on construction. +* An engine with **no rules** approves vacuously (nothing failed). + +## Class diagram + +See [rule-engine.urm.puml](./etc/rule-engine.urm.puml) for the PlantUML class diagram. + +## When to Use the Rule Engine Pattern in Java + +* Decision logic is a growing set of independent conditions that would otherwise become a large, hard-to-read nested `if/else`. +* Rules must be added, removed, or reordered without touching the code that coordinates them. +* You need to report *all* failing conditions, not just the first one. +* Business rules deserve to be unit tested in isolation. + +## Real-World Applications of Rule Engine Pattern in Java + +* Loan, insurance, and credit approval workflows. +* Validation frameworks such as Bean Validation, where each constraint is an independent rule. +* Fraud detection and risk scoring, where many independent checks contribute to one decision. +* Pricing, discount, and promotion eligibility engines. + +## Benefits and Trade-offs of Rule Engine Pattern + +Benefits + +* Encapsulation: each rule owns one criterion. +* Extensibility: new rules are new classes; the engine is closed for modification. +* Testability: rules and the engine can be tested independently. +* Transparency: the result lists every passed and failed rule. + +Trade-offs + +* Debugging a decision means tracing several small objects instead of reading one block. +* Rule ordering and conflicts must be managed deliberately when rules are not independent. +* Over-abstracting trivial logic into a rule engine adds indirection that a simple `if` would not. + +## Related Java Design Patterns + +* [Specification](../specification): combines boolean criteria that an object must satisfy; a Rule Engine orchestrates and executes such criteria. +* [Chain of Responsibility](../chain-of-responsibility): passes a request along handlers; a Rule Engine instead runs every rule against one context. +* [Strategy](../strategy): each rule is effectively a pluggable strategy for one decision. +* [Command](../command): a rule's `execute` action resembles an encapsulated command. + +## References and Credits + +* [Patterns of Enterprise Application Architecture](https://www.amazon.com/gp/product/0321127420) (Martin Fowler) +* [Should I use a Rules Engine? (Martin Fowler)](https://martinfowler.com/bliki/RulesEngine.html) diff --git a/rule-engine/etc/rule-engine.urm.puml b/rule-engine/etc/rule-engine.urm.puml new file mode 100644 index 000000000000..c560f7862485 --- /dev/null +++ b/rule-engine/etc/rule-engine.urm.puml @@ -0,0 +1,61 @@ +@startuml +package com.iluwatar.ruleengine { + interface Rule { + + name() : String {abstract} + + evaluate(context : T) : boolean {abstract} + + execute(context : T) : void {abstract} + } + class RuleEngine { + - rules : List> + + RuleEngine(rules : List>) + + run(context : T) : RuleEngineResult + + rules() : List> + } + class RuleEngineResult { + + RuleEngineResult(approved : boolean, passedRules : List, failedRules : List) + + approved() : boolean + + passedRules() : List + + failedRules() : List + } + class LoanApplication { + + LoanApplication(age : int, monthlyIncome : double, loanAmount : double, creditScore : int) + + age() : int + + monthlyIncome() : double + + loanAmount() : double + + creditScore() : int + } + class MinimumAgeRule { + - minimumAge : int + + MinimumAgeRule(minimumAge : int) + + name() : String + + evaluate(context : LoanApplication) : boolean + + execute(context : LoanApplication) : void + } + class MinimumIncomeRule { + - minimumIncome : double + + MinimumIncomeRule(minimumIncome : double) + + name() : String + + evaluate(context : LoanApplication) : boolean + + execute(context : LoanApplication) : void + } + class CreditScoreRule { + - minimumScore : int + + CreditScoreRule(minimumScore : int) + + name() : String + + evaluate(context : LoanApplication) : boolean + + execute(context : LoanApplication) : void + } + class App { + + App() + + main(args : String[]) : void + } +} +RuleEngine --> "*" Rule +RuleEngine ..> RuleEngineResult +MinimumAgeRule ..|> Rule +MinimumIncomeRule ..|> Rule +CreditScoreRule ..|> Rule +MinimumAgeRule ..> LoanApplication +MinimumIncomeRule ..> LoanApplication +CreditScoreRule ..> LoanApplication +@enduml diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml new file mode 100644 index 000000000000..29738d783a99 --- /dev/null +++ b/rule-engine/pom.xml @@ -0,0 +1,70 @@ + + + + + java-design-patterns + com.iluwatar + 1.26.0-SNAPSHOT + + 4.0.0 + rule-engine + + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + + + + com.iluwatar.ruleengine.App + + + + + + + + + diff --git a/rule-engine/src/main/java/com/iluwatar/ruleengine/App.java b/rule-engine/src/main/java/com/iluwatar/ruleengine/App.java new file mode 100644 index 000000000000..78a51f152b84 --- /dev/null +++ b/rule-engine/src/main/java/com/iluwatar/ruleengine/App.java @@ -0,0 +1,69 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * The Rule Engine pattern encapsulates each business rule as an independent object and lets a + * {@link RuleEngine} evaluate a collection of them against a shared context. This replaces large, + * tangled conditional blocks with small, testable rules that can be added or removed without + * touching the orchestration logic. + * + *

This demo builds a loan approval engine from three rules and runs two applications through it: + * one that satisfies every rule and one that fails two of them. Because the engine evaluates every + * rule rather than stopping at the first failure, the result reports all the reasons an application + * was rejected. + */ +@Slf4j +public class App { + + /** + * Program entry point. + * + * @param args command line arguments + */ + public static void main(String[] args) { + var engine = + new RuleEngine<>( + List.of( + new MinimumAgeRule(18), new MinimumIncomeRule(2000.0), new CreditScoreRule(650))); + + var approved = new LoanApplication(30, 3500.0, 15000.0, 720); + var rejected = new LoanApplication(17, 1500.0, 15000.0, 720); + + report(engine.run(approved)); + report(engine.run(rejected)); + } + + private static void report(RuleEngineResult result) { + if (result.approved()) { + LOGGER.info("Loan approved. Passed rules: {}", result.passedRules()); + } else { + LOGGER.info("Loan rejected. Failed rules: {}", result.failedRules()); + } + } +} diff --git a/rule-engine/src/main/java/com/iluwatar/ruleengine/CreditScoreRule.java b/rule-engine/src/main/java/com/iluwatar/ruleengine/CreditScoreRule.java new file mode 100644 index 000000000000..28cd59baadbb --- /dev/null +++ b/rule-engine/src/main/java/com/iluwatar/ruleengine/CreditScoreRule.java @@ -0,0 +1,61 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import lombok.extern.slf4j.Slf4j; + +/** Passes when the applicant's credit score reaches the required minimum. */ +@Slf4j +public class CreditScoreRule implements Rule { + + private final int minimumScore; + + /** + * Creates the rule. + * + * @param minimumScore the inclusive minimum credit score the applicant must have + */ + public CreditScoreRule(int minimumScore) { + this.minimumScore = minimumScore; + } + + @Override + public String name() { + return "CreditScoreRule"; + } + + @Override + public boolean evaluate(LoanApplication context) { + return context.creditScore() >= minimumScore; + } + + @Override + public void execute(LoanApplication context) { + LOGGER.info( + "Applicant credit score {} meets the minimum score of {}.", + context.creditScore(), + minimumScore); + } +} diff --git a/rule-engine/src/main/java/com/iluwatar/ruleengine/LoanApplication.java b/rule-engine/src/main/java/com/iluwatar/ruleengine/LoanApplication.java new file mode 100644 index 000000000000..0eda1f17d3e2 --- /dev/null +++ b/rule-engine/src/main/java/com/iluwatar/ruleengine/LoanApplication.java @@ -0,0 +1,38 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +/** + * Immutable context that the loan {@link Rule rules} are evaluated against. + * + *

Using a record keeps the domain object immutable and free of behaviour, so the business logic + * lives entirely inside the rules rather than being scattered across the data it operates on. + * + * @param age applicant age in years + * @param monthlyIncome applicant net monthly income + * @param loanAmount requested loan amount + * @param creditScore applicant credit score + */ +public record LoanApplication(int age, double monthlyIncome, double loanAmount, int creditScore) {} diff --git a/rule-engine/src/main/java/com/iluwatar/ruleengine/MinimumAgeRule.java b/rule-engine/src/main/java/com/iluwatar/ruleengine/MinimumAgeRule.java new file mode 100644 index 000000000000..71532dde4707 --- /dev/null +++ b/rule-engine/src/main/java/com/iluwatar/ruleengine/MinimumAgeRule.java @@ -0,0 +1,58 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import lombok.extern.slf4j.Slf4j; + +/** Passes when the applicant is at least the required minimum age. */ +@Slf4j +public class MinimumAgeRule implements Rule { + + private final int minimumAge; + + /** + * Creates the rule. + * + * @param minimumAge the inclusive minimum age the applicant must reach + */ + public MinimumAgeRule(int minimumAge) { + this.minimumAge = minimumAge; + } + + @Override + public String name() { + return "MinimumAgeRule"; + } + + @Override + public boolean evaluate(LoanApplication context) { + return context.age() >= minimumAge; + } + + @Override + public void execute(LoanApplication context) { + LOGGER.info("Applicant age {} meets the minimum age of {}.", context.age(), minimumAge); + } +} diff --git a/rule-engine/src/main/java/com/iluwatar/ruleengine/MinimumIncomeRule.java b/rule-engine/src/main/java/com/iluwatar/ruleengine/MinimumIncomeRule.java new file mode 100644 index 000000000000..fc6d40964fc9 --- /dev/null +++ b/rule-engine/src/main/java/com/iluwatar/ruleengine/MinimumIncomeRule.java @@ -0,0 +1,61 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import lombok.extern.slf4j.Slf4j; + +/** Passes when the applicant's monthly income reaches the required minimum. */ +@Slf4j +public class MinimumIncomeRule implements Rule { + + private final double minimumIncome; + + /** + * Creates the rule. + * + * @param minimumIncome the inclusive minimum monthly income the applicant must earn + */ + public MinimumIncomeRule(double minimumIncome) { + this.minimumIncome = minimumIncome; + } + + @Override + public String name() { + return "MinimumIncomeRule"; + } + + @Override + public boolean evaluate(LoanApplication context) { + return context.monthlyIncome() >= minimumIncome; + } + + @Override + public void execute(LoanApplication context) { + LOGGER.info( + "Applicant income {} meets the minimum income of {}.", + context.monthlyIncome(), + minimumIncome); + } +} diff --git a/rule-engine/src/main/java/com/iluwatar/ruleengine/Rule.java b/rule-engine/src/main/java/com/iluwatar/ruleengine/Rule.java new file mode 100644 index 000000000000..723b31e73b52 --- /dev/null +++ b/rule-engine/src/main/java/com/iluwatar/ruleengine/Rule.java @@ -0,0 +1,61 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +/** + * Abstraction for a single, self-contained business rule. + * + *

A rule separates the decision ({@link #evaluate(Object)}) from the action ({@link + * #execute(Object)}). {@code evaluate} inspects the context and reports whether the rule's + * condition is satisfied, while {@code execute} performs the side effect associated with a + * satisfied rule. Keeping the two responsibilities apart lets a {@link RuleEngine} decide which + * rules apply before running any action, and lets new rules be added without touching the engine. + * + * @param type of the context the rule is evaluated against + */ +public interface Rule { + + /** + * Returns a human readable identifier used when reporting rule outcomes. + * + * @return the rule name + */ + String name(); + + /** + * Evaluates the rule against the given context. + * + * @param context the immutable input the rule inspects + * @return {@code true} when the rule's condition is satisfied, {@code false} otherwise + */ + boolean evaluate(T context); + + /** + * Performs the action associated with a satisfied rule. + * + * @param context the immutable input the action operates on + */ + void execute(T context); +} diff --git a/rule-engine/src/main/java/com/iluwatar/ruleengine/RuleEngine.java b/rule-engine/src/main/java/com/iluwatar/ruleengine/RuleEngine.java new file mode 100644 index 000000000000..85a8db9d74b5 --- /dev/null +++ b/rule-engine/src/main/java/com/iluwatar/ruleengine/RuleEngine.java @@ -0,0 +1,91 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Orchestrates a fixed collection of {@link Rule rules} against a context. + * + *

The engine owns the coordination logic that would otherwise live in a large nested {@code + * if}/{@code else} block: it evaluates every rule in insertion order, runs the action of each rule + * whose condition is satisfied, and reports the combined outcome as a {@link RuleEngineResult}. + * Evaluation never stops early, so a single run reports all failing rules rather than only + * the first, which is what makes rejection reasons useful. + * + *

The rule collection is copied defensively on construction, making the engine immutable and + * safe to reuse across many contexts. New behaviour is added by supplying additional {@link Rule} + * implementations; the engine itself never changes. + * + * @param type of the context the rules are evaluated against + */ +public class RuleEngine { + + private final List> rules; + + /** + * Creates an engine for the given rules. + * + * @param rules the rules to evaluate, applied in iteration order; must not be {@code null} or + * contain {@code null} elements + * @throws NullPointerException if {@code rules} is {@code null} or contains a {@code null} rule + */ + public RuleEngine(List> rules) { + this.rules = List.copyOf(rules); + } + + /** + * Evaluates every rule against the context and executes the rules that pass. + * + * @param context the context inspected by the rules; must not be {@code null} + * @return the combined outcome of the run + * @throws NullPointerException if {@code context} is {@code null} + */ + public RuleEngineResult run(T context) { + Objects.requireNonNull(context, "context must not be null"); + List passed = new ArrayList<>(); + List failed = new ArrayList<>(); + for (Rule rule : rules) { + if (rule.evaluate(context)) { + rule.execute(context); + passed.add(rule.name()); + } else { + failed.add(rule.name()); + } + } + return new RuleEngineResult(failed.isEmpty(), passed, failed); + } + + /** + * Returns the rules held by this engine. + * + * @return an immutable view of the configured rules + */ + public List> rules() { + return rules; + } +} diff --git a/rule-engine/src/main/java/com/iluwatar/ruleengine/RuleEngineResult.java b/rule-engine/src/main/java/com/iluwatar/ruleengine/RuleEngineResult.java new file mode 100644 index 000000000000..868e3986602f --- /dev/null +++ b/rule-engine/src/main/java/com/iluwatar/ruleengine/RuleEngineResult.java @@ -0,0 +1,48 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import java.util.List; + +/** + * Immutable outcome produced by {@link RuleEngine#run(Object)}. + * + *

The result reports whether every rule passed and, for transparency, the names of the rules + * that passed and failed in the order they were evaluated. The lists are copied on construction so + * the result cannot be mutated by callers or by later changes to the source collections. + * + * @param approved {@code true} when no rule failed + * @param passedRules names of the rules whose condition was satisfied, in evaluation order + * @param failedRules names of the rules whose condition was not satisfied, in evaluation order + */ +public record RuleEngineResult( + boolean approved, List passedRules, List failedRules) { + + /** Defensive copy keeps the value object immutable. */ + public RuleEngineResult { + passedRules = List.copyOf(passedRules); + failedRules = List.copyOf(failedRules); + } +} diff --git a/rule-engine/src/test/java/com/iluwatar/ruleengine/AppTest.java b/rule-engine/src/test/java/com/iluwatar/ruleengine/AppTest.java new file mode 100644 index 000000000000..a8746fa0c387 --- /dev/null +++ b/rule-engine/src/test/java/com/iluwatar/ruleengine/AppTest.java @@ -0,0 +1,37 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.Test; + +class AppTest { + + @Test + void shouldLaunchApp() { + assertDoesNotThrow(() -> App.main(new String[] {})); + } +} diff --git a/rule-engine/src/test/java/com/iluwatar/ruleengine/LoanRulesTest.java b/rule-engine/src/test/java/com/iluwatar/ruleengine/LoanRulesTest.java new file mode 100644 index 000000000000..80ba160b1ffa --- /dev/null +++ b/rule-engine/src/test/java/com/iluwatar/ruleengine/LoanRulesTest.java @@ -0,0 +1,81 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Verifies each concrete rule independently at, above and below its threshold. */ +class LoanRulesTest { + + private static LoanApplication applicant(int age, double income, int score) { + return new LoanApplication(age, income, 10000.0, score); + } + + @Test + void minimumAgeRulePassesAtOrAboveThreshold() { + var rule = new MinimumAgeRule(18); + assertTrue(rule.evaluate(applicant(18, 3000.0, 700))); + assertTrue(rule.evaluate(applicant(40, 3000.0, 700))); + assertDoesNotThrow(() -> rule.execute(applicant(18, 3000.0, 700))); + } + + @Test + void minimumAgeRuleFailsBelowThreshold() { + var rule = new MinimumAgeRule(18); + assertFalse(rule.evaluate(applicant(17, 3000.0, 700))); + } + + @Test + void minimumIncomeRulePassesAtOrAboveThreshold() { + var rule = new MinimumIncomeRule(2000.0); + assertTrue(rule.evaluate(applicant(30, 2000.0, 700))); + assertTrue(rule.evaluate(applicant(30, 5000.0, 700))); + assertDoesNotThrow(() -> rule.execute(applicant(30, 2000.0, 700))); + } + + @Test + void minimumIncomeRuleFailsBelowThreshold() { + var rule = new MinimumIncomeRule(2000.0); + assertFalse(rule.evaluate(applicant(30, 1999.99, 700))); + } + + @Test + void creditScoreRulePassesAtOrAboveThreshold() { + var rule = new CreditScoreRule(650); + assertTrue(rule.evaluate(applicant(30, 3000.0, 650))); + assertTrue(rule.evaluate(applicant(30, 3000.0, 800))); + assertDoesNotThrow(() -> rule.execute(applicant(30, 3000.0, 650))); + } + + @Test + void creditScoreRuleFailsBelowThreshold() { + var rule = new CreditScoreRule(650); + assertFalse(rule.evaluate(applicant(30, 3000.0, 649))); + } +} diff --git a/rule-engine/src/test/java/com/iluwatar/ruleengine/RuleEngineResultTest.java b/rule-engine/src/test/java/com/iluwatar/ruleengine/RuleEngineResultTest.java new file mode 100644 index 000000000000..0bae5dc12c0d --- /dev/null +++ b/rule-engine/src/test/java/com/iluwatar/ruleengine/RuleEngineResultTest.java @@ -0,0 +1,55 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RuleEngineResultTest { + + @Test + void copiesListsDefensivelyOnConstruction() { + var passed = new ArrayList<>(List.of("MinimumAgeRule")); + var failed = new ArrayList<>(List.of("CreditScoreRule")); + var result = new RuleEngineResult(false, passed, failed); + + passed.add("MinimumIncomeRule"); // mutate the sources after construction + failed.clear(); + + assertEquals(List.of("MinimumAgeRule"), result.passedRules()); + assertEquals(List.of("CreditScoreRule"), result.failedRules()); + } + + @Test + void exposedListsAreUnmodifiable() { + var result = new RuleEngineResult(true, List.of("MinimumAgeRule"), List.of()); + assertThrows(UnsupportedOperationException.class, () -> result.passedRules().add("x")); + assertThrows(UnsupportedOperationException.class, () -> result.failedRules().add("x")); + } +} diff --git a/rule-engine/src/test/java/com/iluwatar/ruleengine/RuleEngineTest.java b/rule-engine/src/test/java/com/iluwatar/ruleengine/RuleEngineTest.java new file mode 100644 index 000000000000..fd8f89943ac9 --- /dev/null +++ b/rule-engine/src/test/java/com/iluwatar/ruleengine/RuleEngineTest.java @@ -0,0 +1,122 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.ruleengine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RuleEngineTest { + + private static RuleEngine loanEngine() { + return new RuleEngine<>( + List.of(new MinimumAgeRule(18), new MinimumIncomeRule(2000.0), new CreditScoreRule(650))); + } + + @Test + void approvesWhenEveryRulePasses() { + var result = loanEngine().run(new LoanApplication(30, 3000.0, 10000.0, 700)); + assertTrue(result.approved()); + assertEquals( + List.of("MinimumAgeRule", "MinimumIncomeRule", "CreditScoreRule"), result.passedRules()); + assertTrue(result.failedRules().isEmpty()); + } + + @Test + void rejectsWhenASingleRuleFails() { + var result = loanEngine().run(new LoanApplication(30, 3000.0, 10000.0, 600)); + assertFalse(result.approved()); + assertEquals(List.of("CreditScoreRule"), result.failedRules()); + assertEquals(List.of("MinimumAgeRule", "MinimumIncomeRule"), result.passedRules()); + } + + @Test + void reportsEveryFailingRuleWithoutStoppingEarly() { + var result = loanEngine().run(new LoanApplication(16, 500.0, 10000.0, 400)); + assertFalse(result.approved()); + assertEquals( + List.of("MinimumAgeRule", "MinimumIncomeRule", "CreditScoreRule"), result.failedRules()); + assertTrue(result.passedRules().isEmpty()); + } + + @Test + void evaluatesRulesInInsertionOrder() { + var engine = + new RuleEngine<>( + List.of( + new CreditScoreRule(650), new MinimumAgeRule(18), new MinimumIncomeRule(2000.0))); + var result = engine.run(new LoanApplication(16, 500.0, 10000.0, 400)); + assertEquals( + List.of("CreditScoreRule", "MinimumAgeRule", "MinimumIncomeRule"), result.failedRules()); + } + + @Test + void emptyEngineApprovesVacuously() { + var result = + new RuleEngine(List.of()).run(new LoanApplication(1, 1.0, 1.0, 1)); + assertTrue(result.approved()); + assertTrue(result.passedRules().isEmpty()); + assertTrue(result.failedRules().isEmpty()); + } + + @Test + void rejectsNullContext() { + assertThrows(NullPointerException.class, () -> loanEngine().run(null)); + } + + @Test + void rejectsNullRuleCollection() { + assertThrows(NullPointerException.class, () -> new RuleEngine(null)); + } + + @Test + void rejectsNullRuleElement() { + var rules = new ArrayList>(); + rules.add(null); + assertThrows(NullPointerException.class, () -> new RuleEngine<>(rules)); + } + + @Test + void copiesRulesDefensivelyOnConstruction() { + var rules = new ArrayList>(); + rules.add(new MinimumAgeRule(18)); + var engine = new RuleEngine<>(rules); + + rules.add(new CreditScoreRule(650)); // mutate the source after construction + assertEquals(1, engine.rules().size()); + } + + @Test + void exposedRulesAreUnmodifiable() { + var engine = loanEngine(); + assertThrows( + UnsupportedOperationException.class, () -> engine.rules().add(new MinimumAgeRule(21))); + } +}