Spring Boot with Temporal: Building Resillient Workflow Applications
Introduction
Building reliable distributed applications can be challenging. System crash, network fail, and processes can get stuck halfway through execution. This is where Temporal comes in. It is an open — source workflow orchestration platform. When combined with Spring Boot, it helps you build robust apps. These apps can handle real — world challenges.
Why should you care about Temporal?
If you’ve worked with distributed systems, you’ve likely faced process failures. They can leave your system inconsistent. They processed a payment but didn’t update the order. Or, they sent an email but didn’t update the database. Temporal solve these problems. It provides durable execution, automatic retries, and state management.
With Spring Boot, Temporal gives you tools for complex processes. It also keeps familiar Spring ecosystem you know. You get the best of both worlds. You get Spring’s dependency injection and configuration. You also get Temporal’s workflow management features.
Setting up your environment
Before we dive into the code, let set up our development environment. We’ll need two things: a Spring Boot application with Temporal dependencies and running Temporal server.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>me.vrnsky</groupId>
<artifactId>temporal-spring-boot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>temporal-spring-boot</name>
<description>temporal-spring-boot</description>
<properties>
<java.version>17</java.version>
<temporal.version>1.17.0</temporal.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.temporal</groupId>
<artifactId>temporal-sdk</artifactId>
<version>${temporal.version}</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
Next, we’ll need a running Temporal server. The easiest way to get started by using Docker:
version: '3.5'
services:
postgresql:
image: postgres:13
environment:
POSTGRES_USER: temporal
POSTGRES_PASSWORD: temporal
ports:
- "5432:5432"
networks:
- temporal-network
temporal:
image: temporalio/auto-setup:1.20.0
depends_on:
- postgresql
environment:
- DB=postgresql
- DB_PORT=5432
- POSTGRES_USER=temporal
- POSTGRES_PWD=temporal
- POSTGRES_SEEDS=postgresql
ports:
- "7233:7233"
networks:
- temporal-network
temporal-web:
image: temporalio/web:1.15.0
environment:
- TEMPORAL_GRPC_ENDPOINT=temporal:7233
- TEMPORAL_PERMIT_WRITE_API=true
ports:
- "8088:8088"
depends_on:
- temporal
networks:
- temporal-network
networks:
temporal-network:
driver: bridge
Understanding how Temporal works
Think of Temporal as a sophisticated task manager for your distributed processes. When your Spring Boot app want to start a workflow, it talks to Temporal via the WorkflowClient
. Temporal then manages the entire process. It ensures each step achieves success. It maintains your workflow’s state, even if systems crash or network fail.
Building a real — world example
Let’s build something practical — an order processing system. This is a common use case where lots of things can go wrong: payment might fail, inventory might be unavailable, or shipping services might be down. Here is how workflow will look:
Now, let’s put this workflow in code.
package me.vrnsky.temporalspringboot.workflow;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
@WorkflowInterface
public interface OrderProcessingWorkflow {
@WorkflowMethod
void processOrder(String orderId);
}
and
package me.vrnsky.temporalspringboot.activity;
import io.temporal.activity.ActivityInterface;
@ActivityInterface
public interface OrderActivity {
void validateOrder(String orderId);
void processPayment(String orderId);
void shipOrder(String orderId);
void sendConfirmation(String orderId);
}
And here’s the workflow impementation.
package me.vrnsky.temporalspringboot.workflow;
import io.temporal.activity.ActivityOptions;
import io.temporal.workflow.Workflow;
import me.vrnsky.temporalspringboot.activity.OrderActivity;
import java.time.Duration;
public class OrderProcessWorkflowImpl implements OrderProcessingWorkflow {
private final ActivityOptions options = ActivityOptions.newBuilder()
.setScheduleToCloseTimeout(Duration.ofMinutes(5))
.build();
private final OrderActivity orderActivity = Workflow.newActivityStub(OrderActivity.class, options);
@Override
public void processOrder(String orderId) {
try {
orderActivity.validateOrder(orderId);
orderActivity.processPayment(orderId);
orderActivity.shipOrder(orderId);
orderActivity.sendConfirmation(orderId);
} catch (Exception e) {
throw e;
}
}
}
We also need to put in place the implementation of the activity interface and configure Workflow client.
package me.vrnsky.temporalspringboot.activity;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class OrderActivityImpl implements OrderActivity {
@Override
public void validateOrder(String orderId) {
log.info("Validating order {}", orderId);
}
@Override
public void processPayment(String orderId) {
log.info("Processing payment {}", orderId);
}
@Override
public void shipOrder(String orderId) {
log.info("Shipping order {}", orderId);
}
@Override
public void sendConfirmation(String orderId) {
log.info("Sending confirmation {}", orderId);
}
}
package me.vrnsky.temporalspringboot.config;
import io.temporal.client.WorkflowClient;
import io.temporal.serviceclient.WorkflowServiceStubs;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TemporalConfig {
@Bean
public WorkflowClient workflowClient() {
WorkflowServiceStubs service = WorkflowServiceStubs.newLocalServiceStubs();
return WorkflowClient.newInstance(service);
}
}
At this point, we can add a REST controller and test our workflow through manual procedures.
package me.vrnsky.temporalspringboot.controller;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import lombok.RequiredArgsConstructor;
import me.vrnsky.temporalspringboot.workflow.OrderProcessingWorkflow;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class OrderController {
private final WorkflowClient workflowClient;
@PostMapping("/orders/{orderId}/process")
public ResponseEntity<String> processOrder(@PathVariable String orderId) {
OrderProcessingWorkflow workflow = workflowClient.newWorkflowStub(
OrderProcessingWorkflow.class,
WorkflowOptions.newBuilder()
.setTaskQueue("OrderProcessingQueue")
.setWorkflowId("Order-" + orderId)
.build());
WorkflowClient.start(workflow::processOrder, orderId);
return ResponseEntity.accepted().body("Order processing started");
}
}
Send a request with the following cURL command.
curl -X POST http://localhost:8080/orders/1/process
In response, you must see Order processing started
And at the web UI, you should see that the workflow is running.
The workflow has been running since there were no workers to poll and execute the workflow. Let’s add a new worker.
package me.vrnsky.temporalspringboot.worker;
import io.temporal.client.WorkflowClient;
import io.temporal.worker.Worker;
import io.temporal.worker.WorkerFactory;
import me.vrnsky.temporalspringboot.activity.OrderActivityImpl;
import me.vrnsky.temporalspringboot.workflow.OrderProcessWorkflowImpl;
import org.springframework.stereotype.Component;
@Component
public class OrderWorker {
private final Worker worker;
public OrderWorker(WorkflowClient workflowClient) {
WorkerFactory factory = WorkerFactory.newInstance(workflowClient);
this.worker = factory.newWorker("OrderProcessingQueue");
worker.registerWorkflowImplementationTypes(OrderProcessWorkflowImpl.class);
worker.registerActivitiesImplementations(new OrderActivityImpl());
factory.start();
}
}
Now, if you run your Spring Boot application again, you should see that the workflow has completed.
The beatury of this implementation is that Temporal handles all the complex problem of distributed systems for you. If any activity fails, Temporal will retry it per your settings. If your application crashes in the middle of processing Temporal will pickup where it left off when you restart.
Handle failure
Let’s start by updating our workflow to include compensation logic:
package me.vrnsky.temporalspringboot.workflow;
import io.temporal.workflow.QueryMethod;
import io.temporal.workflow.SignalMethod;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
import me.vrnsky.temporalspringboot.model.OrderResult;
import me.vrnsky.temporalspringboot.model.OrderStatus;
@WorkflowInterface
public interface OrderProcessingWorkflow {
@WorkflowMethod
OrderResult processOrder(String orderId);
@QueryMethod
OrderStatus getOrderStatus();
@SignalMethod
void cancelOrder(String reason);
}
package me.vrnsky.temporalspringboot.model;
public record OrderResult(
String orderId,
OrderStatus status,
String message
) {
}
package me.vrnsky.temporalspringboot.model;
public enum OrderStatus {
CREATED,
VALIDATED,
PAYMENT_PROCESSED,
SHIPPED,
COMPLETED,
FAILED,
COMPENSATING,
CANCELLED
}
package me.vrnsky.temporalspringboot.model;
public enum AlertLevel {
INFO,
WARNING,
ERROR,
CRITICAL
}
package me.vrnsky.temporalspringboot.activity;
import io.temporal.activity.ActivityInterface;
import me.vrnsky.temporalspringboot.model.AlertLevel;
import me.vrnsky.temporalspringboot.model.OrderStatus;
@ActivityInterface
public interface OrderActivity {
void validateOrder(String orderId);
void processPayment(String orderId);
void shipOrder(String orderId);
void sendConfirmation(String orderId);
void refundPayment(String orderId);
void cancelShipment(String orderId);
void sendCancellationNotification(String orderId, String reason);
void logOrderEvent(String orderId, String event, OrderStatus status);
void sendAlert(String orderId, AlertLevel level, String message);
}
Let’s override the methods that we recently added.
package me.vrnsky.temporalspringboot.activity;
import lombok.extern.log4j.Log4j2;
import lombok.extern.slf4j.Slf4j;
import me.vrnsky.temporalspringboot.model.AlertLevel;
import me.vrnsky.temporalspringboot.model.OrderStatus;
@Log4j2
public class OrderActivityImpl implements OrderActivity {
@Override
public void validateOrder(String orderId) {
log.info("Validating order {}", orderId);
}
@Override
public void processPayment(String orderId) {
log.info("Processing payment {}", orderId);
}
@Override
public void shipOrder(String orderId) {
log.info("Shipping order {}", orderId);
}
@Override
public void sendConfirmation(String orderId) {
log.info("Sending confirmation {}", orderId);
}
@Override
public void refundPayment(String orderId) {
log.info("Refunding payment {}", orderId);
}
@Override
public void cancelShipment(String orderId) {
log.info("Cancelling shipment {}", orderId);
}
@Override
public void sendCancellationNotification(String orderId, String reason) {
log.info("Cancelling notification {}", orderId);
}
@Override
public void logOrderEvent(String orderId, String event, OrderStatus status) {
log.info("Order {} event {}", orderId, event);
}
@Override
public void sendAlert(String orderId, AlertLevel level, String message) {
log.info("Sending alert {}", orderId);
}
}
Now, let’s put in place the enhanced workflow with compensation logic and monitoring.
package me.vrnsky.temporalspringboot.workflow;
import io.temporal.activity.ActivityOptions;
import io.temporal.common.RetryOptions;
import io.temporal.workflow.Workflow;
import me.vrnsky.temporalspringboot.activity.OrderActivity;
import me.vrnsky.temporalspringboot.model.AlertLevel;
import me.vrnsky.temporalspringboot.model.OrderResult;
import me.vrnsky.temporalspringboot.model.OrderStatus;
import java.time.Duration;
public class OrderProcessWorkflowImpl implements OrderProcessingWorkflow {
private OrderStatus currentStatus = OrderStatus.CREATED;
private boolean paymentProcessed = false;
private boolean shipmentCreated = false;
private final ActivityOptions options = ActivityOptions.newBuilder()
.setScheduleToCloseTimeout(Duration.ofMinutes(5))
.setRetryOptions(RetryOptions.newBuilder()
.setMaximumAttempts(3)
.setInitialInterval(Duration.ofSeconds(1))
.setMaximumInterval(Duration.ofSeconds(10))
.build())
.build();
private final OrderActivity orderActivity = Workflow.newActivityStub(OrderActivity.class, options);
@Override
public OrderResult processOrder(String orderId) {
try {
try {
orderActivity.validateOrder(orderId);
currentStatus = OrderStatus.VALIDATED;
orderActivity.logOrderEvent(orderId, "Order validated", currentStatus);
} catch (Exception e) {
handleFailure(orderId, "Order validation failed", e);
return new OrderResult(orderId, currentStatus, "Validation failed: " + e.getMessage());
}
// Process Payment
try {
orderActivity.processPayment(orderId);
paymentProcessed = true;
currentStatus = OrderStatus.PAYMENT_PROCESSED;
orderActivity.logOrderEvent(orderId, "Payment processed", currentStatus);
} catch (Exception e) {
handleFailure(orderId, "Payment processing failed", e);
return new OrderResult(orderId, currentStatus, "Payment failed: " + e.getMessage());
}
// Ship Order
try {
orderActivity.shipOrder(orderId);
shipmentCreated = true;
currentStatus = OrderStatus.SHIPPED;
orderActivity.logOrderEvent(orderId, "Order shipped", currentStatus);
} catch (Exception e) {
handleFailure(orderId, "Shipping failed", e);
return new OrderResult(orderId, currentStatus, "Shipping failed: " + e.getMessage());
}
// Send Confirmation
try {
orderActivity.sendConfirmation(orderId);
currentStatus = OrderStatus.COMPLETED;
orderActivity.logOrderEvent(orderId, "Order completed", currentStatus);
return new OrderResult(orderId, currentStatus, "Order completed successfully");
} catch (Exception e) {
handleFailure(orderId, "Confirmation failed", e);
return new OrderResult(orderId, currentStatus, "Confirmation failed: " + e.getMessage());
}
} catch (Exception e) {
handleFailure(orderId, "Unexpected error", e);
return new OrderResult(orderId, currentStatus, "Unexpected error: " + e.getMessage());
}
}
@Override
public OrderStatus getOrderStatus() {
return currentStatus;
}
@Override
public void cancelOrder(String reason) {
if (currentStatus == OrderStatus.COMPLETED || currentStatus == OrderStatus.CANCELLED) {
throw new IllegalStateException("Cannot cancel completed or already cancelled order");
}
compensate(Workflow.getInfo().getWorkflowId(), reason);
}
private void handleFailure(String orderId, String message, Exception e) {
currentStatus = OrderStatus.COMPENSATING;
orderActivity.logOrderEvent(orderId, message, currentStatus);
orderActivity.sendAlert(orderId, AlertLevel.ERROR, message + ":" + e.getMessage());
}
private void compensate(String orderId, String reason) {
try {
if (shipmentCreated) {
orderActivity.cancelShipment(orderId);
orderActivity.logOrderEvent(orderId, "Shipment cancelled", currentStatus);
}
if (paymentProcessed) {
orderActivity.refundPayment(orderId);
orderActivity.logOrderEvent(orderId, "Payment refunded", currentStatus);
}
orderActivity.sendCancellationNotification(orderId, reason);
currentStatus = OrderStatus.CANCELLED;
orderActivity.logOrderEvent(orderId, "Order cancelled", currentStatus);
} catch (Exception e) {
currentStatus = OrderStatus.FAILED;
orderActivity.sendAlert(orderId, AlertLevel.CRITICAL, "Compensation failed: " + e.getMessage());
}
}
}
Conclusion
Spring Boot and Temporal together provide a strong base. It is for building distributed apps that can face real — world challenges. The example we’ve build demonstrates key benefits:
- Automatic retry mechanisms and state persistence.
- Compensation logic for managing failures in a smooth manner.
- Clear separation between workflow and activities.
When implementing complex business process, choose tools carefully. Temporal’s workflow orchestration complements Spring Boot’s dependency injection and configuration features. This lets developers focus on business logic. Temporal will handle the complexities of distributed system’s reliability.
Consider using this approach when building application that need:
- Long — running business processes.
- Complex failure handling.
- Transaction consistency across services.
- Detailed process visibility and monitoring.