Somewhere in your system there is a method like isEligible(application) — a page of
nested conditionals that decides who gets the loan, the discount, the upgrade, the refund.
It has three properties: nobody can state the full policy from reading it, every change
requires re-understanding all of it, and when it says false, nothing can say why.
The industry’s reflex answer is “get a rules engine,” which usually means a product with its own authoring language and its own operational surface. This post takes the other route, the one the loose gears in the photo suggest: machine each rule as a small, identically shaped part, and let the engine be nothing but composition. Plain Java, a handful of lines, and by the end the thing most vendor pitches lead with — the rejection that explains itself — falls out of the design.
Start where the JDK already is
java.util.function.Predicate is a composable rule language hiding in plain sight —
and, or, negate come with it:
record Application(int age, BigDecimal income, int recentDefaults) {}
static final BigDecimal MIN_INCOME = new BigDecimal("20000");
static final Predicate<Application> ADULT = app -> app.age() >= 18;static final Predicate<Application> SOLVENT = app -> app.income().compareTo(MIN_INCOME) >= 0;static final Predicate<Application> CLEAN_HISTORY = app -> app.recentDefaults() == 0;
Predicate<Application> eligible = ADULT.and(SOLVENT).and(CLEAN_HISTORY);This is already better than the conditional pile: each check has a name, each is testable in isolation, and the policy reads as a sentence. For a guard clause deep inside your own code, stop here — composition without ceremony.
But run it at a real decision boundary and the limitation surfaces immediately:
eligible.test(application) returns false, and false is a dead end. The support agent
asking “why was this customer rejected?”, the regulator asking the same with a deadline,
the log line you will grep at 2am — all of them need which rules failed, and a composed
Predicate cannot tell you. It short-circuits, forgets, and answers in one bit.
The whole engine is a dozen lines
Upgrade the atom: a rule is a predicate plus its name. An evaluation is the subject plus every rule it failed. That is the entire design.
record Rule<T>(String name, Predicate<T> passes) {}
record Evaluation<T>(T subject, List<String> failures) { Evaluation { failures = List.copyOf(failures); } // defensive: keep passed() stable boolean passed() { return failures.isEmpty(); }}
static <T> Evaluation<T> evaluate(List<Rule<T>> rules, T subject) { List<String> failures = rules.stream() .filter(rule -> !rule.passes().test(subject)) .map(Rule::name) .toList(); return new Evaluation<>(subject, failures);}Note what evaluate does not do: short-circuit. It runs every rule and keeps every
failure, because at a decision boundary the complete answer — “underage and income below
minimum” — is the difference between one support interaction and three. This is the same
insight that accumulating validation is
built on, applied to policy instead of input shape.
And because rules are values in a List, the policy is inspectable: the checklist of
rule names can be logged straight off the list, and the policy reads — and diffs in code
review — as declarative source instead of control flow. (Inspectability stops at each
rule’s name: the predicate inside is opaque at runtime, so anything richer — rendering
the full structure for documentation — works from the source that builds the rules, not
from the objects.) Adding a rule is appending an entry with its own unit tests — the
property that made
case 5 of the real-world post a
schedule-visible win.
Combinators: the gears mesh
AND-of-a-list is what evaluate gives you. Real policies need two more shapes, and
because a composite rule is just another Rule, they nest arbitrarily:
static <T> Rule<T> anyOf(String name, List<Rule<T>> alternatives) { List<Rule<T>> options = List.copyOf(alternatives); // snapshot: later list edits change nothing return new Rule<>(name, subject -> options.stream().anyMatch(rule -> rule.passes().test(subject)));}
static <T> Rule<T> atLeast(String name, int n, List<Rule<T>> rules) { List<Rule<T>> checks = List.copyOf(rules); return new Rule<>(name, subject -> checks.stream().filter(rule -> rule.passes().test(subject)).count() >= n);}anyOf models alternative qualification paths (“salaried income or two years of
invoices”). atLeast models scorecards (“any three of these five signals”) — a shape that
turns into remarkably tangled boolean logic when written by hand, and stays one readable
line here. (One asymmetry to know: anyOf stops at the first passing alternative —
anyMatch short-circuits — while atLeast as written evaluates every rule; if the rules
are expensive, .filter(...).limit(n).count() == n is the short-circuiting form.) The
tree of composites is your policy’s actual structure, written down in source instead
of implied by nesting depth — the same shift as
replacing branch sprawl with data.
Four honest caveats before you ship these combinators, because sharp edges in a rules
engine reject real people. First, a failing composite reports only its own name — which
inner alternative was the near-miss is discarded, the same one-bit answer this post held
against plain Predicate; the remedy within this design is to name composites by what
the user must fix (“proof of income”). Second, anyOf of an empty list always
fails (anyMatch on nothing is false) — deadly when alternatives are filtered
dynamically by product or region. Third, atLeast accepts thresholds that make it vacuous
(n <= 0 passes everyone) or unsatisfiable (n greater than the rule count rejects
everyone), both silently; validate n where the threshold is computed or configured.
Fourth, a rule that throws — SOLVENT on an application whose income is null, say —
aborts the entire evaluation from inside the stream, producing no Evaluation at all: a
stack trace where the caller was promised a list of reasons, strictly worse than the
one-bit false. Either establish non-null inputs at the boundary before rules run, or
fold the exception into a failure the way Guard.ofCatching does below.
Two disciplines keep the engine honest at a higher level. Rules should be pure — a
rule that queries a repository mid-evaluation reintroduces ordering, latency, and partial
failure into what should be a calculation, so fetch first, decide second, keeping the
decision in the pure core and the fetching in the shell.
And rule evaluation must be separate from rule consequence — the engine reports which
rules failed; what to do about it (reject, price up, route to manual review) is the
caller’s switch, not the rule’s side effect.
The same shape, already in the library
If the dozen-line engine looks familiar to readers of this blog, it should: a named check
that accumulates failures is exactly what dmx-fun’s
Guard is, with the outcome made explicit in the type — either
the validated value, or a provably non-empty list of failure messages, rather than a bare
list you must remember to check. The atoms carry their message; allOf runs every guard
and accumulates:
Guard<Application> adult = Guard.of(app -> app.age() >= 18, "applicant must be an adult");Guard<Application> solvent = Guard.of( app -> app.income().compareTo(MIN_INCOME) >= 0, app -> "income below minimum: " + app.income());Guard<Application> cleanHistory = Guard.of(app -> app.recentDefaults() == 0, "recent defaults on file");
Guard<Application> eligibility = Guard.allOf(adult, solvent, cleanHistory);
Validated<NonEmptyList<String>, Application> outcome = eligibility.check(application);The result is a Validated: either the
application, or a non-empty list of every reason it failed. Consequences live in one
exhaustive switch at the boundary:
String response = switch (outcome) { case Validated.Valid<NonEmptyList<String>, Application> ok -> approve(ok.value()); // String approve(Application) case Validated.Invalid<NonEmptyList<String>, Application> rejected -> "rejected: " + String.join("; ", rejected.error().toList());};Guard also ships the engine niceties you would otherwise hand-roll next sprint —
including fixes for the caveats above. Its anyOf, when every alternative fails,
accumulates the messages from all of them (the near-miss stays visible), and its
mandatory first argument makes the empty composition unrepresentable. For throwing
predicates there is Guard.ofCatching, which folds a thrown RuntimeException into the
guard’s failure message instead of letting it abort the evaluation. Beyond that: or
and negate(message) on individual guards, withMessage to rename a composite, and
contramap with a field label to aim a small reusable guard at part of a bigger object —
given a guard of your own like notBlank = Guard.of(s -> !s.isBlank(), "must not be blank"),
the composition notBlank.contramap(User::email, "email") reports failures as
email: must not be blank. Small gears, machined once, meshing across every policy in
the codebase.
When you actually need the big engine
The honest boundary, so the title’s “small” stays truthful. Reach for a real rules product (Drools, a DMN engine) when the organizational requirements appear: business analysts authoring rules without deployments, rule changes on a different release cadence than code, thousands of interdependent rules where inference actually pays, an audit UI for non-engineers. Those are real needs — and they are the product’s price of admission, paid in authoring tooling, runtime surface, and a second language nobody on the team reviews fluently. Martin Fowler’s classic note on rules engines makes the same point from the consulting trenches: much of what reaches for the product needed the list of predicates.
Below that threshold — which is most eligibility, routing, and pricing logic in most services — the composable version wins on every axis stakeholders can see: rules live in git and are reviewed like code, each rule is a pure function with a unit test, the policy is data you can print, and every rejection arrives with its reasons attached. The engine was never the interesting part. The gears were.
Further reading
- Guard — the library guide — the full API this post’s engine is a distillation of: variance, catching guards, message mapping, field-context contramap.
- Functional Design of Business Rules — rules as composable functions at production scale, including where database-backed checks fit.
- Should All Business Logic Be Pure? — the fetch-first, decide-second boundary this post’s purity discipline leans on.
- Validated: Accumulating Errors in a Functional Way
— the error channel underneath
Guard.check. - Replacing if/else and switch Sprawl with Maps of Functions — the sibling move: dispatch as data, as rules are here.
- RulesEngine — Martin Fowler — the build-vs-buy caution, from the source.
- Real-World Cases Where Functional Programming Actually Adds Value — case 5 is this post’s pattern, measured by its schedule impact.
Found a bug or have a suggestion? Open an issue on GitHub.
