Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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