02 — Getting Started
Overview
rulii is a Business Rule Engine written in Java. Its goal is to allow defining, managing, and executing business rules (decisions/logic) in a flexible way, outside the hard-coded paths of the application logic. This makes the system more adaptable to changing business requirements, easier to test, and more maintainable.
rulii provides a flexible and extensible framework for defining and executing business rules, allowing developers to separate business logic from application code. This separation makes it easier to manage and update rules as business requirements change, without needing to modify the core application logic.
Key Concepts
| Concept | Purpose / Description |
|---|---|
| Rule | A single business rule: usually has a condition (when) and an action (then). The condition might check some facts/state, the action changes or returns something. |
| RuleSet | A group or collection of rules, executed together and in certain order. |
| Bindings | The data passed in to the engine to be evaluated against the rules. Often immutable, sometimes enriched by rule actions. |
| Engine / Executor | The component that takes the rules and the context and runs them: evaluating conditions, executing actions, handling ordering, perhaps stopping early, collecting results, etc. |
Tips
1. Always use the Builder when creating objects
Most rulii interfaces provide a static builder() method that you can use instead of creating objects directly.
Rule rule = Rule.builder()...build();
RuleSet ruleSet = RuleSet.builder()...build();
Binding binding = Binding.builder()...build();
This approach makes your code easier to read, safer, and less error-prone.
2. Keep the Rule(s) stateless
Rules are designed to be reused across many executions, and sometimes they may even run in parallel. To avoid unexpected behavior, make sure your Rules don't hold or modify internal state. If you do need to use state, pass it in through Bindings. Bindings map values from the execution context into method parameters. That way, any required state is provided externally and not stored inside the Rule.
3. Be concise with the Rules
Rules should stay small and focused. If you pass in a large "God object" (an object that holds too many unrelated properties), it becomes unclear which pieces of data the Rule actually depends on.
This has two downsides:
- Hard to understand — You can't quickly see what inputs matter for the Rule.
- Hard to test — To unit test the Rule, you'd have to fully populate the God object, even if the Rule only cares about one or two fields.
Good (concise Bindings):
Rule isAdult = Rule.builder()
.name("isAdultRule")
.given(condition(Integer age) -> age >= 18)
.then(action(Boolean eligible) -> eligible = true))
.build();
Bad (God object):
Rule isAdult = Rule.builder()
.name("isAdultRule")
.given(condition(Person person) -> person.getAge() >= 18)
.then(action(Person person) -> person.setEligible(true))
.build();
4. Declarative vs. Functional Rules
- Declarative (Class-based) — Write a dedicated class for each Rule.
- Functional (Lambda-based) — Define Rules inline with lambdas.
Both approaches are valid, but they have different trade-offs.
Declarative (Class-based)
public class IsAdultRule implements Rule {
@Given
public boolean given(Integer age) {
return age >= 18;
}
@Then
public void then(Boolean eligible) {
eligible = true;
}
}
Advantages
- Clear separation — each Rule lives in its own file.
- Easy to attach metadata (name, description, priority).
- Very explicit — good for large teams or projects where clarity matters.
- Easier to reuse — ideal for systems with a lot of business rules.
- Easier to unit test in isolation.
Disadvantages
- More boilerplate code — need to create a new class for each Rule.
- Slightly harder to see the overall flow when many Rules are involved.
Functional (Lambda-based)
Rule isAdult = Rule.builder()
.name("isAdultRule")
.given(condition(Integer age) -> age >= 18)
.then(action(Boolean eligible) -> eligible = true))
.build();
Advantages
- Concise — define Rules inline without extra files.
- Easier to see the overall flow when many Rules are involved.
- Great for small projects or teams where speed matters.
- Ideal for simple Validation rules.
Disadvantages
- Can get messy if Rules become complex.
- Harder to attach metadata (name, description, etc).
- Certain annotations are not supported. Example: @Binding
- Slightly harder to unit test in isolation.
When to use which?
Use Declarative (Class-based) Rules when:
- Rules are complex and need clear structure.
- Working in a large team where clarity, reusability and maintainability are priorities.
- Rules need rich metadata or documentation.
Use Functional (Lambda-based) Rules when:
- Rules are simple and straightforward.
- Working in a small team or solo where speed and agility are priorities.
- Rules are unlikely to change often.
In many cases, a hybrid approach works well: use Declarative Rules for complex logic and Functional Rules for simple validations. This way, you get the best of both worlds.
5. Don't Make Assumptions About Rule Parameters
Rules should be reusable. If you assume things like parameters are always non-null (or always within a certain range), you limit where and how the Rule can be used. Instead of hard-coding assumptions inside the Rule, declare them up front using preCondition. A preCondition acts like a guard: the Rule won't even run unless those conditions are met.
Good (using preCondition):
Rule isAdult = Rule.builder()
.name("isAdultRule")
// Check: only run if age is there
.preCondition(condition((Integer age) -> age != null))
.given(condition(Integer age) -> age >= 18)
.then(action(Boolean eligible) -> eligible = true))
.build();
Bad (assuming parameters):
Rule isAdult = Rule.builder()
.name("isAdultRule")
// BAD: assumes age is never null
.given(condition(Integer age) -> age >= 18)
.then(action(Boolean eligible) -> eligible = true))
.build();
If age is missing or null, this Rule will throw an error and become less reusable.
Why this matters:
- More reusable: Rules can be shared across different contexts.
- Safer: No unexpected null pointer errors.
- Clearer: Preconditions document exactly what inputs the Rule expects.
6. Store method parameter names
Important
rulii uses reflection to figure out which method parameters match your bindings. Most of the time this works fine, but it's not perfect. To avoid issues, we recommend turning on parameter name storage in your compiler settings.
For example, if you're using Javac, you can enable parameter name storage by adding the -parameters flag when you compile:
javac -parameters MyClass.java
If you're using Maven, add this to your pom.xml:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
If you're using Gradle, add this to your build.gradle:
tasks.withType(JavaCompile).configureEach {
options.compilerArgs.add('-parameters')
}