Core — New in 2.0.0
RuleFlows
A RuleFlow is a composable, executable pipeline that orchestrates Rules, RuleSets, nested RuleFlows, Functions, and Actions into a single flow — with typed input parameters, bindings, conditionals, loops, scopes, exception handling, and async execution built in.
RuleFlow is the recommended way to run rules
Prefer running Rules and RuleSets through a RuleFlow instead of calling rule.run(...) or ruleSet.run(...) directly. A flow gives you validated input parameters, declarative wiring of results between steps, uniform exception handling, tracing, and async execution — without hand-rolling bindings and orchestration code around every call. Direct execution remains available, but treat it as the low-level API.
Your First RuleFlow
import static org.rulii.model.function.Functions.function;
RuleFlow<BigDecimal> checkout = RuleFlow.builder()
.name("CheckoutFlow")
.description("Validates and prices an order")
.param("order", Order.class) // required input
.run(validationRules) // a RuleSet
.run(pricingRule, spec -> spec.as("price")) // a Rule; result bound as "price"
.<BigDecimal>returning(function((BigDecimal price) -> price))
.build();
BigDecimal price = checkout.run(order -> myOrder);
Every pipeline method returns the builder, so steps chain fluently and execute in declaration order. The name is required and must match [a-zA-Z$_][a-zA-Z0-9$_]*.
Running a RuleFlow
flow.run(); // no inputs
flow.run(orderId -> "A-1", amount -> 250); // BindingDeclaration lambdas
flow.run(bindings); // an existing Bindings instance
flow.run(ruleContext); // caller-supplied RuleContext
// Asynchronously — returns a CompletableFuture
CompletableFuture<BigDecimal> future = flow.runAsync(ruleContext);
CompletableFuture<BigDecimal> bounded = flow.runAsync(ruleContext, 10, TimeUnit.SECONDS);
Default result
Without returning(...), run() returns the RuleContext, giving you access to all bindings after execution.
Command Reference
The full set of builder commands:
| Command | Purpose |
|---|---|
name(name) |
Flow name (required) |
description(text) |
Optional human-readable description |
context(configurator) |
Customize the RuleContext — must be the first step, once only |
param(name, type, ...) |
Declare a required or optional input parameter, optionally with a default |
bind(...) / bindTo(scope, ...) |
Add bindings to the current (or a named) scope |
run(...) |
Run a Rule, RuleSet, nested RuleFlow, or a registry lookup by name/class |
apply(function) |
Apply a Function; bind its result with spec.as(...) |
execute(action) |
Execute a void side-effect Action |
when(cond, then[, otherwise]) |
Conditional branch — bodies are Consumer<builder> lambdas |
forEach(source, name[, stop], body) |
Iterate a collection; binds each element and a 0-based index |
scope([name,] body) |
Run a body in a (named) binding scope; bindings are discarded afterwards |
exit([extractor]) |
Terminate the flow immediately, optionally extracting a result |
asyncRun(...) |
Launch a Rule/RuleSet/RuleFlow asynchronously; result is a CompletableFuture |
await / awaitAll / awaitAny |
Block on one, all, or any of the named future bindings (default timeout 30s) |
command(cmd) |
Inject a pre-built custom RuleFlowCommand |
onException(type, body) |
Flow-level (global) exception handler |
finalizer(action) |
Always runs after the pipeline — even on early exit or exception |
returning([extractor]) |
Result extractor; no-arg form returns the RuleContext |
build() |
Validate the pipeline structure and produce an immutable RuleFlow<T> |
Input Parameters
Parameters declared with param(...) are validated before the pipeline runs. A missing required parameter throws UnrulyException:
.param("orderId", String.class) // required
.param("notes", String.class, false) // optional
.param("discount", Integer.class,
function((RuleContext ctx) -> 5)) // default applied when absent
Binding Values
bind(...) adds bindings to the current scope; bindTo(scopeName, ...) targets a named scope. All the familiar loading styles from Bindings are available as steps:
.bind(total -> BigDecimal.ZERO) // BindingDeclaration lambda(s)
.bind("status", "pending") // explicit name + value
.bind(existingBindings) // copy from a Bindings instance
.bind(orderPojo) // JavaBean properties (or Map entries)
.bind(customLoader, sourceValue) // via a BindingLoader
.bindTo("audit", "checkedAt", Instant.now()) // same overloads, into scope "audit"
Running Rules, RuleSets & Nested Flows
run(...) accepts a pre-built Rule, RuleSet, or nested RuleFlow — or a name / class resolved from the RuleRegistry at execution time:
.run(ageRule) // Rule
.run(eligibilityRules) // RuleSet
.run(pricingFlow) // nested RuleFlow
.run("CheckoutRuleSet") // RuleRegistry lookup by name
.run(FraudCheckRule.class) // RuleRegistry lookup by class
Step Configuration with RunSpec
Overloads that take a spec consumer expose per-step configuration — there are no top-level as()/with() methods on the builder itself:
.run(pricingRule, spec -> spec
.as("price") // bind the step result into the current scope
.with(discount -> 0.1) // step-scoped params — exist only during the step
.onException(Exception.class, // step-level exception handler
b -> b.run(fallbackPricingRule)))
as(bindingName)— bind the step's result into the current scope;as(scopeName, bindingName)targets a named scope.with(...)— step-scoped parameters fromBindingDeclarationlambdas or a POJO /Map; removed when the step completes.onException(type, body)— a handler checked before the flow-level handler.
Functions & Actions
// apply() — evaluate a Function and use its result in later steps
.apply(function((BigDecimal price, Integer qty) -> price.multiply(BigDecimal.valueOf(qty))),
spec -> spec.as("total"))
// execute() — a void side effect; ExecuteSpec supports onException() only
.execute(action((BigDecimal total) -> log.info("total = {}", total)))
Control Flow
Container commands take Consumer<builder> bodies — the lambda itself delimits the block, so there is no endWhen() or endScope():
// Branching — otherwise-branch is optional
.when(condition((BigDecimal total) -> total.compareTo(threshold) > 0),
b -> b.run(premiumRules),
b -> b.run(standardRules))
// Iteration — binds each element as "item" and the 0-based position as "index"
.forEach(function((Order order) -> order.getLineItems()), "item",
b -> b.run(perLineItemRule))
// Iteration with an early-stop condition (checked after each element)
.forEach(sourceFn, "item", condition((Boolean done) -> done),
b -> b.run(perItemRule))
// Scoped bindings — discarded when the body ends
.scope("temp", b -> b
.bind(intermediate -> expensiveComputation())
.run(usesIntermediateRule))
Early Exit, Results & the Finalizer
// Early termination — subsequent top-level commands would fail build() as unreachable
.when(alreadyProcessed, b -> b.<String>exit(function(() -> "cached")))
// Result extraction — evaluated when the flow completes naturally
.<BigDecimal>returning(function((BigDecimal total) -> total))
// Always runs — success, early exit, or exception
.finalizer(action(() -> metrics.increment("checkout.flows")))
Use the type witness form .<String>returning(...) so build() infers RuleFlow<String>. The no-arg exit() and returning() forms return the RuleContext.
Exception Handling
Handlers exist at three levels, all with catch-and-continue semantics: a matched handler body runs, then execution resumes at the next step. Inside every handler body the caught exception is bound as "ex":
// 1. Step-level — checked first
.run(riskyRule, spec -> spec.onException(UnrulyException.class,
b -> b.execute(action((Exception ex) -> log.warn("step failed", ex)))))
// 2. Flow-level global — fallback for any step without a matching step handler
.onException(Exception.class, b -> b.bind(handled -> true))
- The step handler is checked first, then the flow-level (global) handler; if neither matches, the exception propagates out of
run(). - Async steps have their own
onExceptiononAsyncRunSpec— see below. - To abort the flow instead of continuing, rethrow from inside the handler body.
context()is forbidden inside handler bodies — it throws at build time.
Async Execution
asyncRun(...) launches a Rule, RuleSet, RuleFlow, or registry lookup on the context's executor service. Bind the resulting CompletableFuture with as(...) and block on it later:
.asyncRun(pricingRuleSet, spec -> spec
.as("pricingFuture")
.withImmutableBindings()) // snapshot — task can't mutate caller's bindings
.asyncRun(inventoryRule, spec -> spec
.as("inventoryFuture")
.thenRun("result", b -> b // non-blocking continuation on completion
.execute(action(() -> log.info("inventory done"))))
.onException(Exception.class, // fires even if never awaited
b -> b.bind(inventoryFailed -> true)))
.awaitAll("pricingFuture", "inventoryFuture") // default timeout 30s
// await does NOT unwrap — the binding stays a CompletableFuture
.apply(function((CompletableFuture<BigDecimal> pricingFuture) ->
pricingFuture.getNow(null)), spec -> spec.as("price"))
| Context Mode | Behavior |
|---|---|
| SHARED (default) | The async task shares the caller's context and bindings — beware concurrent mutation |
IMMUTABLE — withImmutableBindings() |
The task reads an immutable snapshot; it cannot mutate the caller's bindings |
CUSTOM — withContext(ctx) |
The task runs against a completely independent, user-supplied RuleContext |
Important
A timeout raises a TimeoutException wrapped in UnrulyException, but the underlying future is not cancelled. Avoid launching multiple SHARED-mode tasks that write bindings concurrently — the scope stack is shared; serialize them with await, or isolate them with withImmutableBindings().
Customizing the RuleContext
context(...) customizes the RuleContext the flow builds when invoked without one. It must be the first pipeline step and may only be called once. When the caller supplies a context via run(ruleContext), the context(...) settings layer on top of it:
RuleFlow<RuleContext> flow = RuleFlow.builder()
.name("customContextFlow")
.context(ctx -> ctx.matchUsing(MatchByTypeMatchingStrategy.class))
.run(myRule)
.build();
Composition & the RuleRegistry
Flows compose: run(otherFlow) nests one flow inside another, and RuleFlows are registrable in the RuleRegistry alongside Rules and RuleSets:
RuleRegistry registry = RuleRegistry.builder().build();
registry.register(checkoutFlow);
RuleFlow<?> flow = registry.getRuleFlow("CheckoutFlow"); // new in 2.0
List<RuleFlow<?>> flows = registry.getRuleFlows();
RuleFlow execution is fully traced — RuleFlowListener events fire through the Tracer for flow and command start, completion, and errors. See Rule Tracing & Debugging.
Extending RuleFlow
Two extension points cover cases the built-in steps don't:
// 1. Inject a pre-built custom command as a leaf step
.command(new MetricsCommand("checkout"))
// 2. Custom container commands (retry, parallel, circuit-breaker, ...)
public abstract class MyBaseBuilder<SELF extends MyBaseBuilder<SELF>>
extends RuleFlowBuilderTemplate<SELF> {
public SELF retry(int maxAttempts, Consumer<SELF> body) {
return runContainer(new RetryCommand(maxAttempts), body);
}
}
Common Mistakes
| Mistake | Fix |
|---|---|
context() after another step, or called twice |
It must be the first pipeline step, once only |
Expecting await("f") to replace the binding with the value |
The binding stays a CompletableFuture — read it via getNow(null) / join() |
Commands after a top-level exit() |
build() throws — the commands are unreachable |
Looking for endWhen() / endScope() |
Container bodies are Consumer<builder> lambdas — the lambda is the scope |
Calling as() / with() on the builder itself |
Step configuration only exists inside the spec -> consumer |
returning(...) without a type witness |
Use .<String>returning(...) so build() infers RuleFlow<String> |
| Expecting a handled exception to abort the flow | Handlers are catch-and-continue — rethrow inside the handler body to abort |
| Async step mutating shared bindings unexpectedly | Default mode is SHARED — use withImmutableBindings() or withContext(ctx) |
Using Spring? RuleFlows can also be declared entirely in XML — see XML RuleFlows.