From 909869c264a6d4e379c23bd5c3716c9a923151ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Andr=C3=A9?= Date: Wed, 4 Nov 2026 12:45:59 -0300 Subject: [PATCH] feat: add simple-workflow Spring Boot example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matheus André --- .github/workflows/validate.yml | 1 + spring-boot-examples/README.md | 1 + spring-boot-examples/workflows/pom.xml | 1 + .../workflows/simple-workflow/README.md | 165 ++++++++++++++++++ .../workflows/simple-workflow/pom.xml | 65 +++++++ .../simpleworkflow/AccountService.java | 33 ++++ .../simpleworkflow/CreateAccountActivity.java | 39 +++++ .../examples/simpleworkflow/Customer.java | 65 +++++++ .../CustomerOnboardingWorkflow.java | 42 +++++ .../OnboardingRestController.java | 73 ++++++++ .../simpleworkflow/OnboardingResult.java | 66 +++++++ .../simpleworkflow/OnboardingStatus.java | 74 ++++++++ .../SendWelcomeEmailActivity.java | 35 ++++ .../SimpleWorkflowApplication.java | 26 +++ .../ValidateCustomerActivity.java | 38 ++++ .../src/main/resources/application.properties | 1 + .../DaprTestContainersConfig.java | 41 +++++ .../simpleworkflow/SimpleWorkflowAppIT.java | 106 +++++++++++ .../TestSimpleWorkflowApplication.java | 29 +++ .../src/test/resources/application.properties | 1 + 20 files changed, 902 insertions(+) create mode 100644 spring-boot-examples/workflows/simple-workflow/README.md create mode 100644 spring-boot-examples/workflows/simple-workflow/pom.xml create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/AccountService.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/CreateAccountActivity.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/Customer.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/CustomerOnboardingWorkflow.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingRestController.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingResult.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingStatus.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/SendWelcomeEmailActivity.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowApplication.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/ValidateCustomerActivity.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/main/resources/application.properties create mode 100644 spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/DaprTestContainersConfig.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowAppIT.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/TestSimpleWorkflowApplication.java create mode 100644 spring-boot-examples/workflows/simple-workflow/src/test/resources/application.properties diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3a6ab6d1a4..9e6cc3219e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -125,6 +125,7 @@ jobs: (cd examples && mm.py ./src/main/java/io/dapr/examples/binarystore/README.md) (cd examples && mm.py ./src/main/java/io/dapr/examples/workflows/README.md) (cd spring-boot-examples/pubsub && mm.py README.md) + (cd spring-boot-examples/workflows/simple-workflow && mm.py README.md) (cd spring-boot-examples/workflows/patterns && mm.py README.md) (cd spring-boot-examples/workflows/event-workflow && mm.py README.md) (cd examples && mm.py ./src/main/java/io/dapr/examples/jobs/README.md) diff --git a/spring-boot-examples/README.md b/spring-boot-examples/README.md index 892bccc3bc..13a7078535 100644 --- a/spring-boot-examples/README.md +++ b/spring-boot-examples/README.md @@ -5,6 +5,7 @@ folder per Dapr API: - [PubSub](pubsub/README.md): a producer app publishing messages with Spring Messaging and a consumer app subscribing to them. - Workflows: + - [Simple Workflow](workflows/simple-workflow/README.md): a first Spring Boot workflow, started from a REST endpoint. - [Patterns](workflows/patterns/README.md): common Dapr Workflow patterns. - [Event Workflow](workflows/event-workflow/README.md): workflows started and continued by Pub/Sub events. - [Multi App](workflows/multi-app/README.md): workflows that span multiple applications. diff --git a/spring-boot-examples/workflows/pom.xml b/spring-boot-examples/workflows/pom.xml index f37f657a0c..90016ebebe 100644 --- a/spring-boot-examples/workflows/pom.xml +++ b/spring-boot-examples/workflows/pom.xml @@ -18,6 +18,7 @@ + simple-workflow patterns multi-app versioning diff --git a/spring-boot-examples/workflows/simple-workflow/README.md b/spring-boot-examples/workflows/simple-workflow/README.md new file mode 100644 index 0000000000..f87696b942 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/README.md @@ -0,0 +1,165 @@ +# Dapr Spring Boot Simple Workflow Example + +This application is the starting point for using [Dapr Workflows](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/) +with Spring Boot. It onboards a customer in three steps: + +```mermaid +graph LR + SW((Start + Workflow)) + A1[Validate Customer] + A2[Create Account] + A3[Send Welcome Email] + EW((End + Workflow)) + SW --> A1 + A1 --> A2 + A2 --> A3 + A3 --> EW +``` + +It shows how to: + +- Define a workflow ([`CustomerOnboardingWorkflow`](src/main/java/io/dapr/springboot/examples/simpleworkflow/CustomerOnboardingWorkflow.java)) + and its activities as Spring `@Component`s, and register them with `@EnableDaprWorkflows`. +- Inject regular Spring beans into activities, like the [`AccountService`](src/main/java/io/dapr/springboot/examples/simpleworkflow/AccountService.java) + used by `CreateAccountActivity`. +- Start a workflow from a REST endpoint with `DaprWorkflowClient.scheduleNewWorkflow` without waiting for it to finish, + and check its status and result later with `DaprWorkflowClient.getWorkflowState`. +- See a workflow fail when one of its activities throws an exception. + +For more workflow patterns, such as fan out/fan in, timers, child workflows or external events, see the +[workflow patterns example](../patterns/README.md). + +## Running this example from source code + +To run this example you will need: +- Java SDK +- Maven +- Docker or a container runtime such as Podman + +From the `spring-boot-examples/workflows/simple-workflow` directory you can start the service by running the following command: + + + + +```sh +../../../mvnw spring-boot:test-run +``` + + + +By running the `spring-boot:test-run` goal, the application is loaded using the [test configurations](src/test/java/io/dapr/springboot/examples/simpleworkflow/DaprTestContainersConfig.java) +configured using [Testcontainers](https://testcontainers.com) to bootstrap the [Dapr](https://dapr.io) sidecar and +control plane. No Dapr components are configured, so the workflow state is kept in an in-memory state store. + +## Onboarding a customer + +Start the workflow by sending a POST request with the customer. The customer id is used as the workflow instance id, +and the request returns as soon as the workflow is scheduled: + + + + +```sh +curl -X POST localhost:8080/onboarding -H 'Content-Type: application/json' -d '{ "id": "customer-1", "name": "Ada Lovelace", "email": "ada@example.com" }' --retry 10 --max-time 20 --retry-all-errors --retry-max-time 90 +``` + + + +Then check the status of the workflow instance: + + + + +```sh +curl --fail localhost:8080/onboarding/customer-1 --retry 10 --max-time 20 --retry-all-errors --retry-max-time 90 +``` + + + +```bash +{"error":null,"instanceId":"customer-1","result":{"accountId":"account-customer-1","customerId":"customer-1","welcomeEmailSent":true},"runtimeStatus":"COMPLETED"} +``` + +In the application output you should see the activities being executed: + +```bash +i.d.s.e.s.OnboardingRestController : Workflow instance customer-1 started +i.d.s.e.s.CustomerOnboardingWorkflow : Starting Workflow: io.dapr.springboot.examples.simpleworkflow.CustomerOnboardingWorkflow +i.d.s.e.s.ValidateCustomerActivity : Customer customer-1 is valid +i.d.s.e.s.CreateAccountActivity : Account account-customer-1 created for customer customer-1 +i.d.s.e.s.SendWelcomeEmailActivity : Sending welcome email to ada@example.com +i.d.s.e.s.CustomerOnboardingWorkflow : Workflow finished with result: OnboardingResult{customerId='customer-1', accountId='account-customer-1', welcomeEmailSent=true} +``` + +## A failing workflow + +If an activity throws an exception and the workflow does not catch it, the workflow instance fails. Start the +onboarding for a customer without an email: + + + + +```sh +curl -X POST localhost:8080/onboarding -H 'Content-Type: application/json' -d '{ "id": "customer-2", "name": "No Email" }' --retry 10 --max-time 20 --retry-all-errors --retry-max-time 90 +``` + + + +The status shows that the workflow failed, and why: + + + + +```sh +curl --fail localhost:8080/onboarding/customer-2 --retry 10 --max-time 20 --retry-all-errors --retry-max-time 90 +``` + + + +## Testing workflows + +The [`SimpleWorkflowAppIT`](src/test/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowAppIT.java) test +uses Testcontainers to run both scenarios end to end. diff --git a/spring-boot-examples/workflows/simple-workflow/pom.xml b/spring-boot-examples/workflows/simple-workflow/pom.xml new file mode 100644 index 0000000000..50cf6e098b --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + io.dapr + workflows + 1.20.0-SNAPSHOT + ../pom.xml + + + simple-workflow + simple-workflow + Spring Boot 4, Testcontainers and Dapr Integration Examples :: Simple Workflow + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + io.dapr.spring + dapr-spring-boot-starter + + + io.dapr.spring + dapr-spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-site-plugin + + true + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + true + + + + + diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/AccountService.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/AccountService.java new file mode 100644 index 0000000000..6e0eb15a46 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/AccountService.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 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.simpleworkflow; + +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Regular Spring service injected into the workflow activities. + * Creating an account twice for the same customer returns the same account, so activity retries are safe. + */ +@Service +public class AccountService { + + private final Map accounts = new ConcurrentHashMap<>(); + + public String createAccount(Customer customer) { + return accounts.computeIfAbsent(customer.getId(), id -> "account-" + id); + } +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/CreateAccountActivity.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/CreateAccountActivity.java new file mode 100644 index 0000000000..e9a7e74766 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/CreateAccountActivity.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 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.simpleworkflow; + +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 CreateAccountActivity implements WorkflowActivity { + + private final Logger logger = LoggerFactory.getLogger(CreateAccountActivity.class); + + @Autowired + private AccountService accountService; + + @Override + public Object run(WorkflowActivityContext ctx) { + logger.info("Starting Activity: " + ctx.getName()); + Customer customer = ctx.getInput(Customer.class); + String accountId = accountService.createAccount(customer); + logger.info("Account " + accountId + " created for customer " + customer.getId()); + return accountId; + } +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/Customer.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/Customer.java new file mode 100644 index 0000000000..4f86034da3 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/Customer.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026 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.simpleworkflow; + +public class Customer { + private String id; + private String name; + private String email; + + public Customer() { + } + + /** + * Creates a new Customer. + * + * @param id customer id + * @param name customer name + * @param email customer email + */ + public Customer(String id, String name, String email) { + this.id = id; + this.name = name; + this.email = email; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + @Override + public String toString() { + return "Customer{" + "id='" + id + '\'' + ", name='" + name + '\'' + ", email='" + email + '\'' + '}'; + } +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/CustomerOnboardingWorkflow.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/CustomerOnboardingWorkflow.java new file mode 100644 index 0000000000..f9e70878c4 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/CustomerOnboardingWorkflow.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 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.simpleworkflow; + +import io.dapr.workflows.Workflow; +import io.dapr.workflows.WorkflowStub; +import org.springframework.stereotype.Component; + +/** + * Onboards a customer by validating it, creating an account and sending a welcome email. + * Each step is an activity, so a step that already completed is not executed again if the application restarts. + */ +@Component +public class CustomerOnboardingWorkflow implements Workflow { + + @Override + public WorkflowStub create() { + return ctx -> { + ctx.getLogger().info("Starting Workflow: " + ctx.getName()); + Customer customer = ctx.getInput(Customer.class); + + ctx.callActivity(ValidateCustomerActivity.class.getName(), customer, Customer.class).await(); + String accountId = ctx.callActivity(CreateAccountActivity.class.getName(), customer, String.class).await(); + Boolean emailSent = ctx.callActivity(SendWelcomeEmailActivity.class.getName(), customer, Boolean.class).await(); + + OnboardingResult result = new OnboardingResult(customer.getId(), accountId, emailSent); + ctx.getLogger().info("Workflow finished with result: " + result); + ctx.complete(result); + }; + } +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingRestController.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingRestController.java new file mode 100644 index 0000000000..cfb74f820c --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingRestController.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 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.simpleworkflow; + +import io.dapr.spring.workflows.config.EnableDaprWorkflows; +import io.dapr.workflows.client.DaprWorkflowClient; +import io.dapr.workflows.client.WorkflowRuntimeStatus; +import io.dapr.workflows.client.WorkflowState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@EnableDaprWorkflows +public class OnboardingRestController { + + private final Logger logger = LoggerFactory.getLogger(OnboardingRestController.class); + + @Autowired + private DaprWorkflowClient daprWorkflowClient; + + /** + * Start the onboarding workflow for a customer without waiting for it to finish. + * @param customer to onboard + * @return the id of the workflow instance, which is the customer id + */ + @PostMapping("/onboarding") + public String startOnboarding(@RequestBody Customer customer) { + String instanceId = daprWorkflowClient.scheduleNewWorkflow(CustomerOnboardingWorkflow.class, customer, + customer.getId()); + logger.info("Workflow instance " + instanceId + " started"); + return instanceId; + } + + /** + * Get the status of an onboarding workflow instance. + * @param instanceId id of the workflow instance + * @return the runtime status of the instance, its result once completed or its error if it failed + */ + @GetMapping("/onboarding/{instanceId}") + public ResponseEntity getOnboarding(@PathVariable("instanceId") String instanceId) { + WorkflowState state = daprWorkflowClient.getWorkflowState(instanceId, true); + if (state == null) { + return ResponseEntity.notFound().build(); + } + OnboardingResult result = null; + String error = null; + if (state.getRuntimeStatus() == WorkflowRuntimeStatus.COMPLETED) { + result = state.readOutputAs(OnboardingResult.class); + } else if (state.getRuntimeStatus() == WorkflowRuntimeStatus.FAILED && state.getFailureDetails() != null) { + error = state.getFailureDetails().getErrorMessage(); + } + return ResponseEntity.ok(new OnboardingStatus(instanceId, state.getRuntimeStatus(), result, error)); + } + +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingResult.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingResult.java new file mode 100644 index 0000000000..07d0617180 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingResult.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 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.simpleworkflow; + +public class OnboardingResult { + private String customerId; + private String accountId; + private boolean welcomeEmailSent; + + public OnboardingResult() { + } + + /** + * Creates a new OnboardingResult. + * + * @param customerId id of the onboarded customer + * @param accountId id of the account created for the customer + * @param welcomeEmailSent whether the welcome email was sent + */ + public OnboardingResult(String customerId, String accountId, boolean welcomeEmailSent) { + this.customerId = customerId; + this.accountId = accountId; + this.welcomeEmailSent = welcomeEmailSent; + } + + public String getCustomerId() { + return customerId; + } + + public void setCustomerId(String customerId) { + this.customerId = customerId; + } + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public boolean isWelcomeEmailSent() { + return welcomeEmailSent; + } + + public void setWelcomeEmailSent(boolean welcomeEmailSent) { + this.welcomeEmailSent = welcomeEmailSent; + } + + @Override + public String toString() { + return "OnboardingResult{" + "customerId='" + customerId + '\'' + ", accountId='" + accountId + '\'' + + ", welcomeEmailSent=" + welcomeEmailSent + '}'; + } +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingStatus.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingStatus.java new file mode 100644 index 0000000000..3a0efab88f --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/OnboardingStatus.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 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.simpleworkflow; + +import io.dapr.workflows.client.WorkflowRuntimeStatus; + +public class OnboardingStatus { + private String instanceId; + private WorkflowRuntimeStatus runtimeStatus; + private OnboardingResult result; + private String error; + + public OnboardingStatus() { + } + + /** + * Creates a new OnboardingStatus. + * + * @param instanceId id of the workflow instance + * @param runtimeStatus current runtime status of the workflow instance + * @param result output of the workflow, set once it completed + * @param error failure message, set if the workflow failed + */ + public OnboardingStatus(String instanceId, WorkflowRuntimeStatus runtimeStatus, OnboardingResult result, + String error) { + this.instanceId = instanceId; + this.runtimeStatus = runtimeStatus; + this.result = result; + this.error = error; + } + + public String getInstanceId() { + return instanceId; + } + + public void setInstanceId(String instanceId) { + this.instanceId = instanceId; + } + + public WorkflowRuntimeStatus getRuntimeStatus() { + return runtimeStatus; + } + + public void setRuntimeStatus(WorkflowRuntimeStatus runtimeStatus) { + this.runtimeStatus = runtimeStatus; + } + + public OnboardingResult getResult() { + return result; + } + + public void setResult(OnboardingResult result) { + this.result = result; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/SendWelcomeEmailActivity.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/SendWelcomeEmailActivity.java new file mode 100644 index 0000000000..f24c49072b --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/SendWelcomeEmailActivity.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 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.simpleworkflow; + +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 SendWelcomeEmailActivity implements WorkflowActivity { + + private final Logger logger = LoggerFactory.getLogger(SendWelcomeEmailActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + logger.info("Starting Activity: " + ctx.getName()); + Customer customer = ctx.getInput(Customer.class); + // A real application would call an email service here + logger.info("Sending welcome email to " + customer.getEmail()); + return true; + } +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowApplication.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowApplication.java new file mode 100644 index 0000000000..e0f76f5eab --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowApplication.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026 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.simpleworkflow; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SimpleWorkflowApplication { + + public static void main(String[] args) { + SpringApplication.run(SimpleWorkflowApplication.class, args); + } + +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/ValidateCustomerActivity.java b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/ValidateCustomerActivity.java new file mode 100644 index 0000000000..4a8d0c47e1 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/java/io/dapr/springboot/examples/simpleworkflow/ValidateCustomerActivity.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 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.simpleworkflow; + +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 ValidateCustomerActivity implements WorkflowActivity { + + private final Logger logger = LoggerFactory.getLogger(ValidateCustomerActivity.class); + + @Override + public Object run(WorkflowActivityContext ctx) { + logger.info("Starting Activity: " + ctx.getName()); + Customer customer = ctx.getInput(Customer.class); + if (customer.getEmail() == null || !customer.getEmail().contains("@")) { + // An exception thrown by an activity fails the workflow unless the workflow catches it + throw new IllegalArgumentException("Invalid email for customer " + customer.getId()); + } + logger.info("Customer " + customer.getId() + " is valid"); + return customer; + } +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/main/resources/application.properties b/spring-boot-examples/workflows/simple-workflow/src/main/resources/application.properties new file mode 100644 index 0000000000..dcbabced7c --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=simple-workflow-app diff --git a/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/DaprTestContainersConfig.java b/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/DaprTestContainersConfig.java new file mode 100644 index 0000000000..2314803da4 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/DaprTestContainersConfig.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 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.simpleworkflow; + +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 static io.dapr.testcontainers.DaprContainerConstants.DAPR_RUNTIME_IMAGE_TAG; + +/** + * Starts the Dapr sidecar and control plane. No components are configured, so the sidecar uses an in-memory + * state store to keep the workflow state. Use a persistent state store, such as PostgreSQL, outside of local + * development (see the workflow patterns example). + */ +@TestConfiguration(proxyBeanMethods = false) +public class DaprTestContainersConfig { + + @Bean + @ServiceConnection + public DaprContainer daprContainer() { + return new DaprContainer(DAPR_RUNTIME_IMAGE_TAG) + .withAppName("simple-workflow-app") + .withAppPort(8080) + .withAppHealthCheckPath("/actuator/health") + .withAppChannelAddress("host.testcontainers.internal"); + } + +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowAppIT.java b/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowAppIT.java new file mode 100644 index 0000000000..389c03c916 --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/SimpleWorkflowAppIT.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 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.simpleworkflow; + +import io.dapr.springboot.DaprAutoConfiguration; +import io.dapr.workflows.client.WorkflowRuntimeStatus; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.client.RestTestClient; + +import java.time.Duration; +import java.util.UUID; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(classes = {TestSimpleWorkflowApplication.class, DaprTestContainersConfig.class, + DaprAutoConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class SimpleWorkflowAppIT { + + @LocalServerPort + private int port; + + private RestTestClient client; + + @BeforeAll + public static void setup() { + org.testcontainers.Testcontainers.exposeHostPorts(8080); + } + + @BeforeEach + void setUp() { + client = RestTestClient.bindToServer() + .baseUrl("http://localhost:" + port) + .build(); + } + + @Test + void testCustomerOnboarded() { + String customerId = UUID.randomUUID().toString(); + startOnboarding(new Customer(customerId, "Ada Lovelace", "ada@example.com")); + + OnboardingStatus status = awaitTerminalStatus(customerId); + + assertEquals(WorkflowRuntimeStatus.COMPLETED, status.getRuntimeStatus()); + assertEquals(customerId, status.getResult().getCustomerId()); + assertEquals("account-" + customerId, status.getResult().getAccountId()); + assertTrue(status.getResult().isWelcomeEmailSent()); + assertNull(status.getError()); + } + + @Test + void testInvalidCustomerFailsWorkflow() { + String customerId = UUID.randomUUID().toString(); + startOnboarding(new Customer(customerId, "No Email", null)); + + OnboardingStatus status = awaitTerminalStatus(customerId); + + assertEquals(WorkflowRuntimeStatus.FAILED, status.getRuntimeStatus()); + assertNull(status.getResult()); + assertTrue(status.getError().contains("Invalid email for customer " + customerId)); + } + + private void startOnboarding(Customer customer) { + String instanceId = client.post() + .uri("/onboarding") + .contentType(MediaType.APPLICATION_JSON) + .body(customer) + .exchange() + .expectStatus().isOk() + .returnResult(String.class) + .getResponseBody(); + assertEquals(customer.getId(), instanceId); + } + + private OnboardingStatus awaitTerminalStatus(String instanceId) { + return await().atMost(Duration.ofSeconds(30)) + .until(() -> client.get() + .uri("/onboarding/" + instanceId) + .exchange() + .expectStatus().isOk() + .returnResult(OnboardingStatus.class) + .getResponseBody(), + s -> s.getRuntimeStatus() == WorkflowRuntimeStatus.COMPLETED + || s.getRuntimeStatus() == WorkflowRuntimeStatus.FAILED); + } + +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/TestSimpleWorkflowApplication.java b/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/TestSimpleWorkflowApplication.java new file mode 100644 index 0000000000..b66ac8088c --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/test/java/io/dapr/springboot/examples/simpleworkflow/TestSimpleWorkflowApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026 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.simpleworkflow; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestSimpleWorkflowApplication { + + public static void main(String[] args) { + SpringApplication.from(SimpleWorkflowApplication::main) + .with(DaprTestContainersConfig.class) + .run(args); + org.testcontainers.Testcontainers.exposeHostPorts(8080); + } + +} diff --git a/spring-boot-examples/workflows/simple-workflow/src/test/resources/application.properties b/spring-boot-examples/workflows/simple-workflow/src/test/resources/application.properties new file mode 100644 index 0000000000..dcbabced7c --- /dev/null +++ b/spring-boot-examples/workflows/simple-workflow/src/test/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=simple-workflow-app