In this post, we’ll build a loan application decision engine using Spring Boot 3 and Drools 10.2.0. The engine evaluates eligibility rules, computes risk-based interest rates, applies conditional surcharges, and returns a detailed decision explaining every rule that fired — the kind of audit trail a compliance team actually needs.
Prerequisites
This is the list of all the prerequisites:
- Spring Boot 3
- Maven 3.9+
- Java 21 or later
- Drools 10.2.0
- IntelliJ IDEA, Visual Studio Code, or another IDE
- Postman / insomnia or any other API testing tool.
- Basic familiarity with Spring Boot and Maven
Overview
Business logic is often the most volatile part of an application. Discount tiers change every quarter, loan eligibility rules shift with regulations, and fraud detection thresholds evolve as the business learns from its data. When those rules live inside Java if-else chains buried in service classes, updating them means a code change, a test run, and a deployment — and if the rules are complex enough, a full regression cycle. The cost of flexibility is constant friction.
Drools offers a different model: externalize business rules into declarative .drl files, where a rule engine matches conditions against your domain objects and fires the appropriate actions. Rules become a first-class artifact that business teams can review, QA can test independently, and developers can change without touching application code. The engine handles the complexity of matching conditions efficiently across any number of rules and facts.
What is Drools?
Drools is a Business Rule Management System (BRMS) built on an enhanced implementation of the Rete algorithm. It belongs to the KIE (Knowledge Is Everything) umbrella of projects maintained by the Red Hat and Apache communities.
The central concept: instead of writing procedural if-else logic, you write rules in a declarative format. Each rule declares what conditions must be true (when) and what to do when they are (then). The engine figures out which rules apply, in what order, and handles re-evaluation when facts change.
Rule anatomy:
rule "Minimum Credit Score Check"
salience 80
when
$app : LoanApplication(creditScore < 580)
then
loanDecision.addRejectionReason(
"Credit score of " + $app.getCreditScore() + " is below the minimum of 580");
end

Under the hood — Rete/Phreak:
Drools uses a discrimination network algorithm (Rete, optimized in modern versions as Phreak). When you insert facts into working memory, the engine traverses this network to find which rules match. This is fundamentally more efficient than evaluating every rule against every fact (O(n×m)). The network is built once at startup from the compiled .drl files — the KieContainer build step — and reused for every session.
Stateful vs. Stateless sessions:

For a REST API (one request in, one decision out), a stateful KieSession created and disposed per request is the standard approach. It gives you the full API — globals, event listeners, fine-grained control over firing — without the complexity of BatchExecutionCommand.
What We’re Building
A REST API that accepts a loan application and returns a full decision:
- Approved or rejected with reason list
- Approved amount and interest rate (for approved applications)
- Risk level classification
- Complete list of rules that fired (for audit and debugging)
Architecture:
POST /api/v1/loans/evaluate
│
▼
LoanController
│
▼
LoanDecisionService
│
┌────┴─────────────────────────────────┐
│ KieSession │
│ ┌──────────────────────────────┐ │
│ │ fact: LoanApplication │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ global: LoanDecision │ │ ← output object
│ └──────────────────────────────┘ │
│ │
│ Rules fired in salience order: │
│ ├── Age Eligibility Check (100) │
│ ├── Employment Status Check ( 90) │
│ ├── Min Credit Score Check ( 80) │
│ ├── DTI Ratio Check ( 70) │
│ ├── Loan Amount Limits ( 65) │
│ ├── Interest Rate Rules ( 50) │
│ └── Self-Employed Surcharge ( 10) │
└──────────────────────────────────────┘
│
▼
LoanDecision (populated by rules)
│
▼
LoanDecisionResponse (HTTP 200)
Rule firing strategy: Eligibility rules run first (high salience). They append to rejectionReasons. After all rules fire, the service checks whether rejectionReasons is empty and sets the final approval status. This avoids cascading state changes through modify() and keeps the DRL readable.
Project Setup
We’ll start by creating a simple Spring Boot project from start.spring.io.
pom.xml:
<properties>
<java.version>21</java.version>
<drools.version>10.2.0</drools.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-bom</artifactId>
<version>${drools.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Drools 10 — classic KIE API via classpath container -->
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-core</artifactId>
</dependency>
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-compiler</artifactId>
</dependency>
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-mvel</artifactId>
</dependency>
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-xml-support</artifactId>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
Four Drools dependencies, four distinct roles:

Drools 10 and Spring Boot: There is no official
kie-spring-boot-starterfor Drools 10. Configuration is done manually — which is cleaner and gives you full control without autoconfiguration surprises.
src/main/resources/META-INF/kmodule.xml:
<?xml version="1.0" encoding="UTF-8"?>
<kmodule xmlns="http://www.drools.org/xsd/kmodule">
<kbase name="LoanRules" packages="rules">
<ksession name="loan-session"/>
</kbase>
</kmodule>
kmodule.xml is the module descriptor. It tells Drools:
- Which packages (resource directories) to scan for
.drlfiles - How many knowledge bases (
kbase) to compile - What session names to expose
The packages="rules" attribute maps to src/main/resources/rules/. All .drl files in that directory are compiled into the LoanRules knowledge base at startup. The ksession element without a type attribute defaults to stateful.
Spring Boot Configuration
@Configuration
public class DroolsConfig {
/**
* KieContainer is thread-safe and expensive to build — create once as a bean.
* It reads META-INF/kmodule.xml from the classpath and compiles all .drl files
* at startup. Individual KieSession instances are NOT thread-safe; create one per request.
*/
@Bean
public KieContainer kieContainer() {
return KieServices.Factory.get().getKieClasspathContainer();
}
}
KieServices.Factory.get().getKieClasspathContainer() scans the classpath for META-INF/kmodule.xml, compiles all declared rules, and returns a KieContainer. This is the entire integration with Spring — no starter, no autoconfiguration, no magic.
Performance note: Rule compilation takes 200–800ms depending on rule count. Creating the KieContainer as a @Bean means it happens once at startup, not per request. This is non-negotiable for production.
Domain Model
The domain has two objects: LoanApplication (input fact) and LoanDecision (output populated by rules).
LoanApplication.java:
@Data
@Builder
public class LoanApplication {
private String applicantId;
private String applicantName;
private int age;
private int creditScore;
private double annualIncome;
private double requestedAmount;
private EmploymentType employmentType;
private LoanPurpose loanPurpose;
}
LoanDecision.java:
@Data
@Builder
public class LoanDecision {
private String applicationId;
private boolean approved;
private double approvedAmount;
private double interestRate;
private RiskLevel riskLevel;
// @Builder.Default is required here. Without it, the builder sets these to null,
// and rules calling addRejectionReason() will throw NullPointerException.
@Builder.Default
private List<String> rejectionReasons = new ArrayList<>();
@Builder.Default
private List<String> firedRules = new ArrayList<>();
public void addRejectionReason(String reason) {
this.rejectionReasons.add(reason);
}
public void addFiredRule(String rule) {
this.firedRules.add(rule);
}
}
Lombok gotcha: When you use
@Builder, field initializers in the class body are ignored for builder-created instances.@Builder.Defaultis the correct way to specify default values that survive builder construction. This is a common source ofNullPointerExceptionin Drools applications — the rule fires, calls a method on the global, and crashes because the list was never initialized.
Supporting enums:
public enum EmploymentType { EMPLOYED, SELF_EMPLOYED, UNEMPLOYED }
public enum LoanPurpose { HOME, CAR, PERSONAL, BUSINESS }
public enum RiskLevel { VERY_LOW, LOW, MEDIUM, HIGH }
Writing Business Rules
Create src/main/resources/rules/loan-rules.drl:
package rules;
import com.boottechnologies.drools.domain.LoanApplication;
import com.boottechnologies.drools.domain.LoanDecision;
import com.boottechnologies.drools.domain.EmploymentType;
import com.boottechnologies.drools.domain.LoanPurpose;
import com.boottechnologies.drools.domain.RiskLevel;
global LoanDecision loanDecision;
The global declaration makes loanDecision available to all rules in this file. The name "loanDecision" must exactly match the string passed to session.setGlobal("loanDecision", decision) in the service — a mismatch silently does nothing (no exception, no error).
Eligibility Rules
rule "Age Eligibility Check"
salience 100
when
LoanApplication(age < 18)
then
loanDecision.addRejectionReason("Applicant must be at least 18 years old");
end
rule "Employment Status Check"
salience 90
when
LoanApplication(employmentType == EmploymentType.UNEMPLOYED)
then
loanDecision.addRejectionReason("Unemployed applicants do not qualify for loans");
end
rule "Minimum Credit Score Check"
salience 80
when
$app : LoanApplication(creditScore < 580)
then
loanDecision.addRejectionReason(
"Credit score of " + $app.getCreditScore() + " is below the minimum of 580");
end
rule "Debt-to-Income Ratio Check"
salience 70
when
LoanApplication(requestedAmount > annualIncome * 5)
then
loanDecision.addRejectionReason("Requested amount exceeds 5x annual income (DTI limit exceeded)");
end
rule "Personal Loan Amount Limit"
salience 65
when
$app : LoanApplication(loanPurpose == LoanPurpose.PERSONAL, requestedAmount > 50000)
then
loanDecision.addRejectionReason(
"Personal loans are capped at $50,000. Requested: $" + $app.getRequestedAmount());
end
rule "Home Loan Minimum Income Requirement"
salience 65
when
LoanApplication(loanPurpose == LoanPurpose.HOME, annualIncome < 30000)
then
loanDecision.addRejectionReason("Home loans require a minimum annual income of $30,000");
end
rule "Business Loan Credit Score Requirement"
salience 60
when
LoanApplication(loanPurpose == LoanPurpose.BUSINESS, creditScore < 650)
then
loanDecision.addRejectionReason("Business loans require a minimum credit score of 650");
end
DRL patterns worth noting:
LoanApplication(creditScore < 580) — inline constraint. The engine pattern-matches this directly against facts in working memory without needing an eval() call.
$app : LoanApplication(...) — binding. $app captures the matched fact so you can read its properties in the then block. The $ convention is idiomatic Drools; it separates bindings from field names visually.
LoanApplication(requestedAmount > annualIncome * 5) — arithmetic in constraints. Drools evaluates this directly via MVEL without requiring a Java helper method.
Multiple conditions in one pattern: LoanApplication(loanPurpose == LoanPurpose.PERSONAL, requestedAmount > 50000) — comma-separated constraints within the same pattern are implicitly ANDed.
Interest Rate Rules
rule "Interest Rate - Fair Credit (580-669)"
salience 50
when
LoanApplication(creditScore >= 580, creditScore < 670)
then
loanDecision.setInterestRate(18.99);
loanDecision.setRiskLevel(RiskLevel.HIGH);
end
rule "Interest Rate - Good Credit (670-739)"
salience 50
when
LoanApplication(creditScore >= 670, creditScore < 740)
then
loanDecision.setInterestRate(12.99);
loanDecision.setRiskLevel(RiskLevel.MEDIUM);
end
rule "Interest Rate - Very Good Credit (740-799)"
salience 50
when
LoanApplication(creditScore >= 740, creditScore < 800)
then
loanDecision.setInterestRate(7.99);
loanDecision.setRiskLevel(RiskLevel.LOW);
end
rule "Interest Rate - Exceptional Credit (800+)"
salience 50
when
LoanApplication(creditScore >= 800)
then
loanDecision.setInterestRate(4.99);
loanDecision.setRiskLevel(RiskLevel.VERY_LOW);
end
Credit score ranges are mutually exclusive — exactly one interest rate rule fires per application. Giving them all salience 50 is safe when conditions don’t overlap. The engine fires them after eligibility rules (salience 100–60) and before the surcharge rule (salience 10).
Interest rate rules intentionally fire for all applications, not just approved ones. The mapper omits interestRate and riskLevel from the response when approved == false. This is a deliberate design choice: the rules compute facts, the service/mapper applies business presentation logic.
Adjustment Rule
rule "Self-Employed Risk Surcharge"
salience 10
when
LoanApplication(employmentType == EmploymentType.SELF_EMPLOYED, creditScore >= 580)
then
loanDecision.setInterestRate(loanDecision.getInterestRate() + 1.50);
end
The creditScore >= 580 guard ensures an interest rate rule has fired before this one adjusts the rate. Without it, the surcharge would add 1.50 to the default 0.0 — a silent, incorrect result.
Rule Audit Listener
Drools fires events for every agenda action. AgendaEventListener lets you intercept these. The most useful event for audit trails is afterMatchFired, which fires after each rule’s then block executes.
@Slf4j
@RequiredArgsConstructor
public class RuleAuditAgendaEventListener extends DefaultAgendaEventListener {
private final LoanDecision loanDecision;
@Override
public void afterMatchFired(AfterMatchFiredEvent event) {
String ruleName = event.getMatch().getRule().getName();
log.debug("Rule fired: [{}]", ruleName);
loanDecision.addFiredRule(ruleName);
}
}
Extending DefaultAgendaEventListener avoids implementing 10+ interface methods with empty bodies — override only what you need.
Other events available on AgendaEventListener:
beforeMatchFired— before the RHS executesmatchCreated/matchCancelled— when a rule enters or leaves the agendaafterRuleFlowGroupActivated— for agenda group workflows
The listener is registered per session (not per container), which is correct — each request gets its own session with its own decision object.
Service Layer
@Service
@RequiredArgsConstructor
@Slf4j
public class LoanDecisionService {
private static final String SESSION_NAME = "loan-session";
private final KieContainer kieContainer;
public LoanDecision evaluateLoanApplication(LoanApplication application) {
LoanDecision decision = LoanDecision.builder()
.applicationId(application.getApplicantId())
.build();
KieSession session = kieContainer.newKieSession(SESSION_NAME);
try {
session.setGlobal("loanDecision", decision);
session.addEventListener(new RuleAuditAgendaEventListener(decision));
session.insert(application);
session.fireAllRules();
} finally {
session.dispose();
}
// Approval determined after all rules fire.
// Rules only append rejection reasons; the service sets the final flag.
boolean approved = decision.getRejectionReasons().isEmpty();
decision.setApproved(approved);
if (approved) {
decision.setApprovedAmount(application.getRequestedAmount());
}
log.info("Loan evaluation — applicant: {}, approved: {}, rules fired: {}, rate: {}%",
application.getApplicantId(),
approved,
decision.getFiredRules().size(),
approved ? decision.getInterestRate() : "N/A");
return decision;
}
}
Session lifecycle matters: kieContainer.newKieSession() allocates working memory for this session. session.dispose() releases it. Without dispose(), you leak memory proportional to the number of inserted facts and retained activations. The try/finally block ensures disposal even when rules throw exceptions.
Global vs. inserted fact: LoanDecision is set as a global rather than inserted as a fact. This means rules can read and write it, but Drools won’t pattern-match against it. If you need rules to react to changes in LoanDecision state (e.g., “if approved is true, then fire rate discount rule”), insert it as a fact and use modify() instead. For this use case — rules write output, service reads it — the global pattern is cleaner and avoids modify-triggered re-evaluation cascades.
Request/Response Layer
LoanApplicationRequest.java (Java record with Bean Validation):
public record LoanApplicationRequest(
@NotBlank(message = "Applicant ID is required")
String applicantId,
@NotBlank(message = "Applicant name is required")
String applicantName,
@Min(0) @Max(120)
int age,
@Min(300) @Max(850)
int creditScore,
@Positive double annualIncome,
@Positive double requestedAmount,
@NotNull EmploymentType employmentType,
@NotNull LoanPurpose loanPurpose
) {}
LoanDecisionResponse.java:
public record LoanDecisionResponse(
String applicationId,
boolean approved,
List<String> rejectionReasons,
Double approvedAmount, // null when rejected
Double interestRate, // null when rejected
RiskLevel riskLevel, // null when rejected
List<String> firedRules
) {}
Controller:
@RestController
@RequestMapping("/api/v1/loans")
@RequiredArgsConstructor
public class LoanController {
private final LoanDecisionService loanDecisionService;
private final LoanMapper loanMapper;
@PostMapping("/evaluate")
public ResponseEntity<LoanDecisionResponse> evaluateLoan(
@Valid @RequestBody LoanApplicationRequest request) {
var decision = loanDecisionService.evaluateLoanApplication(
loanMapper.toEntity(request));
return ResponseEntity.ok(loanMapper.toResponse(decision));
}
}
Testing
# Run the app
mvn spring-boot:run
Approved application

INFO 27192 --- [spring-boot-drools-demo] [nio-8080-exec-2] c.b.drools.controller.LoanController : Loan evaluation request received for applicant: APP-001
DEBUG 27192 --- [spring-boot-drools-demo] [nio-8080-exec-2] c.b.d.l.RuleAuditAgendaEventListener : Rule fired: [Interest Rate - Very Good Credit (740-799)]
INFO 27192 --- [spring-boot-drools-demo] [nio-8080-exec-2] c.b.drools.service.LoanDecisionService : Loan evaluation — applicant: APP-001, approved: true, rules fired: 1, rate: 7.99%
Rejected application — multiple reasons

INFO 27192 --- [spring-boot-drools-demo] [nio-8080-exec-4] c.b.drools.controller.LoanController : Loan evaluation request received for applicant: APP-002
DEBUG 27192 --- [spring-boot-drools-demo] [nio-8080-exec-4] c.b.d.l.RuleAuditAgendaEventListener : Rule fired: [Employment Status Check]
DEBUG 27192 --- [spring-boot-drools-demo] [nio-8080-exec-4] c.b.d.l.RuleAuditAgendaEventListener : Rule fired: [Minimum Credit Score Check]
INFO 27192 --- [spring-boot-drools-demo] [nio-8080-exec-4] c.b.drools.service.LoanDecisionService : Loan evaluation — applicant: APP-002, approved: false, rules fired: 2, rate: N/A%
Performance Considerations
KieContainer build time: Rule compilation is the expensive step, typically 200–800ms for a small rule set. It happens once at startup via the @Bean. Never call KieServices.Factory.get().getKieClasspathContainer() inside a request path.
Session creation cost: kieContainer.newKieSession() creates a session with an empty working memory. It’s lightweight (microseconds) — safe to call per request.
Fact count and Rete network size: The Rete/Phreak algorithm’s efficiency grows with fact count. For single-fact evaluations (one loan application per session), the overhead of the engine is not meaningful compared to network I/O. Where Drools genuinely outperforms if-else code is when you have hundreds of rules and thousands of facts in a single session.
Disposing sessions: Every session that isn’t disposed retains its working memory. Under load, failing to call dispose() leaks memory proportional to session count × fact count. Always use try/finally.
Rule evaluation with globals vs. facts: Globals are not in working memory — the engine can’t pattern-match against them or track their changes. If your rules need to react to state changes in the output object (e.g., “if approved is set to true, apply a discount”), insert the output as a fact and use modify(). For simpler collection patterns (rules accumulate into an output object), globals are appropriate and avoid modify-triggered re-evaluation.
Conclusion
🏁 Well done !!. In this post, we created a loan application decision engine using Spring Boot 3 and Drools 10.2.0, defined business rules in a .drl file, executed them through a KieSession, and exposed the functionality through a REST API.
As your applications grow, a rules engine can significantly reduce complexity and help keep business logic organized, reusable, and easy to evolve.
The complete source code is available on GitHub.
Support me through GitHub Sponsors.
Thank you for reading!! See you in the next post.