Skip to content

LibEntity Decision

Overview

LibEntity Decision is a decision engine that can be used to make decisions based on a meta-model for business rules. It consolidates LibEntity by bringing structure to this very sensitive aspect of business entities.

Given the action and transition structure, one can use ordinary java code to implement the business rules. But over time, rules tend to fall through the cracks and become obscure, hard to maintain and understand.

LibEntity Decision addresses this issue by providing a decision engine somewhat inspired by decisions4s.

Example

Setup

Make sure to include the annotation processor in your build tool.

Grandle:

groovy
annotationProcessor 'com.libentity:lib-entity-decision:<latest-version>

Define your input type

Similar to decisions4s, you can define your input type using a class.

java
@AllArgsConstructor
@DecisionInput
public class InvoiceInput {
    Rule<String> requesterId;
    Rule<LocalDate> isDateSet;
    Rule<BigDecimal> amount;
    Rule<String> isApproved;
}

Important

At the moment only classes with public/default attributes are supported. Soon records and access via getters will be supported.

The @DecisionInput annotation will generate a value class and a InputProvider interface implementation. More on that later.

Create your rules

The Rule type is a type-safe way to define rules for each attribute of the input type. Here is how to define a series of MatchingRules that will be used by our decision table.

java
var blockedRequesters = Set.of(
    UUID.randomUUID().toString(), 
    UUID.randomUUID().toString(), 
    "98c8627c-2203-4f0b-8d0a-adea7150f6c6");

var rules = List.of(
    MatchingRule.of(
            new InvoiceInput(
                    // in matches if the attribute is in the set
                    in(blockedRequesters),
                    // it is true if the attribute is present
                    isPresent(),
                    // gt fo greater than
                    gt(BigDecimal.valueOf(0.0)),
                    any()),
            // This is the expected output that can be any object
            Boolean.FALSE,
            inputProvider),
    MatchingRule.of(
            new InvoiceInput(
                    // catch all
                    any(),
                    // catch all
                    any(),
                    // grater than 1.0
                    gt(BigDecimal.valueOf(1.0)),
                    // catch all
                    any()),
            Boolean.TRUE,
            inputProvider));

Evaluate

With the rules at hand, we can create a decision table.

java
var decisionTable = DecisionTable.of(rules);

And finally, we can use the decision table to make decisions.

java
var decision = decisionTable.evaluateFirst(
        // InvoiceInputValue is the generated by the framework
        new InvoiceInputValue(
                "a0834601-041b-4837-a2fd-293802cc255f",
                LocalDate.now(),
                BigDecimal.valueOf(1.0),
                "true"));

Diagnose

The decision table result can be inspected by calling decision.diagnose(). The output should be similar to:

Hit Policy: First
Result: Optional[true]
Input: 
  requesterId: user1
  isDateSet: 2025-05-29
  amount: 100
  isApproved: "true"
Rule 0 [f]:
  requesterId  [f]: in( 2a9ad172-9f2f-4dbc-9093-6dbb20b5b07e, 98c8627c-2203-4f0b-8d0a-adea7150f6c6, 95c50ae5-4b4a-44f7-b8ca-fe00b7de8524 )
  isDateSet    [t]: isPresent
  amount       [t]: > 0.0
  isApproved   [t]: -
  == x
Rule 1 [t]:
  requesterId  [t]: -
  isDateSet    [t]: -
  amount       [t]: > 1.0
  isApproved   [t]: -
  == true

The way to read is as follows:

  • Hit Policy: First means that the first matching rule output will be used as the final result of the evaluation.
  • Result: Optional[true] means that the first matching rule output was true.
  • Input: Outputs the values given to the decision table.
  • Rule x [f/t]: For each MatchingRule outputs each attribute match result. f means false and t means true.

Hit Policies

LibEntity Decision provides a few hit policies:

  • First: Returns the first matching rule output.
  • Unique: Returns the first matching rule as long as there are no other rule also matching the input. Useful for cases where only one rule should match and more than one trigger may indication some unexpected behavior.
  • Collect : Returns the output of all rules.
  • Sum: Allows the call site to provide a BinaryOperator on the result of distinct match policy outcomes.

Matchers

The rules can be formed by arbitrary code, or preferredly by using matchers. Here are the available matchers:

  • Rule.any(): Matches any value. Always true.
  • Rule.in(Set<T> target): Matches if the value is in the set.
  • Rule.gt(T target): Matches if the value is greater than the given value.
  • Rule.ge(T target): Matches if the value is greater than or equal to the given value.
  • Rule.lt(T target): Matches if the value is less than the given value.
  • Rule.le(T target): Matches if the value is less than or equal to the given value.
  • Rule.is(T target): Matches if the value is equal to the given value.
  • Rule.isPresent(): Matches if the value is not null. If it's an Optional, it matches if it is present.

Boolean logic

The especial Rule.test takes a predicate and returns a Rule<T> that matches if the predicate returns true. The produced Rule for each matcher can be used to be negated with a not() call. For example Rule.is("Samba").not().evaluate("Rock") will evaluate to true.

Similarly, Rule<T>.and(other) and Rule<T>.or(other) can be used to combine rules into more complex structures.

Why Not Just Write Plain Java?

For small or isolated decisions, using plain Java logic might seem simpler:

java
if (blockedRequesters.contains(requesterId)
        && isDateSet != null
        && amount.compareTo(BigDecimal.ZERO) > 0) {
    return false;
}

if (amount.compareTo(BigDecimal.ONE) > 0) {
    return true;
}

return false;

While this is readable at first glance, it becomes problematic as business rules evolve:

❌ Scattered logic: Rules get embedded across services, controllers, and utilities.

❌ Poor traceability: It’s hard to answer “Why did this payment transition fail?” or “What rule applied?”

❌ Limited diagnostics: No built-in insight into which rule matched or failed.

❌ Hard to test in isolation: Rules are often coupled to state and environment.

❌ High change risk: Modifying logic can introduce subtle regressions.

Advantages of LibEntity Decision LibEntity Decision was designed to mitigate these issues:

✅ Centralized rules: Business rules are defined in one place using structured, testable constructs.

✅ Declarative API: Rules are expressed as data, not control flow, making them easier to read and change.

✅ Traceability: Rule matches are fully diagnosable via .diagnose(), allowing introspection into decision outcomes.

✅ Policied evaluation: Choose between First, Unique, Collect or Sum evaluation strategies for different business needs.

✅ Auto-generated support: @DecisionInput eliminates boilerplate and enforces type safety.

✅ Composable matchers: Clean, reusable, and composable rule conditions like isPresent(), in(...), gt(...), etc.

FeatureManual Java LogicLibEntity Decision
Code structureImperative, scatteredDeclarative, centralized
DiagnosabilityManual loggingBuilt-in .diagnose()
Rule compositionCustom logic per caseMatchers & rules
MaintainabilityRisky with complexityScales with complexity
TestabilityOften tightly coupledFully testable in isolation
Evolution of logicRequires refactoringRules can be reordered safely

Future explorations

At the moment evaluation happens eagerly and Policies are used as a way to extract the results. Future explorations may include a lazy evaluation approach, moving the evaluation to each policy.

The remaining Policies available for decisions4s will be implemented.

A Lib-Entity Action Handler backed by a decision will be made available for a tight integration.

Released under the MIT License. Made with ☕, 💡 and lots of Java love.