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>2.0.0</version>
</dependency>
<!-- Spring Boot starter - autoconfigured client + @JobWorker registration -->
<dependency>
<groupId>com.quantumbpm</groupId>
<artifactId>quantum-spring</artifactId>
<version>2.0.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, ClientCredentialsTokenProvider, 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.ClientCredentialsTokenProvider;
import com.quantumbpm.client.variables.Vars;
import java.util.UUID;
var provider = new ClientCredentialsTokenProvider(
"https://idp.example.com/realms/acme/protocol/openid-connect/token",
"billing-svc",
System.getenv("QUANTUMBPM_CLIENT_SECRET"));
QuantumBPM client = QuantumBPM.builder()
.baseUrl("https://quantumbpm.example.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://quantumbpm.example.com
project-id: 00000000-0000-0000-0000-000000000000
auth:
client-credentials:
token-url: https://idp.example.com/realms/acme/protocol/openid-connect/token
client-id: billing-svc
client-secret: ${QUANTUMBPM_CLIENT_SECRET}
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("loan-process", 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 the client credentials properties (quantumbpm.auth.client-credentials.token-url and .client-id) are set:
quantumbpm:
base-url: http://localhost:9060/api
project-id: 00000000-0000-0000-0000-0000000000ccClientCredentialsTokenProvider fetches access tokens from your OIDC identity provider (Keycloak, Microsoft Entra ID, Okta, Auth0 and others) with the client credentials grant. Register a confidential client for your service at the identity provider and pass its token endpoint and credentials:
import com.quantumbpm.client.auth.ClientCredentialsTokenProvider;
var provider = new ClientCredentialsTokenProvider(
"https://idp.example.com/realms/acme/protocol/openid-connect/token",
"billing-svc",
System.getenv("QUANTUMBPM_CLIENT_SECRET"));
// With scopes and an audience. Some providers such as Auth0 require the audience.
var withAudience = new ClientCredentialsTokenProvider(
"https://idp.example.com/realms/acme/protocol/openid-connect/token",
"billing-svc",
System.getenv("QUANTUMBPM_CLIENT_SECRET"),
List.of("openid"),
"quantumbpm");The exact token endpoint depends on the identity provider. The provider's OIDC discovery document (/.well-known/openid-configuration) lists it as token_endpoint.
Scopes are sent space-joined as the scope parameter and the audience as the audience parameter. Each is left out of the request when empty. The client must be set up at the identity provider so its tokens carry the audience and roles your QuantumBPM deployment expects.
The token is cached in memory and replaced once less than 60 seconds of its lifetime remain. Concurrent calls share the cache via a lock. A non-2xx response from the token endpoint throws TokenException with the HTTP status and response body.
With the Spring Boot starter, set the same values as properties:
quantumbpm:
auth:
client-credentials:
token-url: https://idp.example.com/realms/acme/protocol/openid-connect/token
client-id: billing-svc
client-secret: ${QUANTUMBPM_CLIENT_SECRET}
scopes: openid # optional, comma-separated or a YAML list
audience: quantumbpm # optionalThe starter uses the client credentials provider when token-url and client-id are set, and quantumbpm.token otherwise.
For a token acquired out of band, for example by another library or in tests:
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"));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 the process versions the deploy created.
var deployed = client.bpmn().getResource(draft.getId());
var processDef = deployed.getProcesses().get(0);
// Starts the latest version. startInstance(processId, version, vars, businessId) picks one.
String instanceId = client.bpmn().startInstance(
processDef.getProcessID(),
new Vars().set("applicantID", "u-123").set("requestedAmt", 25000));var state = client.bpmn().getInstance(instanceId);
System.out.println(state.getStatus());
Vars vars = client.bpmn().getInstanceVariables(instanceId);
var children = client.bpmn().getInstanceChildren(instanceId);
// History pages run oldest first. With fromSeq and beforeSeq null a page holds
// the newest entries, and an entry read by its seq carries its inputs and outputs.
var page = client.bpmn().getInstanceHistory(instanceId, 1L, null, 100);
var entry = client.bpmn().getInstanceHistoryEntry(instanceId, page.getItems().get(0).getSeq());
var version = client.bpmn().getProcessVersion("loan-process", 2, null);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);
// Lists of instances and user tasks page by cursor: pass the page's
// next cursor back for the next one. It is null on the last page.
var next = page.getPagination().getNextCursor();
if (next != null) {
page = client.bpmn().listUserTasks(
null, "CREATED", "alice@example.com", null, null, next, null);
}
// The caller's tasks soonest due first, and the project's overdue ones.
var mine = client.bpmn().listUserTasksForCaller(null, null, "dueDate", null, null);
var overdue = client.bpmn().listUserTasks(
null, "CREATED", null, null, null, null, null, OffsetDateTime.now(), 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 (ENGINE_WORKER_LOCK_MIN, ENGINE_WORKER_LOCK_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 (ENGINE_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(), instanceId, body);MIT License - see LICENSE for details.