Integration: Spring Boot (JVM)
This guide wires io.github.goplasmatic:datalogic into a
Spring Boot service: the engine as a singleton bean, rules compiled
once and cached, evaluation per request. The binding uses the Java 22+
Foreign Function & Memory API (no JNI/JNA), and every native-handle
type (Engine, Rule, Session, TracedSession, DataHandle) is
AutoCloseable.
The running example: eligibility rules stored as JSONLogic in a database column, changeable by ops without a deployment.
Dependencies
<dependency>
<groupId>io.github.goplasmatic</groupId>
<artifactId>datalogic</artifactId>
<version>5.9.0</version>
</dependency>
The jar bundles the native library for each platform. JDK 22+ is required; run with native access enabled:
--enable-native-access=ALL-UNNAMED
Put the flag in your launch script or JAVA_TOOL_OPTIONS; Spring Boot
needs no other setting. On first start the binding extracts the native
library to ~/.cache/datalogic/native/ (Linux) and reuses it on later
starts; in a container with a read-only home directory it extracts to a
temp directory instead.
The engine as a bean
Engine and compiled Rule objects are thread-safe: build once,
share across the whole application. Sessions are the per-thread tier;
you don’t need them until you have a measured hot loop.
@Configuration
public class DatalogicConfig {
@Bean(destroyMethod = "close")
public Engine datalogicEngine() {
return new Engine();
}
}
A rule cache keyed by version
Compilation is the expensive step (microseconds; pay it once per rule version, not per request):
@Service
public class RuleService {
private final Engine engine;
private final ConcurrentHashMap<String, Rule> cache = new ConcurrentHashMap<>();
public RuleService(Engine engine) {
this.engine = engine;
}
/** row.id() + row.version() identify one immutable rule body. */
public Rule compiled(RuleRow row) {
return cache.computeIfAbsent(
row.id() + "@" + row.version(),
key -> engine.compile(row.logic()));
}
}
Request threads can share compiled rules. If rule churn is
high, evict old versions (Caffeine or a bounded LinkedHashMap). Closing
a rule while a request thread still evaluates it is not supported, so
either close() an evicted rule once no request can hold it, or drop
the reference and let the binding’s Cleaner free the native handle
when the rule becomes unreachable.
The endpoint
@RestController
public class EligibilityController {
private final RuleService rules;
private final RuleRepository repo;
EligibilityController(RuleService rules, RuleRepository repo) {
this.rules = rules;
this.repo = repo;
}
@PostMapping("/eligibility")
public String check(@RequestBody String applicantJson) {
Rule rule = rules.compiled(repo.activeEligibilityRule());
return rule.evaluate(applicantJson); // JSON in, JSON out
}
}
The JVM surface is JSON-string in/out, which fits Spring endpoints
that already hold the request body as JSON. If you’re
mapping through Jackson anyway, serialize once and reuse: for payloads
evaluated repeatedly, parse once into a DataHandle (immutable,
thread-safe) and use the handle-based evaluations to skip the per-call
parse.
Validating rules at ingestion
Treat rule ingestion as untrusted input: bound the size, compile with
compileChecked, and run golden tests before activating.
compileChecked refuses a rule the engine can see will fail (an
unknown operator, with a suggestion one edit away; an argument count
the operator rejects; a timezone that does not exist) and lists every
problem at once, each with a JSON Pointer into the rule that an admin
UI can highlight.
@PostMapping("/rules")
public ResponseEntity<?> saveRule(@RequestBody @Size(max = 65_536) String logic) {
try (Rule candidate = engine.compileChecked(logic)) {
for (GoldenCase c : goldenCases) {
if (!candidate.evaluate(c.input()).equals(c.expected())) {
return ResponseEntity.unprocessableEntity()
.body("rule fails golden case: " + c.name());
}
}
} catch (DatalogicException e) {
// compileChecked: error type "CompileError", diagnostics as a JSON array
String body = e.diagnosticsJson() != null ? e.diagnosticsJson() : e.getMessage();
return ResponseEntity.unprocessableEntity().body(body);
}
// persist with a bumped version...
return ResponseEntity.noContent().build();
}
Evaluation itself is sandboxed (rules have no I/O and can only read
the data document you pass), so ingestion bounds (size limits, golden
tests) are where your review effort belongs. Two more checks fit here:
candidate.facts() lists the data paths a rule reads, so you can
refuse one that reads fields outside the applicant schema, and an
ops_budget in the engine’s config
(Engine.builder().setConfigJson("{\"ops_budget\": 100000}")) caps the
work one evaluation may do, failing with error type BudgetExceeded.
Hot paths: sessions and batch
For a measured hot loop (scoring a stream, evaluating a rule set per
message), open a Session per worker thread (it reuses one native
arena across calls) and use the typed (evaluateBool, evaluateLong,
evaluateDouble, evaluateTruthy) and batch (evaluateBatch,
evaluateMany) entry points to skip JSON result parsing. Patterns and
the full tier table are in the
JVM README;
the JVM chapter covers the basics.
One more thing polyglot teams get for free
The rule your Spring service enforces is the same rule (same bytes, same semantics, same conformance battery) that your React admin UI can render and step-debug with the visual editor, and that a Node or Python service can evaluate with its own binding, so behavior does not drift between them.
See Also
- Java / JVM (
io.github.goplasmatic:datalogic) — full JVM API reference and installation guide - Starter Boilerplates — runnable project templates for every language
- Sandboxing Untrusted Rules — security checklist for user-authored rules
- Rule Analysis —
check,compileChecked, andfactsin detail - Integration: Express (Node.js) — the same architecture in a Node.js/Express service