Engineering

Rules as Functions

Compose, memoize, and short-circuit your way to a lightweight rules library — no framework required

Learning Objectives

By the end of this module you will be able to:

  • Explain how pure functions enable memoization and why this matters for rule evaluation performance.
  • Implement short-circuit evaluation for rule chains in Java using combinator patterns.
  • Describe what monadic sequencing provides for rule composition and early termination.
  • Design a simple rule combinator library in Java using first-class functions and algebraic laws.
  • Explain what a DSEL is and how to express rule logic as a fluent Java API.
  • Contrast lazy evaluation with eager evaluation for rules that are expensive to compute.

Core Concepts

Rules as Values

In most Java codebases, rules live inside methods that are called imperatively: if (rule1.evaluate(ctx)) { ... }. This works, but it means rules are statements, not values. You cannot easily pass a rule to another function, store it in a list, or build a new rule by combining two existing ones without writing a wrapper class every time.

Functional rule combinator libraries expose rule composition as a first-class API by treating rule combinations as values rather than statements. In Java, a rule becomes a Predicate<Context> (or a richer functional interface you define), and combinators are functions that produce new rules from existing ones. The result is declarative: complex rule logic is built through expression composition, not imperative sequencing.

// A rule is just a function
Predicate<Order> isHighValue = order -> order.total() > 1000;
Predicate<Order> isPremiumCustomer = order -> order.customer().isPremium();

// Composition is just function application
Predicate<Order> priority = isHighValue.and(isPremiumCustomer);

This simplicity scales. sequence, choice, negate, all, any — each combinator is a higher-order function that produces a new rule. Higher-order functions unlock the creation of powerful combinators from simpler building blocks.

Purity and Referential Transparency

A pure function always produces the same output for the same input and has no side effects. For rule evaluation, this means that calling a rule function can safely be replaced with its cached result — a property called referential transparency.

This is not just a stylistic preference. It is the prerequisite for safe memoization. If a rule reads database state, writes a log, or mutates shared context, caching its result is incorrect. If it is pure, caching is always correct.

Purity is the prerequisite

Function purity is a necessary and sufficient condition for safely memoizing rule evaluation results. You cannot partially memoize an impure rule — the entire evaluation must be side-effect-free for caching to be trustworthy.

Pure rule functions also improve testability dramatically. Stateless functions have no external dependencies, so test setup is just input construction. There is no session to initialize, no mock to wire up, no ordering constraint between tests. Each rule is a pure mapping from input to result — verify the input, verify the output, done.

Algebraic Laws

When combinators obey algebraic laws, you can reason about and refactor rule compositions the way you reason about arithmetic. Algebraic laws provide a mathematical basis for safe code optimization: associativity means (a.and(b)).and(c) is equivalent to a.and(b.and(c)), so you can re-bracket freely. An identity law means rule.and(alwaysTrue) simplifies to rule.

Composition laws enable chaining and reordering without semantic change. This is what lets compilers — and humans — refactor rule pipelines safely. You do not need to trace execution; the laws guarantee equivalence.

The theoretical grounding here is combinatory logic: an alternative model of computation equivalent to lambda calculus. Combinators are primitive functions with no free variables, whose behavior is fully determined by application. The key advantage over raw lambda calculus is that evaluation is simpler — there is no substitution and no risk of variable capture — while remaining expressive enough to represent any computable function.

Short-Circuit Evaluation

Short-circuit evaluation avoids evaluating subsequent rules when earlier rules already determine the outcome. In A && B, if A is false, B is never evaluated. This is not just an optimization — it is the default semantic you want in any rule chain where rules have costs.

Monadic bind operations enable composable sequencing with this early-termination semantic. In a monadic framework, mzero represents a failed evaluation and mplus represents alternative paths. Rule pipelines built on bind (>>=) stop as soon as any step produces mzero, propagating failure without evaluating the rest of the chain.

Monadic Sequencing

Monadic sequencing provides an algebraic structure for composing rule evaluation pipelines while preserving referential transparency. Two operations define the structure:

  • return injects a plain value into the monadic context (e.g., Optional.of(result)).
  • bind (>>=) sequences two computations: takes the result of the first and feeds it into the second.

In Java, Optional is a simple approximation of this monad: flatMap is bind, and Optional.of is return. The key property — recognized formally by Hutton and Meijer in their work on monadic parser combinators — is that this structure eliminates nested tuple manipulation and gives you clean sequencing semantics with early termination built in.

Optional<Decision> result = validateOrder(order)        // Optional<Order>
    .flatMap(this::checkInventory)                       // short-circuits if empty
    .flatMap(this::applyPricingRules)                    // short-circuits if empty
    .map(this::toDecision);

If validateOrder returns Optional.empty(), the chain halts. No downstream rules run. No special-case branching needed.

Lazy Evaluation

Lazy evaluation defers computation until results are actually needed, and avoids repeating evaluations by sharing results. In eager evaluation, every rule in a chain is computed upfront. In lazy evaluation, rules are computed only when their results are required.

Java is eagerly evaluated by default, but you can introduce laziness explicitly using Supplier<T>:

// Eager: expensive() runs immediately
Predicate<Order> rule = order -> expensive(order) && cheap(order);

// Lazy: expensive() only runs if needed
Supplier<Boolean> lazyRule = () -> cheap(order) && expensive(order);

Monad transformers can change the evaluation strategy of functional code without changing its structure or types. In practice, this means you can design your combinator library so that individual rules declare their cost, and the combinator scheduler defers expensive rules until cheaper ones have narrowed the candidate set. Purely functional incremental computation takes this further: only rules whose inputs have changed since the last evaluation need to re-run.

Domain-Specific Embedded Languages (DSELs)

A DSEL is a rule language you embed inside your host language (Java) using that language's own constructs — no parser, no compiler, no external tooling. Combinator libraries enable the design of DSELs for rule systems by providing primitives and higher-order combinators that construct and compose values of a domain-specific type.

The pattern has two parts:

  1. Primitives: functions that construct atomic rule values (greaterThan(1000), hasTag("vip")).
  2. Combinators: functions that compose them (and, or, not, sequence, unless).

This approach allows customized rule languages to be provided without implementing separate parsers or compilers. The result reads like a rule language inside Java:

Rule<Order> vipFastTrack = when(isVip()).and(hasMinimumOrderValue(500)).then(fastTrack());

Compiler Optimization Benefits

Pure rule combinators are not just ergonomically better — they are optimization-friendly by construction. Inline expansion replaces function calls with their bodies, eliminating call overhead and enabling further intraprocedural optimizations like constant folding. Small, pure functions are more likely to be inlined because compilers can safely prove no side effects occur across function boundaries.

Specialization generates optimized versions of polymorphic functions for specific types, removing generics overhead at call sites. The combination — purity enabling inlining enabling specialization — means that functional rule combinators written clearly can reach performance levels competitive with hand-written imperative code, without manual optimization.


Key Principles

1. Write rules as pure functions first. Every rule should depend only on its input and return a result with no side effects. This is not a constraint — it is a prerequisite for memoization, safe composition, and reliable testing.

2. Let combinators define the structure of rule logic. Use and, or, not, sequence, choice as your vocabulary. Complex rule trees become compositions of primitives. If you find yourself writing control flow inside a rule, consider whether a combinator expresses the intent more clearly.

3. Short-circuit as a default, not an afterthought. Order cheaper rules first in a chain. Use monadic sequencing so that the chain halts on the first failure. Do not evaluate rules whose results are irrelevant.

4. Defer expensive computation with laziness. Wrap expensive rule checks in Supplier<T> or equivalent lazy constructs. Let the combinator layer decide when evaluation is actually required.

5. Design for algebraic properties. When writing a combinator, ask: is this associative? Does it have an identity? Can the developer safely reorder applications? Documenting these properties — even informally — makes the library safer to extend and refactor.

6. Prefer the host language over an external rule language. A well-designed DSEL in Java gives you IDE support, compile-time type checking, and the full power of Java's standard library. Reserve external rule languages for requirements that genuinely cannot be met from within the host language.


Worked Example

A Minimal Rule Combinator Library in Java

The goal: build a small, composable rule library that supports short-circuit evaluation, memoization, and a fluent API — from scratch, no framework.

Step 1: Define the Rule type

@FunctionalInterface
public interface Rule<T> {
    boolean evaluate(T input);

    default Rule<T> and(Rule<T> other) {
        return input -> this.evaluate(input) && other.evaluate(input);
    }

    default Rule<T> or(Rule<T> other) {
        return input -> this.evaluate(input) || other.evaluate(input);
    }

    default Rule<T> negate() {
        return input -> !this.evaluate(input);
    }
}

&& and || in Java already provide short-circuit evaluation. If this.evaluate(input) is false in and, other.evaluate(input) never runs.

Step 2: Add memoization

public static <T> Rule<T> memoize(Rule<T> rule) {
    Map<T, Boolean> cache = new ConcurrentHashMap<>();
    return input -> cache.computeIfAbsent(input, rule::evaluate);
}

This is safe only because rule is pure. If rule had side effects, caching its result would silently suppress them. Purity is the contract that makes this correct.

Step 3: Monadic sequencing with Optional

For rules that enrich or transform context (not just pass/fail), use Optional as the monadic carrier:

public interface EnrichingRule<T, R> {
    Optional<R> apply(T input);

    default <S> EnrichingRule<T, S> andThen(EnrichingRule<R, S> next) {
        return input -> this.apply(input).flatMap(next::apply);
    }
}

The chain halts as soon as any step returns Optional.empty(). No branching. No checked return codes.

Step 4: Primitives for a fluent DSEL

public class OrderRules {

    public static Rule<Order> minimumValue(double threshold) {
        return order -> order.total() >= threshold;
    }

    public static Rule<Order> hasTag(String tag) {
        return order -> order.tags().contains(tag);
    }

    public static Rule<Order> customerTier(String tier) {
        return order -> order.customer().tier().equals(tier);
    }
}

Step 5: Compose at the call site

import static com.example.OrderRules.*;

Rule<Order> vipFastTrack =
    minimumValue(500)
        .and(hasTag("express"))
        .or(customerTier("platinum"));

Rule<Order> memoizedRule = memoize(vipFastTrack);

boolean qualifies = memoizedRule.evaluate(order);
What you just built

A composable, memoizable, short-circuiting rule library in under 60 lines of Java. No framework. No XML. No annotation processor. The combinator pattern and function purity did all the heavy lifting.

Step 6: Algebraic verification

Before shipping the library, verify the laws hold for your combinators:

// Identity: rule.and(alwaysTrue) == rule
Rule<Order> alwaysTrue = order -> true;
assert rule.and(alwaysTrue).evaluate(sample) == rule.evaluate(sample);

// Associativity: (a.and(b)).and(c) == a.and(b.and(c))
assert (a.and(b)).and(c).evaluate(sample) == a.and(b.and(c)).evaluate(sample);

These are not just unit tests — they are proofs of the algebraic structure your library offers. Documenting which laws hold tells downstream developers what they can safely assume when composing rules.


Active Exercise

Build a lazy rule scheduler.

You have the following three rules for processing loan applications:

  • Rule<Application> creditScoreCheck — fast, in-memory lookup. Cost: low.
  • Rule<Application> fraudSignalCheck — calls an external API. Cost: high.
  • Rule<Application> incomeVerification — calls two external APIs. Cost: very high.

Task:

  1. Wrap fraudSignalCheck and incomeVerification in Supplier<Rule<Application>> so they are lazily initialized.
  2. Write a lazyAnd combinator that accepts a Rule<T> and a Supplier<Rule<T>>, evaluates the first rule eagerly, and only resolves and evaluates the supplier if the first rule passes.
  3. Chain all three rules using lazyAnd so that expensive checks are never made for applications that fail the credit score check.
  4. Write two unit tests: one verifying that fraudSignalCheck is never called when creditScoreCheck fails (use a spy or counter), and one verifying correct evaluation when all rules pass.

Stretch: Add memoization to the chain so that if the same Application instance is evaluated twice, the external APIs are only called once.

Key Takeaways

  1. Purity unlocks memoization. A rule that depends only on its input and has no side effects can always be safely cached. This is the foundational contract of functional rule evaluation.
  2. Combinators make rule composition declarative. and, or, negate, sequence, and choice turn rule wiring into expression construction rather than imperative branching.
  3. Short-circuit evaluation is built into the monadic bind pattern. Chain rules with flatMap (or &&/||) and the chain halts at the first failure — no explicit branching required.
  4. Lazy evaluation defers expensive rules until they are needed. Use Supplier<Rule<T>> to avoid calling external services for inputs that fail cheap checks first.
  5. Algebraic laws make refactoring safe. When your combinators satisfy associativity and identity, you can reorder and re-bracket rule compositions without changing semantics.

Further Exploration

Primary Sources

Practical Guides