Temporal Durable Workflows: A Guide with Java 25 + Spring
A guide to Temporal durable workflows with Java 25 and Spring.
1. What is Temporal?
Temporal is an open-source workflow orchestration platform that lets you write long-running, reliable, stateful workflows as ordinary code. Even if a server crashes, the network drops, or a service restarts, Temporal guarantees your workflow resumes exactly where it left off. It does this by durably persisting every step and event as an “event history”; when a worker dies, another worker can replay that history and continue execution seamlessly.
Core Components
| Concept | Description |
|---|---|
| Workflow | Deterministic code defining the business logic |
| Activity | Side-effect operations (DB calls, HTTP, files, etc.) |
| Worker | Process that executes workflow & activity code |
| Task Queue | Queue workers poll for tasks |
| Temporal Server | Server that persists state & event history |
| Signal | Sending an async message into a running workflow |
| Query | Reading the current state of a running workflow |
2. Why Use Temporal?
With traditional approaches (cron jobs, message queues + hand-rolled state machines, manual retry logic), you run into:
- Lost state when a process crashes mid-execution.
- Retry/backoff logic duplicated everywhere by hand.
- Complexity in persisting and synchronizing state for processes that run for days/weeks (e.g., a loan application waiting on approval).
- Fragile, manually-managed compensation logic for distributed transactions (the saga pattern).
Temporal solves these by providing:
- Durability: Workflow state is automatically persisted; it survives crashes.
- Automatic retries: You declare a retry policy on activities and Temporal handles the rest.
- Orchestration as code: Write complex business processes using plain Java control flow (if/else, loops, try/catch).
- Visibility: The Temporal Web UI shows the full execution history of every workflow - which step ran when, and what failed.
- Scalability: Manages thousands to millions of concurrent workflow executions.
3. Real-World Use Cases
3.1 E-Commerce Order Processing (Saga Pattern)
An order spans multiple microservices: reserving inventory, charging payment, creating a shipment. If payment fails, the inventory reservation must be rolled back (compensation). Temporal lets you manage this saga as a single, step-by-step, fault-tolerant workflow.
3.2 Payment Processing with Automatic Retries
A payment provider (Stripe, iyzico, etc.) may be temporarily unavailable. Temporal automatically retries with exponential backoff, while failing fast (no retry) for certain error types like a declined card.
3.3 Human-in-the-Loop Approval Processes
Processes like loan approvals, expense approvals, or KYC checks can take days. The workflow “sleeps” without consuming resources until a human approval arrives as a signal, then resumes exactly where it left off.
3.4 Scheduled & Cron Workflows
For daily report generation, subscription renewals, or periodic data sync, Temporal’s built-in cron/schedule support is more reliable than classic cron jobs, since every run’s history and failures are fully tracked.
3.5 ETL & Data Pipelines
For pipelines that extract, transform, and load large datasets step by step, each step is modeled as an Activity. If one step fails, only that step is retried - no need to restart the entire pipeline.
3.6 Microservices Orchestration
For complex business processes spanning dozens of microservices (e.g., new-user onboarding: create account → verify email → add payment method → welcome campaign), Temporal acts as a centralized, observable orchestrator.
3.7 Notification & Reminder Systems
Time-based business rules like “send a reminder email if the user hasn’t completed the action in 3 days, deactivate the account after 7 days” are naturally expressed using Workflow.sleep().
4. Architecture Overview
flowchart LR
A["Spring Boot<br/>REST API Controller"] -->|"start / signal / query"| B["Temporal Server<br/>(state & history)"]
B <-->|"poll tasks"| C["Worker (Spring)<br/>Workflow + Activities"]
The Spring Boot application connects to the Temporal Server via WorkflowClient to start workflows and send signals/queries. A separate (or the same) Worker process polls the relevant Task Queue and executes the workflow and activity code.
5. Setup
5.1 Maven Dependencies
Add the following dependencies for Java 25 and Spring Boot 3.x.
<properties>
<java.version>25</java.version>
<temporal.version>1.26.1</temporal.version>
</properties>
<dependencies>
<dependency>
<groupId>io.temporal</groupId>
<artifactId>temporal-sdk</artifactId>
<version>${temporal.version}</version>
</dependency>
<dependency>
<groupId>io.temporal</groupId>
<artifactId>temporal-spring-boot-starter</artifactId>
<version>${temporal.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
Note: Always check the latest version numbers on Maven Central.
5.2 Configuration (application.yml)
spring:
temporal:
connection:
target: 127.0.0.1:7233
namespace: default
workers:
- task-queue: order-processing-queue
workers-auto-discovery:
packages:
- com.example.temporaldemo.workflows
For local development, you can run the Temporal Server via the Temporal CLI (temporal server start-dev) or via the official Temporal docker-compose file.
6. Code Examples
6.1 Order Saga: Workflow Interface
package com.example.temporaldemo.workflows;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
import io.temporal.workflow.SignalMethod;
import io.temporal.workflow.QueryMethod;
// Workflow interface - the API this workflow exposes to the outside world
@WorkflowInterface
public interface OrderWorkflow {
@WorkflowMethod
OrderResult processOrder(OrderRequest request);
// Notify the workflow when a tracking number arrives
@SignalMethod
void shipmentTrackingReceived(String trackingNumber);
// Query the current status of the workflow
@QueryMethod
String getOrderStatus();
}
6.2 Workflow Implementation (Saga + Compensation)
package com.example.temporaldemo.workflows;
import io.temporal.activity.ActivityOptions;
import io.temporal.common.RetryOptions;
import io.temporal.workflow.Workflow;
import java.time.Duration;
public class OrderWorkflowImpl implements OrderWorkflow {
private String status = "STARTED";
private String trackingNumber;
// Retry policy - automatically retry on transient failures
private final RetryOptions retryOptions = RetryOptions.newBuilder()
.setInitialInterval(Duration.ofSeconds(1))
.setMaximumInterval(Duration.ofSeconds(30))
.setBackoffCoefficient(2.0)
.setMaximumAttempts(5)
.build();
private final ActivityOptions activityOptions = ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.setRetryOptions(retryOptions)
.build();
private final OrderActivities activities =
Workflow.newActivityStub(OrderActivities.class, activityOptions);
@Override
public OrderResult processOrder(OrderRequest request) {
status = "RESERVING_INVENTORY";
activities.reserveInventory(request.getOrderId(), request.getItems());
try {
status = "CHARGING_PAYMENT";
activities.chargePayment(request.getOrderId(), request.getAmount());
} catch (Exception e) {
// Payment failed -> compensate by releasing the inventory reservation
status = "COMPENSATING";
activities.releaseInventory(request.getOrderId(), request.getItems());
status = "FAILED";
return new OrderResult(request.getOrderId(), "FAILED", "Payment failed: " + e.getMessage());
}
status = "CREATING_SHIPMENT";
activities.createShipment(request.getOrderId());
// Wait until the tracking number arrives via signal (can take days/weeks)
status = "AWAITING_TRACKING";
Workflow.await(() -> trackingNumber != null);
status = "COMPLETED";
return new OrderResult(request.getOrderId(), "COMPLETED", "Tracking: " + trackingNumber);
}
@Override
public void shipmentTrackingReceived(String trackingNumber) {
this.trackingNumber = trackingNumber;
}
@Override
public String getOrderStatus() {
return status;
}
}
6.3 Activity Interface & Implementation
package com.example.temporaldemo.workflows;
import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;
import java.util.List;
// Activities contain side-effecting operations (DB, HTTP calls, etc.)
@ActivityInterface
public interface OrderActivities {
@ActivityMethod
void reserveInventory(String orderId, List<String> items);
@ActivityMethod
void releaseInventory(String orderId, List<String> items);
@ActivityMethod
void chargePayment(String orderId, double amount);
@ActivityMethod
void createShipment(String orderId);
}
package com.example.temporaldemo.workflows;
import org.springframework.stereotype.Component;
import java.util.List;
// Defined as a Spring bean, contains the actual service calls
@Component
public class OrderActivitiesImpl implements OrderActivities {
@Override
public void reserveInventory(String orderId, List<String> items) {
// HTTP/gRPC call to the inventory service
System.out.println("Reserving inventory for order " + orderId);
}
@Override
public void releaseInventory(String orderId, List<String> items) {
System.out.println("Releasing inventory for order " + orderId);
}
@Override
public void chargePayment(String orderId, double amount) {
// Call to the payment service; throwing triggers Temporal's retry logic
System.out.println("Charging payment for order " + orderId + ": " + amount);
}
@Override
public void createShipment(String orderId) {
System.out.println("Creating shipment for order " + orderId);
}
}
6.4 Spring Boot Worker Configuration
package com.example.temporaldemo.config;
import io.temporal.client.WorkflowClient;
import io.temporal.serviceclient.WorkflowServiceStubs;
import io.temporal.serviceclient.WorkflowServiceStubsOptions;
import io.temporal.worker.Worker;
import io.temporal.worker.WorkerFactory;
import com.example.temporaldemo.workflows.OrderActivitiesImpl;
import com.example.temporaldemo.workflows.OrderWorkflowImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
@Configuration
public class TemporalConfig {
public static final String TASK_QUEUE = "order-processing-queue";
@Autowired
private OrderActivitiesImpl orderActivities;
@Bean
public WorkflowServiceStubs workflowServiceStubs() {
return WorkflowServiceStubs.newServiceStubs(
WorkflowServiceStubsOptions.newBuilder()
.setTarget("127.0.0.1:7233")
.build());
}
@Bean
public WorkflowClient workflowClient(WorkflowServiceStubs serviceStubs) {
return WorkflowClient.newInstance(serviceStubs);
}
@Bean
public WorkerFactory workerFactory(WorkflowClient client) {
WorkerFactory factory = WorkerFactory.newInstance(client);
Worker worker = factory.newWorker(TASK_QUEUE);
// Register the workflow implementation
worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class);
// Register the activity bean (with Spring-managed dependencies)
worker.registerActivitiesImplementations(orderActivities);
return factory;
}
@PostConstruct
public void startWorkerFactory() {
// Note: Depending on bean ordering, factory.start() can be invoked
// from a separate @Bean method or an ApplicationRunner.
}
}
In production, using
temporal-spring-boot-starterlargely automates worker registration and startup; the example above is shown manually to illustrate the mechanism.
6.5 Starting a Workflow from a REST Controller
package com.example.temporaldemo.controller;
import com.example.temporaldemo.workflows.OrderRequest;
import com.example.temporaldemo.workflows.OrderWorkflow;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
private WorkflowClient workflowClient;
private static final String TASK_QUEUE = "order-processing-queue";
@PostMapping
public String createOrder(@RequestBody OrderRequest request) {
// A unique workflow ID is used per order (provides idempotency)
WorkflowOptions options = WorkflowOptions.newBuilder()
.setTaskQueue(TASK_QUEUE)
.setWorkflowId("order-" + request.getOrderId())
.build();
OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, options);
// Start asynchronously and return immediately (workflow keeps running in the background)
WorkflowClient.start(workflow::processOrder, request);
return "Order submitted: " + request.getOrderId();
}
@PostMapping("/{orderId}/tracking")
public String submitTracking(@PathVariable String orderId, @RequestBody String trackingNumber) {
OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, "order-" + orderId);
workflow.shipmentTrackingReceived(trackingNumber);
return "Tracking number recorded";
}
@GetMapping("/{orderId}/status")
public String getStatus(@PathVariable String orderId) {
OrderWorkflow workflow = workflowClient.newWorkflowStub(OrderWorkflow.class, "order-" + orderId);
return workflow.getOrderStatus();
}
}
6.6 Scheduled Workflow Example
A report workflow that runs at a fixed time every day.
package com.example.temporaldemo.workflows;
import io.temporal.client.WorkflowClient;
import io.temporal.client.schedules.*;
import java.time.Duration;
public class DailyReportScheduler {
public void scheduleDailyReport(WorkflowClient client) {
ScheduleClient scheduleClient = ScheduleClient.newInstance(client.getWorkflowServiceStubs());
Schedule schedule = Schedule.newBuilder()
.setAction(ScheduleActionStartWorkflow.newBuilder()
.setWorkflowType(ReportWorkflow.class)
.setArguments("daily-sales-report")
.setOptions(io.temporal.client.WorkflowOptions.newBuilder()
.setTaskQueue("report-queue")
.setWorkflowId("daily-report")
.build())
.build())
// Run every day at 06:00
.setSpec(ScheduleSpec.newBuilder()
.setCronExpressions(java.util.List.of("0 6 * * *"))
.build())
.build();
scheduleClient.createSchedule("daily-report-schedule", schedule, ScheduleOptions.newBuilder().build());
}
}
6.7 Human-in-the-Loop: Approval Workflow
package com.example.temporaldemo.workflows;
import io.temporal.workflow.*;
import java.time.Duration;
@WorkflowInterface
public interface ExpenseApprovalWorkflow {
@WorkflowMethod
String submitExpense(ExpenseRequest request);
@SignalMethod
void approve(String approverId);
@SignalMethod
void reject(String approverId, String reason);
}
class ExpenseApprovalWorkflowImpl implements ExpenseApprovalWorkflow {
private boolean approved = false;
private boolean rejected = false;
private String rejectionReason;
@Override
public String submitExpense(ExpenseRequest request) {
// Notify the manager (via an Activity)
ExpenseActivities activities = Workflow.newActivityStub(
ExpenseActivities.class,
io.temporal.activity.ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.build());
activities.notifyApprover(request);
// Wait for an approve/reject signal; time out if no response within 3 days
boolean receivedInTime = Workflow.await(
Duration.ofDays(3),
() -> approved || rejected);
if (!receivedInTime) {
return "TIMED_OUT";
}
if (rejected) {
return "REJECTED: " + rejectionReason;
}
return "APPROVED";
}
@Override
public void approve(String approverId) {
this.approved = true;
}
@Override
public void reject(String approverId, String reason) {
this.rejected = true;
this.rejectionReason = reason;
}
}
6.8 Proper Error Handling in Activities
Not every failure should be retried. Use ApplicationFailure to mark errors as non-retryable (e.g., a declined card, invalid input) so Temporal fails fast instead of wasting retry attempts.
package com.example.temporaldemo.workflows;
import io.temporal.failure.ApplicationFailure;
public class PaymentActivitiesImpl implements OrderActivities {
@Override
public void chargePayment(String orderId, double amount) {
PaymentResult result = callPaymentGateway(orderId, amount);
if (result.isDeclined()) {
// Non-retryable: retrying a declined card will never succeed
throw ApplicationFailure.newNonRetryableFailure(
"Card declined for order " + orderId,
"CardDeclined");
}
if (result.isGatewayTimeout()) {
// Retryable: a transient network/gateway issue, safe to retry
throw ApplicationFailure.newFailure(
"Gateway timeout for order " + orderId,
"GatewayTimeout");
}
}
private PaymentResult callPaymentGateway(String orderId, double amount) {
// actual HTTP call to the payment provider
return new PaymentResult();
}
}
You can also exclude specific exception types from retries directly on the RetryOptions:
RetryOptions retryOptions = RetryOptions.newBuilder()
.setInitialInterval(Duration.ofSeconds(1))
.setMaximumAttempts(5)
.setDoNotRetry(IllegalArgumentException.class.getName(), "CardDeclined")
.build();
6.9 Continue-As-New for Long-Running / Infinite Workflows
Workflows that loop indefinitely (e.g., a per-user “always-on” state machine, or a workflow processing an unbounded stream of events) must periodically call continueAsNew to reset their event history. Otherwise the history grows unbounded and replay becomes slow and expensive.
package com.example.temporaldemo.workflows;
import io.temporal.workflow.Workflow;
import io.temporal.workflow.WorkflowMethod;
import io.temporal.workflow.WorkflowInterface;
@WorkflowInterface
public interface UserSessionWorkflow {
@WorkflowMethod
void run(UserSessionState state);
}
class UserSessionWorkflowImpl implements UserSessionWorkflow {
private static final int MAX_EVENTS_PER_RUN = 10_000;
@Override
public void run(UserSessionState state) {
int eventsProcessed = 0;
while (eventsProcessed < MAX_EVENTS_PER_RUN) {
// ... process incoming signals/events, update state ...
eventsProcessed++;
}
// Reset history with the current state as input to the next run
Workflow.continueAsNew(state);
}
}
6.10 Testing Workflows
Temporal provides TestWorkflowEnvironment, which runs an in-memory Temporal server and lets you skip simulated time (e.g., fast-forward through a Workflow.sleep(Duration.ofDays(3)) in milliseconds).
package com.example.temporaldemo.workflows;
import io.temporal.testing.TestWorkflowEnvironment;
import io.temporal.testing.TestWorkflowExtension;
import io.temporal.worker.Worker;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
public class OrderWorkflowTest {
@RegisterExtension
public static final TestWorkflowExtension testWorkflowExtension =
TestWorkflowExtension.newBuilder()
.setWorkflowTypes(OrderWorkflowImpl.class)
.setDoNotStart(true)
.build();
@Test
public void testSuccessfulOrder(TestWorkflowEnvironment testEnv, Worker worker, OrderWorkflow workflow) {
// Register a mocked activity implementation instead of the real one
OrderActivities mockActivities = mock(OrderActivities.class);
worker.registerActivitiesImplementations(mockActivities);
testEnv.start();
OrderRequest request = new OrderRequest("order-1", java.util.List.of("item-1"), 100.0);
OrderResult result = workflow.processOrder(request);
verify(mockActivities).reserveInventory(eq("order-1"), any());
verify(mockActivities).chargePayment(eq("order-1"), eq(100.0));
assertEquals("COMPLETED", result.getStatus());
}
}
6.11 Observability with Search Attributes
Search Attributes let you query and filter running/completed workflows in the Temporal Web UI or via the CLI (e.g., “show me all failed orders for customer X”).
package com.example.temporaldemo.workflows;
import io.temporal.workflow.Workflow;
import io.temporal.common.SearchAttributeKey;
public class OrderWorkflowImpl implements OrderWorkflow {
private static final SearchAttributeKey<String> CUSTOMER_ID_KEY =
SearchAttributeKey.forKeyword("CustomerId");
private static final SearchAttributeKey<Double> ORDER_AMOUNT_KEY =
SearchAttributeKey.forDouble("OrderAmount");
public OrderResult processOrder(OrderRequest request) {
Workflow.upsertTypedSearchAttributes(
CUSTOMER_ID_KEY.valueSet(request.getCustomerId()),
ORDER_AMOUNT_KEY.valueSet(request.getAmount()));
// ... rest of the workflow logic ...
return null; // placeholder
}
}
7. Best Practices
- Workflow code must be deterministic: avoid side effects like
System.currentTimeMillis(),Random, or direct HTTP calls inside workflow code - use an Activity instead. - For long waits, use
Workflow.sleep()orWorkflow.await(), neverThread.sleep(). - Design activities to be idempotent; remember they may be retried and executed more than once.
- Choose workflow IDs meaningfully and uniquely (e.g.,
order-12345) - this naturally provides idempotency. - Use versioning (
Workflow.getVersion) to safely change workflow code without breaking workflows already running in production. - Use
ApplicationFailureto distinguish retryable errors from permanent ones - don’t let Temporal burn retry attempts on failures that will never succeed. - Call
continueAsNewperiodically in workflows that loop indefinitely, to keep event history from growing unbounded. - Write unit tests with
TestWorkflowEnvironment- it lets you simulate days/weeks ofWorkflow.sleep()in milliseconds, which is essential for testing timeout and reminder logic. - Attach Search Attributes to important business fields (customer ID, order amount, region) so you can query and monitor workflows effectively from the Temporal Web UI.
8. Resources
- Official Docs: https://docs.temporal.io
- Temporal Java SDK: https://github.com/temporalio/sdk-java
- Temporal Spring Boot Starter: https://github.com/temporalio/sdk-java/tree/master/temporal-spring-boot-autoconfigure
- Temporal Samples (Java): https://github.com/temporalio/samples-java