A Java library for expressing business rules and policies as composable objects, including rules whose shape is decided by configuration rather than by code.
- No dependencies. Only the Java 25 standard library.
- No infrastructure. It does not read from a database, write logs, or depend on a framework. That is left to the project using it.
- Immutable and thread-safe. Everything the library returns is built once and can be shared.
- The problem
- Installation
- One-minute example
- The four concepts
- Configurable rules
- API map
- What it leaves out, and why
- Documentation
- Build and publish
Business conditions tend to end up tangled inside service methods:
public void confirm(Booking booking) {
if (!booking.active()) {
throw new IllegalStateException("The booking is not active");
}
if (booking.customer().age() < 18) {
throw new IllegalStateException("Underage customer");
}
if (booking.weeklyBookings() >= 3) {
throw new IllegalStateException("Weekly limit reached");
}
// ...
}That has three concrete problems:
- The condition and its meaning are welded together. You cannot reuse
age() >= 18in another use case without dragging along its message and its exception. - Only the first failure is reported. A form or an API usually needs every reason at once.
- Changing a threshold means a deployment. The
18and the3are business data, yet they live in the code.
policy-rules separates those three things: the condition (Rule), its meaning
(Policy → PolicyResult) and its origin (code or configuration).
Published on Maven Central: no token and no extra repository needed.
<dependency>
<groupId>io.github.sebas2409</groupId>
<artifactId>policy-rules</artifactId>
<version>1.1.0</version>
</dependency>Gradle:
implementation("io.github.sebas2409:policy-rules:1.1.0")Requires Java 25. The library declares the automatic module name
io.github.sebas2409.policyrules, so it works on the classpath and on the module
path alike.
import io.github.sebas2409.policyrules.policy.*;
import java.util.List;
import java.util.Map;
record Booking(String customer, int age, int weeklyBookings, boolean active) {}
Policy<Booking> canBeConfirmed = Policies.allOf("booking-can-be-confirmed", List.of(
Policies.require("booking-must-be-active",
Booking::active,
"BOOKING_INACTIVE", "The booking must be active"),
Policies.require("customer-must-be-adult",
booking -> booking.age() >= 18,
"CUSTOMER_UNDERAGE", "The customer must be of age"),
Policies.require("weekly-limit",
booking -> booking.weeklyBookings() < 3,
booking -> new PolicyViolation(
"WEEKLY_LIMIT_REACHED",
"Weekly booking limit reached",
Map.of("current", booking.weeklyBookings(), "maximum", 3)))
));
PolicyResult result = canBeConfirmed.evaluate(booking);
if (result.allowed()) {
bookings.confirm(booking);
} else {
return unprocessableEntity(result.violations()); // every reason
}And at the boundary of the application, where the operation cannot continue:
canBeConfirmed.enforce(booking); // throws PolicyViolationException when denied| Type | Answers | Holds |
|---|---|---|
Rule<T> |
does the condition hold? | a boolean, nothing else |
Policy<T> |
may this happen? | a condition plus the reason for denying it |
PolicyResult |
what happened? | the list of reasons (empty means allowed) |
PolicyViolation |
why not? | a stable code, a message and metadata |
Two design decisions worth understanding before using it:
A denial is a value, not an exception. Denying is an expected business
outcome, so evaluate returns a PolicyResult carrying every reason. Only the
boundary of the application turns it into an exception, through enforce or
requireAllowed.
State is derived, not stored. PolicyResult.allowed() is
violations().isEmpty(), which makes the contradictory states "denied without a
reason" and "allowed with violations" impossible to build.
Full detail in docs/policies.md.
When a threshold changes more often than the software, the condition can live outside the code. The application declares which rule types it accepts, and configuration can only combine and parameterize those:
RuleRegistry<Booking> registry = new RuleRegistry<Booking>()
.register("minimum-age", parameters -> {
int minimum = parameters.intValue("minimum");
return booking -> booking.age() >= minimum;
})
.register("country-in", parameters -> {
var allowed = Set.copyOf(parameters.list("countries", String.class));
return booking -> allowed.contains(booking.country());
});
RuleCompiler<Booking> compiler = new RuleCompiler<>(registry);The stored document — loaded by whoever loads it — has this shape:
{
"operator": "and",
"rules": [
{ "type": "minimum-age", "parameters": { "minimum": 18 } },
{ "type": "country-in", "parameters": { "countries": ["ES", "PT"] } }
]
}And it compiles into an ordinary rule:
Map<String, Object> document = ruleStore.load("booking-eligibility");
Rule<Booking> eligible = compiler.compile(RuleDefinitionCodec.read(document));
Policy<Booking> eligibility = Policies.require(
"booking-eligibility", eligible,
"NOT_ELIGIBLE", "The booking does not meet the eligibility rule");A compiled rule is indistinguishable from a hand-written one: it composes with
and/or/not and any policy can use it.
Three guarantees make this safe:
- Configuration introduces no behavior. It can only use the registered types;
anything else is rejected with
UnknownRuleTypeException. - Errors surface at compile time, not at decision time. A missing parameter or
an unknown type fails in
compile(...). A compiled rule can no longer fail because of configuration. - The library does not know where the document came from. MongoDB,
PostgreSQL, a JSON file or a remote service: all of them hand over a
Map<String, Object>.
Full guide in docs/dynamic-rules.md, format spec in docs/rule-definition-format.md.
io.github.sebas2409.policyrules.policy Business decisions
Policy<T> id() + evaluate(ctx) + enforce(ctx)
Policies require, forbid, allOf, firstFailureOf, adapt, allow, deny
requireExplained, forbidExplained
PolicyResult allowed/denied, violations, codes, combine, requireAllowed
PolicyViolation code + message + metadata
PolicyViolationException what the application boundary throws
io.github.sebas2409.policyrules.rule Conditions
Rule<T> matches + and/or/not + adapt + asPredicate
Rules alwaysTrue, alwaysFalse, of, not, allOf, anyOf
io.github.sebas2409.policyrules.rule.definition Rules from configuration
RuleDefinition sealed model: atomic, and, or, not
RuleDefinitions factories plus typesOf/sizeOf for validation
RuleDefinitionCodec Map <-> RuleDefinition
RuleRegistry<T> catalog of accepted types
RuleFactory<T> builds a rule from its parameters
RuleParameters type-safe parameter reading
RuleConfigurationException + UnknownRuleType / RuleParameter / RuleDefinitionFormat
io.github.sebas2409.policyrules.rule.trace Explaining a decision
ExplainableRule<T> a Rule that also answers explain(ctx)
ExplainableRules of, and, or, not
RuleTrace label + outcome + parameters + children, culprit, format
The library ships no observability, persistence or caching. That is not an omission: those are precisely the three things every project already solves its own way, and bundling them here would force the library's choice on everyone.
Since Policy and Rule are small interfaces, adding them in your project is a
decorator of a few lines. docs/integration.md has the
ready-to-copy patterns:
- decorating a policy with metrics, logs or traces,
- caching compiled rules and invalidating them when configuration changes,
- loading definitions from MongoDB, JPA or a JSON file,
- registering policies as Spring beans,
- validating stored configuration before it reaches production.
| Document | Content |
|---|---|
| docs/getting-started.md | From nothing to a composed policy, step by step |
| docs/policies.md | Policies, results, violations and composition |
| docs/dynamic-rules.md | Registry, factories, parameters and compilation |
| docs/rule-definition-format.md | Specification of the configuration document |
| docs/tracing.md | Explaining which node of a rule decided an evaluation |
| docs/integration.md | Observability, persistence, caching, Spring and tests |
| docs/design.md | Design decisions and the alternatives that were discarded |
| docs/publishing.md | Release flow, workflows and how to consume the artifact |
The Javadoc is the detailed per-type reference; it is generated with
mvn -Prelease javadoc:javadoc into target/site/apidocs.
mvn verify # compiles and runs the tests
mvn -Prelease install # also builds the sources and Javadoc jarsThe release profile attaches -sources.jar and -javadoc.jar. Javadoc is
generated with strict doclint: incomplete documentation breaks the build.
Publishing is automatic and the version in pom.xml drives it: merging a
<version> change into main makes the Release workflow sign and publish the
artifact to Maven Central, create the vX.Y.Z tag and open the release with the
jars attached. A -SNAPSHOT version, or one whose tag already exists, publishes
nothing.
# bump <version> in pom.xml (e.g. 1.1.0) and:
git commit -am "Version 1.1.0" && git push origin mainFull detail in docs/publishing.md.
MIT. See LICENSE.