Official Java SDK for the QuantumBPM platform - DMN evaluation, BPMN process orchestration, and external job workers.
The SDK is split into two artifacts:
<!-- Plain Java client -->
<dependency>
<groupId>com.quantumbpm</groupId>
<artifactId>quantum-client</artifactId>
<version>1.2.0</version>
</dependency>
<!-- Spring Boot starter - autoconfigured client + @JobWorker registration -->
<dependency>
<groupId>com.quantumbpm</groupId>
<artifactId>quantum-spring</artifactId>
<version>1.2.0</version>
</dependency>Java 21+. The worker runtime uses virtual threads.
| Module | Package | Purpose |
|---|---|---|
quantum-client |
com.quantumbpm.client |
Plain Java SDK - QuantumBPM, DmnClient, BpmnClient, Worker |
com.quantumbpm.client.auth |
TokenProvider, ZitadelTokenProvider, StaticTokenProvider |
|
com.quantumbpm.client.variables |
Vars wrapper with typed accessors and FEEL-context conversion |
|
com.quantumbpm.client.workers |
Worker, Job<T>, Handler<T>, BpmnError |
|
com.quantumbpm.client.generated |
OpenAPI-generated client (use client.raw() for unwrapped endpoints) |
|
quantum-spring |
com.quantumbpm.spring |
Spring Boot starter, @JobWorker annotation, autoconfig |
import com.quantumbpm.client.QuantumBPM;
import com.quantumbpm.client.auth.ZitadelTokenProvider;
import com.quantumbpm.client.variables.Vars;
import java.util.UUID;
ZitadelTokenProvider provider = new ZitadelTokenProvider(
"./service-account.json", // Zitadel JSON Key file
"https://auth.quantumbpm.com", // issuer
"your-zitadel-project-id"); // audience scope
QuantumBPM client = QuantumBPM.builder()
.baseUrl("https://api.quantumbpm.com")
.projectId(UUID.fromString("00000000-0000-0000-0000-000000000000"))
.tokenProvider(provider)
.build();
var result = client.dmn().evaluate(
"loan-eligibility",
new Vars().set("requestedAmt", 1000).set("creditScore", 720));
System.out.println(result);application.yml:
quantumbpm:
base-url: https://api.quantumbpm.com
project-id: 00000000-0000-0000-0000-000000000000
auth:
zitadel:
key-file: /path/to/service-account.json
issuer: https://auth.quantumbpm.com
project-id: 123456789
worker:
enabled: true # default; scans @JobWorker beans
client-id: billing-svcA handler - register a method with @JobWorker:
import com.quantumbpm.client.variables.Vars;
import com.quantumbpm.client.workers.Job;
import com.quantumbpm.spring.JobWorker;
import org.springframework.stereotype.Component;
@Component
public class EmailHandler {
@JobWorker(type = "send-email", maxJobs = 5, lockDuration = "1m")
public Vars handle(Job<EmailJob> job) {
emailService.send(job.typed().recipient(), job.typed().subject());
return new Vars().set("messageID", "msg-123");
}
public record EmailJob(String recipient, String subject) {}
}The autoconfig wires everything: builds the QuantumBPM bean from properties, scans every Spring bean for @JobWorker methods, and starts/stops the worker around the application lifecycle.
Inject QuantumBPM anywhere you need DMN/BPMN client calls:
@Service
public class LoanService {
private final QuantumBPM quantum;
public LoanService(QuantumBPM quantum) { this.quantum = quantum; }
public String startLoan(LoanRequest req) throws Exception {
return quantum.bpmn().startInstance(processDefId, Vars.of()
.set("applicantID", req.applicantId())
.set("requestedAmt", req.amount()));
}
}The TokenProvider interface is a @FunctionalInterface returning a bearer token. Two implementations ship out of the box.
Against the local devserver, which has no login, leave the token provider out. The API lives under /api there:
QuantumBPM client = QuantumBPM.builder()
.baseUrl("http://localhost:9060/api")
.projectId("00000000-0000-0000-0000-0000000000cc")
.build();The Spring Boot starter does the same when neither quantumbpm.token nor quantumbpm.auth.zitadel.key-file is set:
quantumbpm:
base-url: http://localhost:9060/api
project-id: 00000000-0000-0000-0000-0000000000ccimport com.quantumbpm.client.auth.ZitadelTokenProvider;
var provider = new ZitadelTokenProvider(
"./service-account.json", // path to JSON Key file
"https://auth.quantumbpm.com", // issuer URL
"your-zitadel-project-id"); // adds the audience scopeTokens are cached in-memory until shortly before expiry. Concurrent calls share the cache via a lock.
import com.quantumbpm.client.auth.StaticTokenProvider;
var provider = new StaticTokenProvider("eyJhbGciOi...");TokenProvider provider = () -> myAuth.fetchToken();client.dmn() exposes four methods.
var result = client.dmn().evaluate(
"loan-eligibility",
new Vars().set("requestedAmt", 5000).set("creditScore", 720));Returns Map<String, EvaluationResult> keyed by decision name.
Decode one decision's value into a record or POJO that matches its output type. Numbers stay exact as BigDecimal. It throws when the decision is missing from the result or failed to evaluate:
import static com.quantumbpm.client.dmn.DmnClient.decisionValue;
record CoverCheck(BigDecimal limit, boolean withinCover, BigDecimal uncovered, String message) {}
CoverCheck cover = decisionValue(result, "Cover check", CoverCheck.class);Pin a version, restrict the evaluated decisions, or attach decision services:
import static com.quantumbpm.client.dmn.DmnClient.*;
var result = client.dmn().evaluate(
"loan-eligibility", vars,
withVersion(3),
withDecisions("eligibility", "rate"));var result = client.dmn().evaluateById(definitionUuid, vars);import static com.quantumbpm.client.dmn.DmnClient.*;
var result = client.dmn().evaluateDesign(
dmnXml, vars,
withAdditionalXmls(importedXml1, importedXml2),
withDesignDecisions("eligibility"));var rows = List.of(
new Vars().set("requestedAmt", 1000),
new Vars().set("requestedAmt", 5000),
new Vars().set("requestedAmt", 25000));
var batch = client.dmn().evaluateDesignBatch(dmnXml, rows);client.bpmn() covers the full BPMN runtime surface.
var draft = client.bpmn().createResource("loan-process", bpmnXml);
client.bpmn().deployResource(draft.getId());
// Re-fetch to get populated process-definition list.
var deployed = client.bpmn().getResource(draft.getId());
var processDef = deployed.getProcesses().get(0);
String workflowId = client.bpmn().startInstance(
processDef.getId(),
new Vars().set("applicantID", "u-123").set("requestedAmt", 25000));var state = client.bpmn().getInstance(workflowId);
System.out.println(state.getStatus());
Vars vars = client.bpmn().getInstanceVariables(workflowId);
var children = client.bpmn().getInstanceChildren(workflowId);client.bpmn().publishMessage("loan-approved",
new Vars().set("approvedAmt", 24000),
null, // CorrelationKeys (optional)
"PT5M"); // TTL (optional)
client.bpmn().publishSignal("system-maintenance", null);var page = client.bpmn().listUserTasks(
null, "CREATED", "alice@example.com", null, null, null, null);
client.bpmn().completeUserTask(executionKey, new Vars().set("approved", true));
// Or fail with a BPMN error code:
client.bpmn().throwUserTaskError(executionKey, "REVIEW_REJECTED", null);Workers handle service tasks asynchronously. Register a handler per task type, then start the worker. The runtime owns long-polling, lock heartbeats, dispatch (on virtual threads), and outcome mapping.
import com.quantumbpm.client.QuantumBPM;
import com.quantumbpm.client.variables.Vars;
import com.quantumbpm.client.workers.BpmnError;
import com.quantumbpm.client.workers.Worker;
QuantumBPM client = QuantumBPM.builder()...build();
Worker worker = client.newWorker("billing-svc");
worker.handle("send-email", job -> {
String recipient = job.vars().get("recipient", String.class);
String subject = job.vars().get("subject", String.class);
emailer.send(recipient, subject);
return new Vars().set("messageID", "msg-123"); // → Complete
}, Worker.withMaxJobs(10), Worker.withLockDuration("1m"));
worker.start(); // returns immediately. Loops run on virtual threads
Runtime.getRuntime().addShutdownHook(new Thread(() -> worker.stop(20_000)));
worker.awaitTermination(); // blocks until stop returnsVirtual threads do not keep the JVM alive, so awaitTermination() blocks main until stop has returned. See Locks, heartbeats, and shutdown for what stop does.
Pass the typed class as the second argument; the runtime decodes the job's variables into that type before invoking the handler. The decoded value lands in job.typed().
record EmailJob(String recipient, String subject) {}
worker.handle("send-email", EmailJob.class, job -> {
emailer.send(job.typed().recipient(), job.typed().subject());
return new Vars().set("messageID", "msg-123");
});In Spring, the type is inferred from the method's Job<T> parameter - no extra argument needed.
Throw a BpmnError to fail the job with a code that boundary error events on the originating service task can catch:
worker.handle("charge-card", job -> {
try {
return new Vars().set("transactionID", charge(job.vars().toMap()));
} catch (InsufficientFundsException e) {
throw new BpmnError("INSUFFICIENT_FUNDS",
new Vars().set("availableBalance", 12.0));
}
});Any other exception is reported as WORKER_ERROR, which the server treats as a retryable failure that decrements the job's retry budget.
worker.handle("send-email", handler,
Worker.withMaxJobs(10), // up to 10 in flight per task type
Worker.withPollTimeout("45s"), // long-poll wait
Worker.withLockDuration("2m")); // exclusive lock per jobConcurrency is per task type. Different task types poll independently. Each poll asks only for as many jobs as there are free slots, so no job waits on the client without a heartbeat.
Durations are Go duration strings such as 500ms, 30s, 12.5s, 1m30s or 1.5h. An invalid one throws IllegalArgumentException from handle.
A polled job is locked to this worker for lockDuration past its latest heartbeat. The server clamps the lock to between 10 seconds and 5 minutes by default (BPMN_WORKER_LEASE_MIN, BPMN_WORKER_LEASE_MAX) and returns the expiry it granted with each job. While the handler runs, the worker heartbeats at half of the lock the server granted. The server caps one attempt at 30 minutes by default (BPMN_JOB_MAX_HOLD). When a lock runs out, the server charges the job a retry and offers it again, or raises an incident when no retries are left. So an abandoned job always costs a retry.
When a heartbeat finds the lock gone (another worker holds the job, or it finished or was canceled), the runtime stops heartbeating, interrupts the handler and does not report its result. A complete or error sent after the lock is gone is discarded by the server and logged as a warning.
worker.stop(timeoutMillis) stops polling at once. Running handlers keep running and heartbeating, and their results are reported. Handlers still running at the timeout are interrupted and not reported, and stop returns. Their locks then run out on the server. Handlers that block (I/O, sleeps, waits) should respond to interrupts.
Wire stop to SIGTERM with a shutdown hook:
Runtime.getRuntime().addShutdownHook(new Thread(() -> worker.stop(20_000)));The Spring starter calls stop(20_000) when the application context closes. On Kubernetes, set terminationGracePeriodSeconds above the stop timeout (for example 30 for a 20 s timeout). Otherwise the pod is killed while handlers are still finishing.
Vars is a thin wrapper around Map<String, Object> shared by DMN, BPMN, and workers.
var v = new Vars().set("amount", 100).set("name", "Alice");
var v = Vars.from(Map.of("amount", 100, "name", "Alice"));double amount = v.get("amount", Double.class);
boolean approved = v.get("approved", Boolean.class);
record Loan(double requestedAmt, boolean approved) {}
Loan loan = v.as(Loan.class);get(name, Class) and as(Class) use Jackson under the hood - records, POJOs, and primitives all work.
client.raw() exposes the underlying generated ApiClient for endpoints not yet wrapped (instance migration, modification, ad-hoc triggers, batch job complete/error, etc.):
import com.quantumbpm.client.generated.api.BpmnApi;
var api = new BpmnApi(client.raw());
api.migrateBpmnInstance(client.projectId(), workflowId, body);MIT License - see LICENSE for details.