adding WIP resiliency and compensation & combined-patterns

Signed-off-by: salaboy <Salaboy@gmail.com>
This commit is contained in:
salaboy 2025-07-25 13:07:50 +01:00
parent f31aaccd1d
commit fa3d4f1d55
56 changed files with 2140 additions and 0 deletions

View File

@ -0,0 +1,141 @@
# Combined Workflow Patterns
This tutorial demonstrates how several workflow patterns can be combined in a single, more realistic, workflow. Some of the workflow activities are using other Dapr APIs, such as state management, service invocation, and Pub/Sub.
## Inspect the code
The demo consist of two applications:
- `WorkflowApp` is the main application that orchestrates an order process in the `OrderWorkflow`.
- `ShippingApp` is a supporting service that is being called by the `OrderWorkflow`.
The `OrderWorkflow` combines task chaining, fan-out/fan-in, and waiting for external event patterns. The workflow contains a number of activities for order processing including checking inventory, register shipment, process payment and more with a final order status being returned with the results of the order. It uses compensating logic in case the shipment fails to get registered and the customer needs to be reimbursed for the payment.
```mermaid
graph LR
SW((Start
Workflow))
EW((End
Workflow))
subgraph OrderWorkflow
direction LR
CHKI[Check inventory]
CHKD[Check shipping
destination]
IF1{Success?}
PAY[Process
payment]
UPD[Update
inventory]
REG[Register
shipment]
WAIT[Wait for
confirmation]
IF2{Success?}
RI[Reimburse
customer]
end
subgraph Shipping
direction LR
REG2[registerShipment]
CHKD2[checkDestination]
end
SW --> CHKI
SW --> CHKD <--> CHKD2
CHKI --> IF1
CHKD --> IF1
IF1 --> PAY
PAY --> UPD
UPD --> REG -.->|pub/sub| REG2
REG2 -.->|pub/sub| WAIT
REG --> WAIT
WAIT --> IF2
IF2 -->|Yes| EW
IF2 -->|No| RI
RI --> EW
```
## Run the tutorial
1. Use a terminal to navigate to the `tutorials/workflow/csharp/combined-patterns` folder.
2. Build the projects using the .NET CLI.
```bash
dotnet build ./WorkflowApp/
dotnet build ./ShippingApp/
```
3. Use the Dapr CLI to run the Dapr Multi-App run file. This starts both applications `order-workflow` and `shipping` with the Dapr components in the [resources](./resources) folder.
<!-- STEP
name: Run multi app run template
expected_stdout_lines:
- 'Started Dapr with app id "order-workflow"'
- 'Started Dapr with app id "shipping"'
expected_stderr_lines:
working_dir: .
output_match_mode: substring
background: true
sleep: 15
timeout_seconds: 30
-->
```bash
dapr run -f .
```
<!-- END_STEP -->
4. Use the POST request in the [`order-workflow.http`](./order-workflow.http) file to start the workflow, or use this cURL command:
```bash
curl -i --request POST \
--url http://localhost:5260/start \
--header 'content-type: application/json' \
--data '{"id": "b0d38481-5547-411e-ae7b-255761cce17a","orderItem" : {"productId": "RBD001","productName": "Rubber Duck","quantity": 10,"totalPrice": 15.00},"customerInfo" : {"id" : "Customer1","country" : "The Netherlands"}}'
```
The input for the workflow is an `Order` object:
```json
{
"id": "{{orderId}}",
"orderItem" : {
"productId": "RBD001",
"productName": "Rubber Duck",
"quantity": 10,
"totalPrice": 15.00
},
"customerInfo" : {
"id" : "Customer1",
"country" : "The Netherlands"
}
}
```
The app logs should come from both services executing all activities as follows:
```text
== APP - order-workflow == CheckInventory: Received input: OrderItem { ProductId = RBD001, ProductName = Rubber Duck, Quantity = 10, TotalPrice = 15.00 }.
== APP - order-workflow == CheckShippingDestination: Received input: Order { Id = 06d49c54-bf65-427b-90d1-730987e96e61, OrderItem = OrderItem { ProductId = RBD001, ProductName = Rubber Duck, Quantity = 10, TotalPrice = 15.00 }, CustomerInfo = CustomerInfo { Id = Customer1, Country = The Netherlands } }.
== APP - shipping == checkDestination: Received input: Order { Id = 06d49c54-bf65-427b-90d1-730987e96e61, OrderItem = OrderItem { ProductId = RBD001, ProductName = Rubber Duck, Quantity = 10, TotalPrice = 15.00 }, CustomerInfo = CustomerInfo { Id = Customer1, Country = The Netherlands } }.
== APP - order-workflow == ProcessPayment: Received input: Order { Id = 06d49c54-bf65-427b-90d1-730987e96e61, OrderItem = OrderItem { ProductId = RBD001, ProductName = Rubber Duck, Quantity = 10, TotalPrice = 15.00 }, CustomerInfo = CustomerInfo { Id = Customer1, Country = The Netherlands } }.
== APP - order-workflow == UpdateInventory: Received input: OrderItem { ProductId = RBD001, ProductName = Rubber Duck, Quantity = 10, TotalPrice = 15.00 }.
== APP - order-workflow == RegisterShipment: Received input: Order { Id = 06d49c54-bf65-427b-90d1-730987e96e61, OrderItem = OrderItem { ProductId = RBD001, ProductName = Rubber Duck, Quantity = 10, TotalPrice = 15.00 }, CustomerInfo = CustomerInfo { Id = Customer1, Country = The Netherlands } }.
== APP - shipping == registerShipment: Received input: Order { Id = 06d49c54-bf65-427b-90d1-730987e96e61, OrderItem = OrderItem { ProductId = RBD001, ProductName = Rubber Duck, Quantity = 10, TotalPrice = 15.00 }, CustomerInfo = CustomerInfo { Id = Customer1, Country = The Netherlands } }.
== APP - order-workflow == Shipment registered for order ShipmentRegistrationStatus { OrderId = 06d49c54-bf65-427b-90d1-730987e96e61, IsSuccess = True, Message = }
```
5. Use the GET request in the [`order-workflow.http`](./order-workflow.http) file to get the status of the workflow, or use this cURL command:
```bash
curl --request GET --url http://localhost:3560/v1.0/workflows/dapr/06d49c54-bf65-427b-90d1-730987e96e61
```
The expected serialized output of the workflow is:
```txt
{\"IsSuccess\":true,\"Message\":\"Order 06d49c54-bf65-427b-90d1-730987e96e61 processed successfully.\"}"
```
*The Order ID is generated when making the request and is different each time.*
6. Stop the Dapr Multi-App run process by pressing `Ctrl+C`.

View File

@ -0,0 +1,25 @@
@apphost=http://localhost:8080
### Start the OrderWorkflow workflow
# @name startWorkflowRequest
@orderId={{$guid}}
POST {{ apphost }}/start
Content-Type: application/json
{
"id": "{{orderId}}",
"orderItem" : {
"productId": "RBD001",
"productName": "Rubber Duck",
"quantity": 10,
"totalPrice": 15.00
},
"customerInfo" : {
"id" : "Customer1",
"country" : "The Netherlands"
}
}
### Get the workflow status
@instanceId={{startWorkflowRequest.response.headers.Location}}
GET {{ apphost }}/status/{{ instanceId }}

View File

@ -0,0 +1,59 @@
<?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.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<artifactId>shipping-app</artifactId>
<name>shipping-app</name>
<description>Shipping App Example</description>
<properties>
<dapr.spring.version>0.15.0-rc-7</dapr.spring.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-boot-starter</artifactId>
<version>${dapr.spring.version}</version>
</dependency>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-boot-starter-test</artifactId>
<version>${dapr.spring.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,27 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ExternalEventsApplication {
public static void main(String[] args) {
SpringApplication.run(ExternalEventsApplication.class, args);
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class ExternalEventsConfiguration {
@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder().build();
}
@Bean
public ObjectMapper mapper() {
return new ObjectMapper();
}
}

View File

@ -0,0 +1,94 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.spring.workflows.config.EnableDaprWorkflows;
import io.dapr.springboot.examples.external.ApprovalStatus;
import io.dapr.springboot.examples.external.ExternalEventsWorkflow;
import io.dapr.springboot.examples.external.Order;
import io.dapr.workflows.client.DaprWorkflowClient;
import io.dapr.workflows.client.WorkflowInstanceStatus;
import io.dapr.workflows.client.WorkflowRuntimeStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.TimeoutException;
@RestController
/*
* Dapr Workflows and Activities need to be registered in the DI container otherwise
* the Dapr runtime does not know this application contains workflows and activities.
* In Spring Boot Applications, the `@EnableDaprWorkflow` annotation takes care of registering
* all workflows and activities components found in the classpath.
*/
@EnableDaprWorkflows
public class ExternalEventsRestController {
private final Logger logger = LoggerFactory.getLogger(ExternalEventsRestController.class);
@Autowired
private DaprWorkflowClient daprWorkflowClient;
private String instanceId;
/**
* The DaprWorkflowClient is the API to manage workflows.
* Here it is used to schedule a new workflow instance.
*
* @return the instanceId of the ExternalEventsWorkflow execution
*/
@PostMapping("start")
public String basic(@RequestBody Order order) throws TimeoutException {
logger.info("Received order: {}", order);
instanceId = daprWorkflowClient.scheduleNewWorkflow(ExternalEventsWorkflow.class, order);
return instanceId;
}
/**
* Obtain the status of the workflow
*
* @return the status of the ExternalEventsWorkflow instance
*/
@GetMapping("status")
public String status() throws TimeoutException {
if(instanceId != null && !instanceId.isEmpty()) {
WorkflowInstanceStatus instanceState = daprWorkflowClient.getInstanceState(instanceId, true);
if (instanceState != null) {
if (instanceState.getRuntimeStatus().equals(WorkflowRuntimeStatus.COMPLETED)) {
var output = instanceState.readOutputAs(String.class);
if (output != null && !output.isEmpty()) {
return "Workflow Instance (" + instanceId + ") Status: " + instanceState.getRuntimeStatus().name() + "\n"
+ "Output: " + output;
}
}
return "Workflow Instance (" + instanceId + ") Status: " + instanceState.getRuntimeStatus().name();
}
}
return "N/A";
}
/**
* Raise a workflow event
*/
@PostMapping("event")
public void raiseEvent(@RequestBody ApprovalStatus approvalStatus) {
daprWorkflowClient.raiseEvent(instanceId, "approval-event", approvalStatus);
}
}

View File

@ -0,0 +1,3 @@
package io.dapr.springboot.examples.external;
public record ApprovalStatus(String orderId, boolean isApproved){}

View File

@ -0,0 +1,59 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.external;
import io.dapr.durabletask.TaskCanceledException;
import io.dapr.springboot.examples.external.activities.ProcessOrderActivity;
import io.dapr.springboot.examples.external.activities.SendNotificationActivity;
import io.dapr.workflows.Workflow;
import io.dapr.workflows.WorkflowStub;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class ExternalEventsWorkflow implements Workflow {
@Override
public WorkflowStub create() {
return ctx -> {
ctx.getLogger().info("Starting Workflow: {}", ctx.getName());
var order = ctx.getInput(Order.class);
var approvalStatus = new ApprovalStatus(order.id(), true);
if(order.totalPrice() > 250){
try{
approvalStatus = ctx.waitForExternalEvent("approval-event", Duration.ofSeconds(12), ApprovalStatus.class).await();
} catch (TaskCanceledException tce){
var notification = "Approval request for order " + order.id() + " timed out.";
ctx.callActivity(SendNotificationActivity.class.getName(), notification).await();
ctx.complete(notification);
}
}
if (approvalStatus.isApproved()){
ctx.callActivity(ProcessOrderActivity.class.getName(), order).await();
}
var notification = approvalStatus.isApproved() ?
"Order " + order.id() + " has been approved." :
"Order " + order.id() +" has been rejected.";
ctx.callActivity(SendNotificationActivity.class.getName(), notification).await();
ctx.complete(notification);
};
}
}

View File

@ -0,0 +1,3 @@
package io.dapr.springboot.examples.external;
public record Order(String id, String description, int quantity, double totalPrice){}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.external.activities;
import io.dapr.springboot.examples.external.Order;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/*
*/
@Component
public class ProcessOrderActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(ProcessOrderActivity.class);
var order = ctx.getInput(Order.class);
// Imagine the order being processed by another system
logger.info("{} : Processed Order: {}", ctx.getName(), order.id());
return true;
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.external.activities;
import io.dapr.springboot.examples.external.Order;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class RequestApprovalActivity implements WorkflowActivity {
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(RequestApprovalActivity.class);
var order = ctx.getInput(Order.class);
logger.info("{} : Request Approval for Order: {}", ctx.getName(), order.id());
return true;
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.external.activities;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/*
*/
@Component
public class SendNotificationActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(SendNotificationActivity.class);
var message = ctx.getInput(String.class);
// Imagine a notification being sent to the user
logger.info("{} : Sending Notification: {}", ctx.getName(), message);
return true;
}
}

View File

@ -0,0 +1 @@
spring.application.name=external-system-interactions

View File

@ -0,0 +1,43 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.testcontainers.Component;
import io.dapr.testcontainers.DaprContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import java.util.Collections;
import static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG;
@TestConfiguration(proxyBeanMethods = false)
public class DaprTestContainersConfig {
@Bean
@ServiceConnection
public DaprContainer daprContainer() {
return new DaprContainer(DAPR_RUNTIME_IMAGE_TAG)
.withAppName("external-system-interactions")
.withComponent(new Component("kvstore", "state.in-memory", "v1", Collections.singletonMap("actorStateStore", String.valueOf(true))))
.withAppPort(8080)
.withAppHealthCheckPath("/actuator/health")
.withAppChannelAddress("host.testcontainers.internal");
}
}

View File

@ -0,0 +1,120 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.client.DaprClient;
import io.dapr.springboot.DaprAutoConfiguration;
import io.dapr.springboot.examples.external.ApprovalStatus;
import io.dapr.springboot.examples.external.Order;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static io.dapr.springboot.examples.StringMatchesUUIDPattern.matchesThePatternOfAUUID;
import static io.restassured.RestAssured.given;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(classes = {TestExternalEventsApplication.class, DaprTestContainersConfig.class,
DaprAutoConfiguration.class, },
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class ExternalEventsAppTests {
@Autowired
private DaprClient daprClient;
@BeforeEach
void setUp() {
RestAssured.baseURI = "http://localhost:" + 8080;
org.testcontainers.Testcontainers.exposeHostPorts(8080);
}
@Test
void testExternalEventsWorkflow() throws InterruptedException {
var order = new Order("123", "Rubber ducks", 100, 500);
given().contentType(ContentType.JSON)
.body(order)
.when()
.post("/start")
.then()
.statusCode(200).body(matchesThePatternOfAUUID());
String status = given().contentType(ContentType.JSON)
.when()
.get("/status")
.then()
.statusCode(200).extract().asString();
assertTrue(status.contains("RUNNING"));
ApprovalStatus approvalStatus = new ApprovalStatus("123", true);
given().contentType(ContentType.JSON)
.body(approvalStatus)
.when()
.post("/event")
.then()
.statusCode(200);
// Wait for the workflow instance to complete
await().atMost(Duration.ofSeconds(2))
.pollDelay(500, TimeUnit.MILLISECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.until(() -> {
var completedStatus = given().contentType(ContentType.JSON)
.when()
.get("/status")
.then()
.statusCode(200).extract().asString();
assertTrue(completedStatus.contains("COMPLETED"));
assertTrue(completedStatus.contains("has been approved"));
return true;
});
}
}
class StringMatchesUUIDPattern extends TypeSafeMatcher<String> {
private static final String UUID_REGEX = "[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}";
@Override
protected boolean matchesSafely(String s) {
return s.matches(UUID_REGEX);
}
@Override
public void describeTo(Description description) {
description.appendText("a string matching the pattern of a UUID");
}
public static Matcher<String> matchesThePatternOfAUUID() {
return new StringMatchesUUIDPattern();
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TestExternalEventsApplication {
public static void main(String[] args) {
SpringApplication.from(ExternalEventsApplication::main)
.with(DaprTestContainersConfig.class)
.run(args);
org.testcontainers.Testcontainers.exposeHostPorts(8080);
}
}

View File

@ -0,0 +1 @@
spring.application.name=external-system-interactions

View File

@ -0,0 +1,59 @@
<?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.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<artifactId>workflow-app</artifactId>
<name>workflow-app</name>
<description>Workflow App Example</description>
<properties>
<dapr.spring.version>0.15.0-rc-7</dapr.spring.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-boot-starter</artifactId>
<version>${dapr.spring.version}</version>
</dependency>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-boot-starter-test</artifactId>
<version>${dapr.spring.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,27 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class WorkflowAppApplication {
public static void main(String[] args) {
SpringApplication.run(WorkflowAppApplication.class, args);
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class WorkflowAppConfiguration {
@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder().build();
}
@Bean
public ObjectMapper mapper() {
return new ObjectMapper();
}
}

View File

@ -0,0 +1,94 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.spring.workflows.config.EnableDaprWorkflows;
import io.dapr.springboot.examples.workflowapp.OrderStatus;
import io.dapr.springboot.examples.workflowapp.OrderWorkflow;
import io.dapr.springboot.examples.workflowapp.Order;
import io.dapr.workflows.client.DaprWorkflowClient;
import io.dapr.workflows.client.WorkflowInstanceStatus;
import io.dapr.workflows.client.WorkflowRuntimeStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.TimeoutException;
@RestController
/*
* Dapr Workflows and Activities need to be registered in the DI container otherwise
* the Dapr runtime does not know this application contains workflows and activities.
* In Spring Boot Applications, the `@EnableDaprWorkflow` annotation takes care of registering
* all workflows and activities components found in the classpath.
*/
@EnableDaprWorkflows
public class WorkflowAppRestController {
private final Logger logger = LoggerFactory.getLogger(WorkflowAppRestController.class);
@Autowired
private DaprWorkflowClient daprWorkflowClient;
private String instanceId;
/**
* The DaprWorkflowClient is the API to manage workflows.
* Here it is used to schedule a new workflow instance.
*
* @return the instanceId of the ExternalEventsWorkflow execution
*/
@PostMapping("start")
public String basic(@RequestBody Order order) throws TimeoutException {
logger.info("Received order: {}", order);
instanceId = daprWorkflowClient.scheduleNewWorkflow(OrderWorkflow.class, order);
return instanceId;
}
/**
* Obtain the status of the workflow
*
* @return the status of the ExternalEventsWorkflow instance
*/
@GetMapping("status")
public String status() throws TimeoutException {
if(instanceId != null && !instanceId.isEmpty()) {
WorkflowInstanceStatus instanceState = daprWorkflowClient.getInstanceState(instanceId, true);
if (instanceState != null) {
if (instanceState.getRuntimeStatus().equals(WorkflowRuntimeStatus.COMPLETED)) {
var output = instanceState.readOutputAs(String.class);
if (output != null && !output.isEmpty()) {
return "Workflow Instance (" + instanceId + ") Status: " + instanceState.getRuntimeStatus().name() + "\n"
+ "Output: " + output;
}
}
return "Workflow Instance (" + instanceId + ") Status: " + instanceState.getRuntimeStatus().name();
}
}
return "N/A";
}
/**
* Raise a workflow event
*/
@PostMapping("event")
public void raiseEvent(@RequestBody OrderStatus approvalStatus) {
daprWorkflowClient.raiseEvent(instanceId, "approval-event", approvalStatus);
}
}

View File

@ -0,0 +1,3 @@
package io.dapr.springboot.examples.workflowapp;
public record ActivityResult(boolean isSuccess, String message) { }

View File

@ -0,0 +1,3 @@
package io.dapr.springboot.examples.workflowapp;
public record CustomerInfo(String id, String country) {}

View File

@ -0,0 +1,3 @@
package io.dapr.springboot.examples.workflowapp;
public record Order(String id, OrderItem orderItem, CustomerInfo customerInfo){}

View File

@ -0,0 +1,5 @@
package io.dapr.springboot.examples.workflowapp;
import java.math.BigDecimal;
public record OrderItem(String productId, String productName, int quantity, BigDecimal totalPrice) {}

View File

@ -0,0 +1,3 @@
package io.dapr.springboot.examples.workflowapp;
public record OrderStatus(boolean isSuccess, String message){}

View File

@ -0,0 +1,88 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.workflowapp;
import io.dapr.durabletask.Task;
import io.dapr.durabletask.TaskCanceledException;
import io.dapr.springboot.examples.workflowapp.activities.*;
import io.dapr.workflows.Workflow;
import io.dapr.workflows.WorkflowStub;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
@Component
public class OrderWorkflow implements Workflow {
@Override
public WorkflowStub create() {
return ctx -> {
var order = ctx.getInput(Order.class);
// First two independent activities are called in parallel (fan-out/fan-in pattern):
var inventoryTask = ctx.callActivity(CheckInventoryActivity.class.getName(), order.orderItem(), ActivityResult.class);
var shippingDestinationTask = ctx.callActivity(
CheckShippingDestinationActivity.class.getName(), order, ActivityResult.class);
List<Task<ActivityResult>> tasks = List.of(inventoryTask, shippingDestinationTask);
var tasksResult = ctx.allOf(tasks).await();
if( tasksResult.stream().anyMatch(r -> !r.isSuccess())){
var message = "Order processing failed. Reason: " + tasksResult.get(0).message();
ctx.complete(new OrderStatus(false, message);
}
// Two activities are called in sequence (chaining pattern) where the UpdateInventory
// activity is dependent on the result of the ProcessPayment activity:
var paymentResult = ctx.callActivity(ProcessPaymentActivity.class.getName(), order.orderItem(), ActivityResult.class).await();
if(paymentResult.isSuccess()){
var inventoryResult = ctx.callActivity(ProcessPaymentActivity.class.getName(), order.orderItem(), UpdateInventoryResult.class).await();
}
ShipmentRegistrationStatus shipmentRegistrationStatus = null;
try
{
// The RegisterShipment activity is using pub/sub messaging to communicate with the ShippingApp.
ctx.callActivity(RegisterShipmentActivity.class.getName(), order.orderItem(), RegisterShipmentResult.class).await();
// The ShippingApp will also use pub/sub messaging back to the WorkflowApp and raise an event.
// The workflow will wait for the event to be received or until the timeout occurs.
shipmentRegistrationStatus = ctx.waitForExternalEvent("", Duration.ofSeconds(300), ShipmentRegistrationStatus.class).await();
} catch (TaskCanceledException tce){
// Timeout occurred, the shipment-registered-event was not received.
var message = "ShipmentRegistrationStatus for " + order.id() + " timed out.";
ctx.complete(new OrderStatus(false, message));
}
assert shipmentRegistrationStatus != null;
if (!shipmentRegistrationStatus.isSuccess())
{
// This is the compensation step in case the shipment registration event was not successful.
ctx.callActivity(ReimburseCustomerActivity.class.getName(), order);
var message = "ShipmentRegistrationStatus for {order.Id} failed. Customer is reimbursed.";
ctx.complete(new OrderStatus(false, message));
}
ctx.complete(new OrderStatus(true, "Order " + order.id() + " processed successfully."));
};
}
}

View File

@ -0,0 +1,3 @@
package io.dapr.springboot.examples.workflowapp;
public record ProductInventory(String productId, int quantity) { }

View File

@ -0,0 +1,4 @@
package io.dapr.springboot.examples.workflowapp;
public record RegisterShipmentResult() {
}

View File

@ -0,0 +1,4 @@
package io.dapr.springboot.examples.workflowapp;
public record ShipmentRegistrationStatus(String orderId, boolean isSuccess, String message) { }

View File

@ -0,0 +1,3 @@
package io.dapr.springboot.examples.workflowapp;
public record UpdateInventoryResult(boolean isSuccess, String message) { }

View File

@ -0,0 +1,51 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.workflowapp.activities;
import io.dapr.client.DaprClient;
import io.dapr.client.domain.State;
import io.dapr.springboot.examples.workflowapp.ActivityResult;
import io.dapr.springboot.examples.workflowapp.Order;
import io.dapr.springboot.examples.workflowapp.OrderItem;
import io.dapr.springboot.examples.workflowapp.ProductInventory;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class CheckInventoryActivity implements WorkflowActivity {
@Autowired
private DaprClient daprClient;
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(CheckInventoryActivity.class);
var orderItem = ctx.getInput(OrderItem.class);
logger.info("{} : Received input: {}", ctx.getName(), orderItem);
var productInventory = daprClient.getState("inventory", orderItem.productId(), ProductInventory.class).block();
if (productInventory == null)
{
return new ActivityResult(false, "");
}
var isAvailable = productInventory.getValue().quantity() >= orderItem.quantity();
return new ActivityResult(isAvailable, "");
}
}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.workflowapp.activities;
import io.dapr.springboot.examples.workflowapp.Order;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/*
*/
@Component
public class CheckShippingDestinationActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(CheckShippingDestinationActivity.class);
var order = ctx.getInput(Order.class);
// Imagine the order being processed by another system
logger.info("{} : Processed Order: {}", ctx.getName(), order.id());
return true;
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.workflowapp.activities;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/*
*/
@Component
public class ProcessPaymentActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(ProcessPaymentActivity.class);
var message = ctx.getInput(String.class);
// Imagine a notification being sent to the user
logger.info("{} : Sending Notification: {}", ctx.getName(), message);
return true;
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.workflowapp.activities;
import io.dapr.springboot.examples.workflowapp.Order;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class RegisterShipmentActivity implements WorkflowActivity {
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(RegisterShipmentActivity.class);
var order = ctx.getInput(Order.class);
logger.info("{} : Request Approval for Order: {}", ctx.getName(), order.id());
return true;
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.workflowapp.activities;
import io.dapr.springboot.examples.workflowapp.Order;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class ReimburseCustomerActivity implements WorkflowActivity {
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(ReimburseCustomerActivity.class);
var order = ctx.getInput(Order.class);
logger.info("{} : Request Approval for Order: {}", ctx.getName(), order.id());
return true;
}
}

View File

@ -0,0 +1 @@
spring.application.name=workflow-app

View File

@ -0,0 +1,43 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.testcontainers.Component;
import io.dapr.testcontainers.DaprContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import java.util.Collections;
import static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG;
@TestConfiguration(proxyBeanMethods = false)
public class DaprTestContainersConfig {
@Bean
@ServiceConnection
public DaprContainer daprContainer() {
return new DaprContainer(DAPR_RUNTIME_IMAGE_TAG)
.withAppName("external-system-interactions")
.withComponent(new Component("kvstore", "state.in-memory", "v1", Collections.singletonMap("actorStateStore", String.valueOf(true))))
.withAppPort(8080)
.withAppHealthCheckPath("/actuator/health")
.withAppChannelAddress("host.testcontainers.internal");
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TestWorkflowAppApplication {
public static void main(String[] args) {
SpringApplication.from(WorkflowAppApplication::main)
.with(DaprTestContainersConfig.class)
.run(args);
org.testcontainers.Testcontainers.exposeHostPorts(8080);
}
}

View File

@ -0,0 +1,120 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.client.DaprClient;
import io.dapr.springboot.DaprAutoConfiguration;
import io.dapr.springboot.examples.workflowapp.OrderStatus;
import io.dapr.springboot.examples.workflowapp.Order;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static io.dapr.springboot.examples.StringMatchesUUIDPattern.matchesThePatternOfAUUID;
import static io.restassured.RestAssured.given;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(classes = {TestWorkflowAppApplication.class, DaprTestContainersConfig.class,
DaprAutoConfiguration.class, },
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class WorkflowAppTests {
@Autowired
private DaprClient daprClient;
@BeforeEach
void setUp() {
RestAssured.baseURI = "http://localhost:" + 8080;
org.testcontainers.Testcontainers.exposeHostPorts(8080);
}
@Test
void testExternalEventsWorkflow() throws InterruptedException {
var order = new Order("123", "Rubber ducks", 100, 500);
given().contentType(ContentType.JSON)
.body(order)
.when()
.post("/start")
.then()
.statusCode(200).body(matchesThePatternOfAUUID());
String status = given().contentType(ContentType.JSON)
.when()
.get("/status")
.then()
.statusCode(200).extract().asString();
assertTrue(status.contains("RUNNING"));
OrderStatus approvalStatus = new OrderStatus("123", true);
given().contentType(ContentType.JSON)
.body(approvalStatus)
.when()
.post("/event")
.then()
.statusCode(200);
// Wait for the workflow instance to complete
await().atMost(Duration.ofSeconds(2))
.pollDelay(500, TimeUnit.MILLISECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.until(() -> {
var completedStatus = given().contentType(ContentType.JSON)
.when()
.get("/status")
.then()
.statusCode(200).extract().asString();
assertTrue(completedStatus.contains("COMPLETED"));
assertTrue(completedStatus.contains("has been approved"));
return true;
});
}
}
class StringMatchesUUIDPattern extends TypeSafeMatcher<String> {
private static final String UUID_REGEX = "[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}";
@Override
protected boolean matchesSafely(String s) {
return s.matches(UUID_REGEX);
}
@Override
public void describeTo(Description description) {
description.appendText("a string matching the pattern of a UUID");
}
public static Matcher<String> matchesThePatternOfAUUID() {
return new StringMatchesUUIDPattern();
}
}

View File

@ -0,0 +1 @@
spring.application.name=workflow-app

View File

@ -0,0 +1,95 @@
# Resiliency & Compensation
This tutorial demonstrates how to improve resiliency when activities are executed and how to include compensation actions when activities return an error. For more on workflow resiliency read the [Dapr docs](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-features-concepts/#retry-policies).
## Inspect the code
Open the `ResiliencyAndCompensationWorkflow.cs` file in the `tutorials/workflow/csharp/resiliency-and-compensation/ResiliencyAndCompensation` folder. This file contains the definition for the workflow. This workflow implements an activity retry policy on all the associated activities and compensating logic if an activity throws an exception.
```mermaid
graph LR
SW((Start
Workflow))
A1[MinusOne]
A2[Division]
EX{Division
Exception?}
A3[PlusOne]
EW((End
Workflow))
SW --> A1
A1 --> A2
A2 --> EX
EX -- yes --> A3
EX -- no --> EW
A3 --> EW
```
## Run the tutorial
1. Use a terminal to navigate to the `tutorials/workflow/csharp/resiliency-and-compensation` folder.
2. Build the project using the .NET CLI.
```bash
dotnet build ./ResiliencyAndCompensation/
```
3. Use the Dapr CLI to run the Dapr Multi-App run file
<!-- STEP
name: Run multi app run template
expected_stdout_lines:
- 'Started Dapr with app id "resiliency"'
expected_stderr_lines:
working_dir: .
output_match_mode: substring
background: true
sleep: 15
timeout_seconds: 30
-->
```bash
dapr run -f .
```
<!-- END_STEP -->
4. Use the POST request in the [`resiliency-compensation.http`](./resiliency-compensation.http) file to start the workflow with a workflow input value of `1`, or use this cURL command:
```bash
curl -i --request POST \
--url http://localhost:8080/start/1
```
When the workflow input is `1`, the `MinusOne` activity will subtract `1` resulting in a `0`. This value is passed to the `Division` activity, which will throw an error because the divisor is `0`. The `Division` activity will be retried three times but all will fail the same way as the divisor has not changed. Finally the compensation action `PlusOne` will be executed, increasing the value back to `1` before returning the result.
The app logs should output the following:
```txt
== APP - resiliency == MinusOne: Received input: 1.
== APP - resiliency == Division: Received divisor: 0.
== APP - resiliency == Division: Received divisor: 0.
== APP - resiliency == Division: Received divisor: 0.
== APP - resiliency == PlusOne: Received input: 0.
```
5. Use the GET request in the [`resiliency-compensation.http`](./resiliency-compensation.http) file to get the status of the workflow, or use this cURL command:
```bash
curl --request GET --url http://localhost:3564/v1.0/workflows/dapr/<INSTANCEID>
```
Where `<INSTANCEID>` is the workflow instance ID you received in the `Location` header in the previous step.
```
Since `1` is used as the input, the expected serialized output of the workflow is:
```txt
"1"
```
The expected serialized custom status field of the workflow output is:
```txt
"\"Compensated MinusOne activity with PlusOne activity.\""
```
6. Stop the Dapr Multi-App run process by pressing `Ctrl+C`.

View File

@ -0,0 +1,59 @@
<?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.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<artifactId>resiliency-and-compensation</artifactId>
<name>resiliency-and-compensation</name>
<description>Resiliency and Compensation Workflow Example</description>
<properties>
<dapr.spring.version>0.15.0-rc-7</dapr.spring.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-boot-starter</artifactId>
<version>${dapr.spring.version}</version>
</dependency>
<dependency>
<groupId>io.dapr.spring</groupId>
<artifactId>dapr-spring-boot-starter-test</artifactId>
<version>${dapr.spring.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,10 @@
@apphost=http://localhost:8080
### Start the Resiliency and Compensation Workflow workflow
# @name startWorkflowRequest
@input=1
POST {{ apphost }}/start/{{input}}

View File

@ -0,0 +1,27 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ResiliencyAndCompensationApplication {
public static void main(String[] args) {
SpringApplication.run(ResiliencyAndCompensationApplication.class, args);
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class ResiliencyAndCompensationConfiguration {
@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder().build();
}
@Bean
public ObjectMapper mapper() {
return new ObjectMapper();
}
}

View File

@ -0,0 +1,50 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.spring.workflows.config.EnableDaprWorkflows;
import io.dapr.springboot.examples.resiliency.ResiliencyAndCompensationWorkflow;
import io.dapr.workflows.client.DaprWorkflowClient;
import io.dapr.workflows.client.WorkflowInstanceStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.TimeoutException;
@RestController
@EnableDaprWorkflows
public class ResiliencyAndCompensationRestController {
private final Logger logger = LoggerFactory.getLogger(ResiliencyAndCompensationRestController.class);
@Autowired
private DaprWorkflowClient daprWorkflowClient;
/**
* Run Resiliency And Compensation Workflow
*
* @return the instanceId of the ResiliencyAndCompensationWorkflow execution
*/
@PostMapping("start/{input}")
public String start(@PathVariable("input") Integer input) throws TimeoutException {
return daprWorkflowClient.scheduleNewWorkflow(ResiliencyAndCompensationWorkflow.class, input);
}
}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.resiliency;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/*
*/
@Component
public class DivisionActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(DivisionActivity.class);
var divisor = ctx.getInput(Integer.class);
logger.info("{} : Received divisor: {}", ctx.getName(), divisor);
var result = Math.round(100d / divisor);
return result;
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.resiliency;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/*
*/
@Component
public class MinusOneActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(MinusOneActivity.class);
var input = ctx.getInput(Integer.class);
logger.info("{}: Received input: {}", ctx.getName(), input);
var result = input - 1;
return result;
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.resiliency;
import io.dapr.workflows.WorkflowActivity;
import io.dapr.workflows.WorkflowActivityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/*
*/
@Component
public class PlusOneActivity implements WorkflowActivity {
/*
*/
@Override
public Object run(WorkflowActivityContext ctx) {
Logger logger = LoggerFactory.getLogger(PlusOneActivity.class);
var input = ctx.getInput(Integer.class);
logger.info("{}: Received input: {}", ctx.getName(), input);
var result = input + 1;
return result;
}
}

View File

@ -0,0 +1,65 @@
/*
* Copyright 2023 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples.resiliency;
import io.dapr.durabletask.CompositeTaskFailedException;
import io.dapr.durabletask.TaskFailedException;
import io.dapr.workflows.Workflow;
import io.dapr.workflows.WorkflowStub;
import io.dapr.workflows.WorkflowTaskOptions;
import io.dapr.workflows.WorkflowTaskRetryPolicy;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class ResiliencyAndCompensationWorkflow implements Workflow {
@Override
public WorkflowStub create() {
return ctx -> {
var counter = ctx.getInput(Integer.class);
WorkflowTaskRetryPolicy workflowTaskRetryPolicy = new WorkflowTaskRetryPolicy(3,
Duration.ofSeconds(2),
1.0,
Duration.ofSeconds(10),
Duration.ofSeconds(15));
WorkflowTaskOptions defaultActivityRetryOptions = new WorkflowTaskOptions(workflowTaskRetryPolicy);
Integer result1 = ctx.callActivity(MinusOneActivity.class.getName(), counter, defaultActivityRetryOptions, Integer.class).await();
Integer workflowResult;
try {
workflowResult = ctx.callActivity(DivisionActivity.class.getName(), result1, defaultActivityRetryOptions, Integer.class).await();
} catch (TaskFailedException wtfe) {
System.out.println("WTFE" + wtfe);
wtfe.printStackTrace();
//Check Exception
workflowResult = ctx.callActivity(PlusOneActivity.class.getName(), result1, defaultActivityRetryOptions, Integer.class).await();
//@TODO: not supported yet
//ctx.setCustomStatus()
}
ctx.complete(workflowResult);
};
}
}

View File

@ -0,0 +1 @@
spring.application.name=workflow-management

View File

@ -0,0 +1,43 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.testcontainers.Component;
import io.dapr.testcontainers.DaprContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import java.util.Collections;
import static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG;
@TestConfiguration(proxyBeanMethods = false)
public class DaprTestContainersConfig {
@Bean
@ServiceConnection
public DaprContainer daprContainer() {
return new DaprContainer(DAPR_RUNTIME_IMAGE_TAG)
.withAppName("workflow-patterns-app")
.withComponent(new Component("kvstore", "state.in-memory", "v1", Collections.singletonMap("actorStateStore", String.valueOf(true))))
.withAppPort(8080)
.withAppHealthCheckPath("/actuator/health")
.withAppChannelAddress("host.testcontainers.internal");
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TestWorkflowManagementApplication {
public static void main(String[] args) {
SpringApplication.from(ResiliencyAndCompensationApplication::main)
.with(DaprTestContainersConfig.class)
.run(args);
org.testcontainers.Testcontainers.exposeHostPorts(8080);
}
}

View File

@ -0,0 +1,121 @@
/*
* Copyright 2025 The Dapr Authors
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
limitations under the License.
*/
package io.dapr.springboot.examples;
import io.dapr.client.DaprClient;
import io.dapr.springboot.DaprAutoConfiguration;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(classes = {TestWorkflowManagementApplication.class, DaprTestContainersConfig.class,
DaprAutoConfiguration.class, },
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
class WorkflowManagementAppTests {
@Autowired
private DaprClient daprClient;
@BeforeEach
void setUp() {
RestAssured.baseURI = "http://localhost:" + 8080;
org.testcontainers.Testcontainers.exposeHostPorts(8080);
}
@Test
void testWorkflowManagement() {
String workflowId = given().contentType(ContentType.JSON)
.pathParam("counter", 0)
.when()
.post("/start/{counter}")
.then()
.statusCode(200)
.extract().asString();
assertNotNull(workflowId);
String status = given().contentType(ContentType.JSON)
.pathParam("instanceId", workflowId)
.when()
.get("/status/{instanceId}")
.then()
.statusCode(200).extract().asString();
assertTrue(status.contains("RUNNING"));
given().contentType(ContentType.JSON)
.pathParam("instanceId", workflowId)
.when()
.post("/suspend/{instanceId}")
.then()
.statusCode(200);
status = given().contentType(ContentType.JSON)
.pathParam("instanceId", workflowId)
.when()
.get("/status/{instanceId}")
.then()
.statusCode(200).extract().asString();
assertTrue(status.contains("SUSPENDED"));
given().contentType(ContentType.JSON)
.pathParam("instanceId", workflowId)
.when()
.post("/resume/{instanceId}")
.then()
.statusCode(200);
status = given().contentType(ContentType.JSON)
.pathParam("instanceId", workflowId)
.when()
.get("/status/{instanceId}")
.then()
.statusCode(200).extract().asString();
assertTrue(status.contains("RUNNING"));
given().contentType(ContentType.JSON)
.pathParam("instanceId", workflowId)
.when()
.post("/terminate/{instanceId}")
.then()
.statusCode(200);
status = given().contentType(ContentType.JSON)
.pathParam("instanceId", workflowId)
.when()
.get("/status/{instanceId}")
.then()
.statusCode(200).extract().asString();
assertTrue(status.contains("TERMINATED"));
}
}

View File

@ -0,0 +1 @@
spring.application.name=workflow-management