09 — Core
RuleSets
A RuleSet groups multiple rules into an ordered collection and runs them in sequence against the same bindings.
Creating a RuleSet
RuleSet<?> ruleSet = RuleSet.builder()
.with("EligibilityRules", "Checks if user is eligible")
.rule(Rule.builder()
.name("AgeRule")
.given(condition((Integer age) -> age >= 18))
.then(action((Binding<Boolean> eligible) -> eligible.setValue(true)))
.build())
.rule(Rule.builder()
.name("ScoreRule")
.given(condition((Integer score) -> score >= 50))
.then(action((Binding<Integer> bonus) -> bonus.setValue(10)))
.build())
.build();
Running a RuleSet
Bindings bindings = Bindings.builder().standard();
bindings.bind("age", 25);
bindings.bind("score", 75);
bindings.bind("eligible", false);
bindings.bind("bonus", 0);
ruleSet.run(bindings);
// After execution:
// eligible = true (AgeRule passed)
// bonus = 10 (ScoreRule passed)
Lifecycle Hooks
A RuleSet supports optional lifecycle hooks that run at specific points during execution. Each hook has a distinct role — understanding when each fires makes it easy to control setup, teardown, early exit, and result extraction.
PreCondition
The pre-condition is a guard evaluated once before the initializer or any rules fire. If it returns false the entire RuleSet is skipped — no initializer, no rules, no finalizer. Use it when the set only applies to a subset of inputs, avoiding unnecessary rule evaluation and side effects.
Typical uses: feature flags, entity-type guards, role checks, or skipping validation when a field is optional and absent.
// Only run bonus rules for full-time employees
RuleSet<?> bonusRules = RuleSet.builder()
.with("BonusCalculation")
.preCondition(condition((String employmentType) ->
"FULL_TIME".equals(employmentType)))
.rule(/* ... bonus rules ... */)
.build();
// Only validate billing address when a physical shipment is required
RuleSet<?> billingValidation = RuleSet.builder()
.with("BillingAddressValidation")
.preCondition(condition((Boolean requiresShipment) -> requiresShipment))
.rule(/* ... address validation rules ... */)
.build();
// Combine multiple guards with &&
RuleSet<?> sensitiveOps = RuleSet.builder()
.with("SensitiveOperations")
.preCondition(condition((Boolean isActive, Boolean isVerified, String role) ->
isActive && isVerified && "ADMIN".equals(role)))
.rule(/* ... admin-only rules ... */)
.build();
Initializer
The initializer is an Action that runs once after the pre-condition passes but before any rules execute. It is the right place to set up shared state that the rules will read or write — initializing counters, resetting accumulators, recording a start timestamp, or opening a resource that must be closed in the finalizer.
// Stamp the start time so rules and the finalizer can compute elapsed duration
RuleSet<?> timedRules = RuleSet.builder()
.with("TimedRuleSet")
.initializer(action((Binding<Long> startTime) ->
startTime.setValue(System.currentTimeMillis()))
)
.rule(/* ... rules ... */)
.finalizer(action((Long startTime) ->
System.out.println("Elapsed: " + (System.currentTimeMillis() - startTime) + "ms"))
)
.build();
// Initialize an audit log that rules append entries to
RuleSet<?> auditedRules = RuleSet.builder()
.with("AuditedRuleSet")
.initializer(action((Binding<List<String>> auditLog) ->
auditLog.setValue(new ArrayList<>()))
)
.rule(/* ... rules that add to auditLog ... */)
.build();
// Reset a pass counter before each run
RuleSet<?> scoringRules = RuleSet.builder()
.with("ScoringRuleSet")
.initializer(action((Binding<Integer> score) -> score.setValue(0)))
.rule(/* ... scoring rules that increment score ... */)
.build();
Stop Condition
The stop condition is evaluated after each rule fires. When it returns true the remaining rules are skipped and execution jumps straight to the finalizer. Use it to implement fail-fast validation, first-match wins, or quota-based early exit without writing manual loop logic.
The stop condition receives a RuleSetExecutionStatus binding that tracks which rules passed, failed, and were skipped so far. RuleSetConditions provides ready-made stop conditions for the most common patterns.
import static org.rulii.ruleset.RuleSetConditions.*;
// Fail-fast: stop as soon as the first rule fails
// Useful for validation where later rules depend on earlier ones passing
RuleSet<?> strictValidation = RuleSet.builder()
.with("StrictValidation")
.stopCondition(stopWhenOneFails())
.rule(/* ... rules ... */)
.build();
// First-match: stop as soon as one rule passes
// Useful for pricing tiers or discount ladders — apply the first match and stop
RuleSet<?> discountTiers = RuleSet.builder()
.with("DiscountTiers")
.stopCondition(stopWhenOnePasses())
.rule(/* PlatinumDiscount, GoldDiscount, SilverDiscount ... */)
.build();
// Quota: stop after 3 rules have passed (e.g. collect at most 3 matching offers)
RuleSet<?> offerRules = RuleSet.builder()
.with("OfferRules")
.stopCondition(stopOnPassCount(3))
.rule(/* ... offer eligibility rules ... */)
.build();
// Stop if a rule fails OR is skipped — strict all-or-nothing execution
RuleSet<?> strictAllOrNothing = RuleSet.builder()
.with("AllOrNothing")
.stopCondition(stopWhenOneFailsOrSkipped())
.rule(/* ... rules ... */)
.build();
// Custom stop condition using the execution status directly
RuleSet<?> customStop = RuleSet.builder()
.with("CustomStop")
.stopCondition(condition((RuleSetExecutionStatus status) ->
status.getFailed().size() >= 2 || status.getPassed().size() >= 5))
.rule(/* ... rules ... */)
.build();
Finalizer
The finalizer is an Action that always runs after the rules complete — whether they all finished, an early stop was triggered, or an exception was thrown. It is the right place for cleanup logic, committing results, logging summaries, or throwing an exception when validation has failed.
// Close a resource opened in the initializer
RuleSet<?> resourceRules = RuleSet.builder()
.with("ResourceRuleSet")
.initializer(action((Binding<Connection> conn) ->
conn.setValue(openConnection())))
.rule(/* ... rules that use the connection ... */)
.finalizer(action((Connection conn) -> conn.close()))
.build();
// Log a summary of which rules passed and failed
RuleSet<?> loggedRules = RuleSet.builder()
.with("LoggedRuleSet")
.rule(/* ... rules ... */)
.finalizer(action((RuleSetExecutionStatus status) -> {
log.info("Passed: {}, Failed: {}, Skipped: {}",
status.getPassed().size(),
status.getFailed().size(),
status.getSkipped().size());
}))
.build();
Validating RuleSet
When a RuleSet is used purely for validation it needs two standard pieces of plumbing: a RuleViolations container that the rules write into, and a finalizer that throws a ValidationException if any severe violations were collected. You can wire this up manually with a finalizer, or use the .validating() shortcut which does both in one call.
import static org.rulii.validation.rules.Validators.*;
// Manual approach: bind the violations container and throw in the finalizer
RuleSet<?> manualValidation = RuleSet.builder()
.with("OrderValidation")
.rule(notBlank(binding("productCode")).errorCode("ORD-001").build())
.rule(min(binding("quantity"), 1).errorCode("ORD-002").build())
.finalizer(action((RuleViolations ruleViolations) -> {
if (ruleViolations.hasSevereErrors())
throw new ValidationException("Order validation failed", ruleViolations);
}))
.build();
// .validating() shortcut — equivalent to the manual approach above.
// It registers a ruleViolations parameter (default: new RuleViolations())
// and installs the throw-on-severe-errors finalizer automatically.
RuleSet<?> orderValidation = RuleSet.builder()
.with("OrderValidation")
.validating()
.rule(notBlank(binding("productCode")).errorCode("ORD-001").build())
.rule(min(binding("quantity"), 1).errorCode("ORD-002").build())
.rule(decimalMin(binding("unitPrice"), 0.01).errorCode("ORD-003").build())
.build();
// Calling run() will throw ValidationException if any severe violations are collected.
// The caller catches it and reads the violations from the exception.
try {
orderValidation.run(
productCode -> "",
quantity -> 0,
unitPrice -> 12.50
);
} catch (ValidationException ex) {
for (RuleViolation v : ex.getViolations()) {
System.out.println("[" + v.getErrorCode() + "] " + v.getErrorMessage());
}
}
// [ORD-001] must not be blank
// [ORD-002] must be greater than or equal to 1
.validating() only throws when hasSevereErrors() returns true — warnings and informational violations are collected but do not interrupt execution. If you need the violations without an exception, omit .validating(), supply your own RuleViolations binding, and inspect it after run() returns.
Result Extractor
The result extractor is a Function that runs last, after the finalizer, and produces the typed return value of RuleSet.run(). By default it returns the RuleSetExecutionStatus. Override it when the callers expect a specific domain value — a boolean flag, a calculated score, a violations list — rather than the raw execution status.
// Return a boolean eligibility flag set by the rules
RuleSet<Boolean> eligibilityCheck = RuleSet.<Boolean>builder()
.with("EligibilityCheck")
.rule(/* ... rules that set eligible = true/false ... */)
.resultExtractor(function((Boolean eligible) -> eligible))
.build();
boolean isEligible = eligibilityCheck.run(bindings);
// Return the accumulated score computed across multiple rules
RuleSet<Integer> riskScorer = RuleSet.<Integer>builder()
.with("RiskScorer")
.initializer(action((Binding<Integer> riskScore) -> riskScore.setValue(0)))
.rule(/* ... rules that increment riskScore ... */)
.resultExtractor(function((Integer riskScore) -> riskScore))
.build();
int score = riskScorer.run(bindings);
// Return the collected violations for the caller to inspect
RuleSet<RuleViolations> formValidation = RuleSet.<RuleViolations>builder()
.with("FormValidation")
.rule(/* ... validation rules ... */)
.resultExtractor(function((RuleViolations ruleViolations) -> ruleViolations))
.build();
RuleViolations violations = formValidation.run(bindings);
RuleSetConditions
RuleSetConditions is a utility class that provides pre-built Condition instances for the most common stop and pre-condition patterns. Import the class statically to keep the builder calls concise.
Stop Conditions
Stop conditions are evaluated after each rule and receive the live RuleSetExecutionStatus which tracks which rules have passed, failed, and been skipped so far.
| Method | Stops when… |
|---|---|
| stopWhenOneFails() | The first rule fails — fail-fast validation |
| stopOnFailCount(n) | At least n rules have failed |
| stopWhenOnePasses() | The first rule passes — first-match wins |
| stopOnPassCount(n) | At least n rules have passed — collect up to n matches |
| stopOnSkipCount(n) | At least n rules were skipped |
| stopWhenOneFailsOrSkipped() | The first rule fails or is skipped — strict all-or-nothing execution |
Rule Grouping Pre-Conditions
RuleSetConditions also provides four pre-conditions that turn the rules of the RuleSet itself into a boolean gate. Understanding the two-phase evaluation is key to using these correctly:
- Gate phase (pre-condition): rulii calls
rule.isTrue(ruleContext)on each rule — this evaluates only thegivencondition. Nothenaction is invoked and no state is changed. This is a pure read-only check. - Execution phase: If the gate passes, the rules run normally — the
givencondition is evaluated again for each rule, and if it passes thethenaction fires and state changes take effect. - Skip: If the gate does not pass, the entire RuleSet is skipped — no initializer, no rules, no finalizer.
This pattern lets you express AND / OR / NOR / XOR semantics over a group of rules declaratively, without adding guard logic inside each individual rule.
| Method | Gate logic | When to use |
|---|---|---|
| allMustPass() | ALL given conditions true | Every eligibility criterion must be satisfied before processing begins (AND-gate) |
| oneMustPass() | AT LEAST ONE given condition true | User must have at least one qualifying attribute — e.g. email OR phone (OR-gate) |
| noneCanPass() | NO given condition true | Conflict detection — none of the blocking conditions must be active (NOR-gate) |
| onlyOneCanPass() | EXACTLY ONE given condition true | Mutually exclusive selection — exactly one payment method, one shipping tier, etc. (XOR-gate) |
allMustPass() — AND-gate
Apply loyalty rewards only when the customer is premium, verified, and has opted in. In the gate phase the three given conditions are evaluated — no points are awarded yet. Only if all three return true does the set execute and the then actions actually credit the points.
import static org.rulii.ruleset.RuleSetConditions.*;
RuleSet<?> loyaltyRewards = RuleSet.builder()
.with("LoyaltyRewards")
.preCondition(allMustPass())
// Gate phase: only given conditions evaluated — no state change yet
.rule(Rule.builder().name("IsPremium")
.given(condition((String tier) -> "PREMIUM".equals(tier)))
.then(action((Binding<Integer> rewardPoints) -> rewardPoints.setValue(rewardPoints.getValue() + 500)))
.build())
.rule(Rule.builder().name("IsVerified")
.given(condition((Boolean verified) -> verified))
.then(action((Binding<Integer> rewardPoints) -> rewardPoints.setValue(rewardPoints.getValue() + 100)))
.build())
.rule(Rule.builder().name("IsOptedIn")
.given(condition((Boolean rewardsOptIn) -> rewardsOptIn))
.then(action((Binding<Integer> rewardPoints) -> rewardPoints.setValue(rewardPoints.getValue() + 50)))
.build())
.build();
// All three given conditions pass → execution phase runs → rewardPoints += 650
// Any given condition fails → whole set skipped, rewardPoints unchanged
oneMustPass() — OR-gate
Start the account-recovery flow only if the user can verify via at least one of: security question, backup email, or authenticator app. The gate evaluates all three given conditions without side effects. If any one is true the set executes — the passing rule(s) fire their then action and grant the recovery token.
RuleSet<?> accountRecovery = RuleSet.builder()
.with("AccountRecovery")
.preCondition(oneMustPass())
// Gate phase: given conditions checked read-only — no token issued yet
.rule(Rule.builder().name("SecurityQuestion")
.given(condition((Boolean securityQuestionPassed) -> securityQuestionPassed))
.then(action((Binding<String> recoveryToken) -> recoveryToken.setValue(issueToken("QUESTION"))))
.build())
.rule(Rule.builder().name("BackupEmail")
.given(condition((Boolean backupEmailVerified) -> backupEmailVerified))
.then(action((Binding<String> recoveryToken) -> recoveryToken.setValue(issueToken("EMAIL"))))
.build())
.rule(Rule.builder().name("AuthenticatorApp")
.given(condition((Boolean totpPassed) -> totpPassed))
.then(action((Binding<String> recoveryToken) -> recoveryToken.setValue(issueToken("TOTP"))))
.build())
.build();
// At least one given passes → execution runs → matching rule(s) issue a recovery token
// All given conditions fail → set skipped, no token issued
noneCanPass() — NOR-gate / conflict detection
Process a payment only when none of the blocking signals are active. The gate reads the three given conditions without any side effects. If all return false (no blocks active) the set executes and the then actions perform the actual payment steps. If any signal is active, the set is skipped entirely.
RuleSet<?> paymentProcessing = RuleSet.builder()
.with("PaymentProcessing")
.preCondition(noneCanPass())
// Gate phase: blocking flags checked read-only — no payment attempted yet
.rule(Rule.builder().name("FraudFlag")
.given(condition((Boolean fraudFlagged) -> fraudFlagged))
.then(action((Binding<String> paymentStatus) -> paymentStatus.setValue("AUTHORIZED")))
.build())
.rule(Rule.builder().name("AccountSuspended")
.given(condition((Boolean accountSuspended) -> accountSuspended))
.then(action((Binding<String> paymentStatus) -> paymentStatus.setValue("AUTHORIZED")))
.build())
.rule(Rule.builder().name("RegionBlocked")
.given(condition((Boolean regionBlocked) -> regionBlocked))
.then(action((Binding<String> paymentStatus) -> paymentStatus.setValue("AUTHORIZED")))
.build())
.build();
// All given conditions false (no blocks) → execution runs → paymentStatus = "AUTHORIZED"
// Any given condition true (a block is active) → set skipped, paymentStatus unchanged
onlyOneCanPass() — XOR-gate / exclusive selection
Execute checkout rules only when exactly one payment method is selected. The gate evaluates all three given conditions without touching the order — no charges, no routing decisions. Only if exactly one returns true does the set execute and the matching rule's then action routes the payment.
RuleSet<?> checkoutRules = RuleSet.builder()
.with("CheckoutRules")
.preCondition(onlyOneCanPass())
// Gate phase: selection flags checked read-only — no payment routed yet
.rule(Rule.builder().name("CreditCard")
.given(condition((Boolean creditCardSelected) -> creditCardSelected))
.then(action((Binding<String> paymentRoute) -> paymentRoute.setValue("CARD_GATEWAY")))
.build())
.rule(Rule.builder().name("BankTransfer")
.given(condition((Boolean bankTransferSelected) -> bankTransferSelected))
.then(action((Binding<String> paymentRoute) -> paymentRoute.setValue("ACH_GATEWAY")))
.build())
.rule(Rule.builder().name("CryptoPay")
.given(condition((Boolean cryptoSelected) -> cryptoSelected))
.then(action((Binding<String> paymentRoute) -> paymentRoute.setValue("CRYPTO_GATEWAY")))
.build())
.build();
// Exactly 1 selected → execution runs → paymentRoute set to the matching gateway
// 0 selected → skipped. 2+ selected → skipped. paymentRoute unchanged in both cases.
Accessing Rules in a RuleSet
Rule ageRule = ruleSet.getRule("AgeRule"); // by name
Rule first = ruleSet.getRule(0); // by index
List<Rule> all = ruleSet.getRules(); // all rules
int count = ruleSet.size(); // count
boolean empty = ruleSet.isEmpty(); // is empty?
Combining Validation Rules
RuleSets are perfect for grouping validation rules:
import static org.rulii.validation.rules.Validators.*;
RuleSet<?> validationSet = RuleSet.builder()
.with("UserValidation")
.rule(notNull(binding("name")).build())
.rule(notEmpty(binding("name")).build())
.rule(size(binding("name"), 2, 50).build())
.rule(email(binding("email")).build())
.build();
validationSet.run(
name -> "Alice",
email -> "alice@example.com",
ruleViolations -> new RuleViolations()
);