Install
$ agentstack add skill-resonatehq-resonate-skills-resonate-saga-pattern-java ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Resonate Saga Pattern — Java
> Prerelease note. resonate-sdk-java is published on Maven Central — pin io.resonatehq:resonate-sdk-java:0.1.1. The API mirrors the Python SDK and may change before a stable 1.0. Requires Java 21+ (virtual threads, a feature generally available in Java 21). There is no Java saga example repo yet, so every code block here uses only documented SDK surface, compile-verified against 0.1.1 and cross-checked against develop/java.mdx (docs PR #230) and the SDK source.
Overview
A saga is a long-running transaction split into discrete steps, each with a compensating action. Forward steps execute top-to-bottom; on any failure, completed compensations run bottom-to-top. Because the whole workflow body re-runs on resume, the completed list is reconstructed deterministically on replay — each ctx.run short-circuits its settled durable promise in the same order, so the list is rebuilt identically before reaching the point of failure.
Java's exception model makes the saga read more like the TypeScript/Python idiom than Go's: the forward path lives in a try block, and a single catch triggers compensation — there is no per-step (T, error) check to thread by hand.
For the language-agnostic mental model — choreography vs orchestration, idempotency requirements, when not to use sagas — see resonate-saga-pattern-typescript.
When to use
- Multi-step workflow where intermediate state is visible to other systems between steps
- Each step is individually compensable (inventory hold, payment charge, shipment create)
- You need "all or nothing" consistency without a distributed 2PC transaction
- Steps span services and may take seconds to minutes each
- Compensation logic exists and is idempotent for every committed step
Basic shape
package io.resonatehq.examples.saga;
import io.resonatehq.resonate.Context;
import io.resonatehq.resonate.Context.Opts;
import io.resonatehq.resonate.Handle.ResonateHandle;
import io.resonatehq.resonate.Resonate;
import io.resonatehq.resonate.Retry.Exponential;
import java.util.ArrayList;
import java.util.List;
public final class Saga {
private Saga() {}
/** The forward steps that can be committed; the enum + switch gives closed-set compensation dispatch. */
public enum Step { INVENTORY, PAYMENT, SHIPMENT }
public record SagaResult(String status, String orderId, List compensated) {}
/**
* The saga orchestrator. Forward steps run in sequence inside a try; the first failure throws,
* and the catch runs compensations in reverse. Each ctx.run is a durable checkpoint, so a
* crash mid-saga resumes from the last settled step — committed steps do not re-execute.
*/
public static SagaResult placeOrder(Context ctx, String orderId) {
List completed = new ArrayList<>();
try {
ctx.run(Saga::reserveInventory, orderId).await();
completed.add(Step.INVENTORY);
ctx.run(Saga::chargePayment, orderId).await();
completed.add(Step.PAYMENT);
ctx.run(Saga::createShipment, orderId).await();
completed.add(Step.SHIPMENT);
return new SagaResult("success", orderId, List.of());
} catch (Exception forwardErr) {
// await() sneaky-throws the rejection, which may be unchecked OR a sneakily-rethrown
// checked exception — catch Exception so no failure path slips past the compensator.
return compensateAll(ctx, orderId, completed);
}
}
/** Iterate completed steps in reverse, running each compensation as its own durable ctx.run. */
public static SagaResult compensateAll(Context ctx, String orderId, List completed) {
List names = new ArrayList<>();
for (int i = completed.size() - 1; i >= 0; i--) {
Step step = completed.get(i);
// A compensation that exhausts retries is a real inconsistency — give it a generous policy.
ctx.options(new Opts().withRetryPolicy(new Exponential(1, 5, 2, 30)))
.run(Saga::compensate, step, orderId)
.await();
names.add(step.name());
}
return new SagaResult("failed", orderId, names);
}
/** Dispatch to the correct undo action via a switch. Each branch must be idempotent. */
public static String compensate(Context ctx, Step step, String orderId) {
return switch (step) {
case SHIPMENT -> "shipment-cancelled:" + orderId; // cancelShipment(orderId)
case PAYMENT -> "payment-refunded:" + orderId; // refundPayment(orderId)
case INVENTORY -> "inventory-released:" + orderId; // releaseInventory(orderId)
};
}
// Forward leaf stubs — replace with real service calls.
public static String reserveInventory(Context ctx, String orderId) {
return "reserved:" + orderId;
}
public static String chargePayment(Context ctx, String orderId) {
return "charged:" + orderId;
}
public static String createShipment(Context ctx, String orderId) {
// Simulate a transient failure to trigger compensation.
throw new IllegalStateException("shipment service unavailable");
}
public static void main(String[] args) {
Resonate r = Resonate.builder().url("http://localhost:8001").build();
r.register(Saga::placeOrder);
try {
ResonateHandle handle = r.run("order-1", Saga::placeOrder, "order-1");
System.out.println(handle.result());
} finally {
r.stop();
}
}
}
Each ctx.run + await pair is a durable checkpoint. If the worker crashes mid-saga, Resonate resumes from the last settled step — completed forward steps do not re-execute.
Catch Exception, not RuntimeException
ResonateFuture.await() re-raises a rejected promise via a sneaky throw (Context.java): the underlying error is rethrown without being wrapped, even though await() declares no checked exception. In the common single-process path a leaf's failure comes back as a RuntimeException (a thrown checked exception is wrapped in ApplicationError, itself a RuntimeException), so catch (RuntimeException) would work. But when a rejection was recorded by a different worker that serialized a checked exception into the promise (Codec can recover an arbitrary originating exception across the durability boundary), await() can surface that checked exception directly. Catch Exception (it always compiles in a try block — Exception and Throwable are special-cased) so the saga compensates on any forward failure, local or cross-worker, at no cost.
Compensation must be retryable
A compensation that exhausts retries leaves the system in a real inconsistency. Give compensations a generous timeout and an explicit retry policy:
ctx.options(new Opts()
.withTimeout(java.time.Duration.ofMinutes(10))
.withRetryPolicy(new Exponential(1, 5, 2, 30))) // 1s base, doubling, capped 30s, 5 retries
.run(Saga::compensate, step, orderId)
.await();
If that await() throws after retries are exhausted, you have a manual-intervention situation — log the step, the order ID, and alert before rethrowing.
Controlling which steps retry — there is no NonRetryable
Unlike the Go SDK (resonate.NewNonRetryable), the Java SDK has no non-retryable error wrapper. The error hierarchy (Errors.ResonateError and its subclasses) is for catching/matching, not for tagging an error as terminal. Control retry behavior with the retry policy instead:
import io.resonatehq.resonate.Retry.Never;
// A forward step against a non-idempotent external API that must NOT be retried:
ctx.options(new Opts().withRetryPolicy(new Never())) // single attempt
.run(Saga::chargeOnce, orderId)
.await();
new Retry.Never() runs the leaf exactly once; any throw propagates immediately to the saga's catch. For a fast-fail on a programming error (e.g. an unhandled enum case), let the exception propagate — an exhaustive switch expression over the enum needs no default, so a newly added step that lacks a compensation is a compile error, not a silent runtime loop.
Distinct Java idioms
try/catchforward path, not per-step(T, error)checks. The whole forward sequence sits in onetry; a singlecatch (Exception forwardErr)triggerscompensateAll. This is closer to the TypeScript/Python saga than to Go's explicit error returns.- Catch
Exception, notRuntimeException—await()sneaky-throws, so a checked exception can surface; onlyExceptionguarantees the compensator runs. enum Step+ exhaustiveswitchexpression — gives the closed-step-set semantics with nodefaultbranch; adding a step without a compensation fails to compile.List completedrebuilt on replay — on crash-resume the body re-runs from the top; eachctx.runshort-circuits its settled promise in order, so the list is reconstructed identically before the failed step.for (int i = completed.size() - 1; i >= 0; i--)— the straightforward reverse loop for unwinding compensations.recordargs/results —record SagaResult(...)and plainStringstep args are JSON-serializable across the durability boundary.- Retry policy controls terminal-vs-retried, since there is no
NonRetryable— usenew Retry.Never()for steps that must not retry.
Avoid
- Catching
RuntimeExceptioninstead ofException—await()can sneaky-throw a checked exception past it, skipping compensation. - Non-idempotent compensations — each compensation
ctx.runmay execute more than once on retry. A compensation that double-refunds is worse than the original failure. - Reading the system-of-record outside a
ctx.run— a raw DB/API call in the workflow body re-executes on every replay. Wrap observable side effects in their ownctx.runso the result is checkpointed. - Swallowing compensation errors — if a compensation
await()throws after retries, surface it. Silently continuing produces a partially compensated saga with no audit trail. - Inventing a
NonRetryableerror — it does not exist in the Java SDK; useRetry.Never()or a boundedmaxRetries. - Copying the example's
finally { r.stop() }into a persistent worker — themainabove is a one-shot demo. A long-lived saga worker registers the function and blocks on aCountDownLatch; callingr.stop()tears down its task pipeline.
Related skills
resonate-basic-durable-world-usage-java—ctx.run,Opts, retry policies, the replay modelresonate-recursive-fan-out-pattern-java— parallel dispatch, composable with saga steps that fan out internallyresonate-external-system-of-record-pattern-java— idempotency keys and SoR reads inside durable leavesresonate-basic-debugging-java— rejected-promise re-throw andawait()sneaky-throw mechanics; ther.stop()-on-a-worker footgundurable-execution— foundational replay semantics that make thecompletedlist saferesonate-saga-pattern-typescript— language-agnostic mental model, choreography vs orchestrationresonate-saga-pattern-python— the closest sibling; the Java API mirrors Python
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: resonatehq
- Source: resonatehq/resonate-skills
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.