Temporal Durable Workflows: A Guide with Java 25 + Spring

A guide to Temporal durable workflows with Java 25 and Spring.

🌱 Seedling·created: ·category:Java

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

ConceptDescription
WorkflowDeterministic code defining the business logic
ActivitySide-effect operations (DB calls, HTTP, files, etc.)
WorkerProcess that executes workflow & activity code
Task QueueQueue workers poll for tasks
Temporal ServerServer that persists state & event history
SignalSending an async message into a running workflow
QueryReading 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-starter largely 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() or Workflow.await(), never Thread.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 ApplicationFailure to distinguish retryable errors from permanent ones - don’t let Temporal burn retry attempts on failures that will never succeed.
  • Call continueAsNew periodically in workflows that loop indefinitely, to keep event history from growing unbounded.
  • Write unit tests with TestWorkflowEnvironment - it lets you simulate days/weeks of Workflow.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

This note is part of the Digital Garden — a collection of connected, evolving thoughts.