
Build an email drip campaign with Java and Spring Boot
Build an email drip campaign and a subscription web application in Java. You'll create a web server using the Spring Boot framework to handle requests and use Temporal Workflows, Activities, and Queries to build the core of the application.
Introduction
Your web server will handle requests from the end user and interact with a Temporal Workflow to manage the email subscription process. Since you're building the business logic with Temporal's Workflows and Activities, you'll be able to use Temporal to manage each subscription rather than relying on a separate database or Task Queue. This reduces the complexity of the code you have to write and support.
You'll create an endpoint for users to give their email address, and then create a new Workflow execution using that email address which will simulate sending an email message at certain intervals. The user can check on the status of their subscription, which you'll handle using a Query, and they can end the subscription at any time by unsubscribing, which you'll handle by cancelling the Workflow Execution.
By the end of this tutorial, you'll have a clear understanding of how to use Temporal to create and manage long-running Workflows within a web application.
You'll find the code for this tutorial on GitHub in the email-drip-campaign-project-java repository.
Prerequisites
Before starting this tutorial:
- Set up a local development environment for Temporal and Java.
- Complete the Hello World tutorial to ensure you understand the basics of creating Workflows and Activities with Temporal.
- This application uses Gradle build automation. Make sure you have installed Gradle.
- You'll use Spring Initializer to generate a project with a
build.gradlefile for Java. Add Spring Web dependencies before generating the project. After creating thebuild.gradlefile, add yourtemporal-sdkandtemporal-spring-bootdependencies.
Your build.gradle dependencies section should look like this:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation "io.temporal:temporal-spring-boot-starter-alpha:$javaSDKVersion"
}
Create settings.gradle in the root of your directory with the following line:
rootProject.name = 'email-subscription'
With the Gradle configurations complete, you're ready to code a Spring Boot web application.
Develop the Workflow
A Workflow defines a sequence of steps defined by writing code, known as a Workflow Definition, and is carried out by running that code, which results in a Workflow Execution.
The Temporal Java SDK recommends the use of a single data class for parameters and return types. This lets you add fields without breaking compatibility. Before writing the Workflow Definition, you'll define the data objects used by the Workflow Definitions, and the Task Queue name you'll use in your Worker.
Create the package directories for this project:
src
├── main
│ ├── java
│ │ └── subscription
│ │ ├── Controller.java
│ │ ├── Starter.java
│ │ ├── activities
│ │ │ ├── SendEmailActivities.java
│ │ │ └── SendEmailActivitiesImpl.java
│ │ ├── model
│ │ │ ├── Constants.java
│ │ │ ├── EmailDetails.java
│ │ │ ├── Message.java
│ │ │ └── WorkflowData.java
│ │ └── workflows
│ │ ├── SendEmailWorkflow.java
│ │ └── SendEmailWorkflowImpl.java
│ └── resources
│ └── application.yaml
└── test
└── java
└── StarterTest.java
Build the model files, which will:
- Set the Task Queue field to
email_subscription. - Add
Message,WorkflowData, andEmailDetailsdata classes.
Create a new file called Constants.java in src/main/java/subscription/model:
package subscription.model;
public class Constants {
public static final String TASK_QUEUE_NAME = "email_subscription";
}
Create a new file called Message.java in src/main/java/subscription/model:
package subscription.model;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
public class Message {
public String message;
public Message() {}
public Message(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
Create a new file called EmailDetails.java in src/main/java/subscription/model:
package subscription.model;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
public class EmailDetails {
public String email;
public String message;
public int count;
public boolean subscribed;
public EmailDetails() {}
public EmailDetails(String email, String message, int count, boolean subscribed) {
this.email = email;
this.message = message;
this.count = count;
this.subscribed = subscribed;
}
}
Create a new file called WorkflowData.java in src/main/java/subscription/model:
package subscription.model;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
public class WorkflowData {
public String email;
public WorkflowData() {}
public WorkflowData(String email) {
this.email = email;
}
public String getEmail() {
return email;
}
}
The following describes each data class:
WorkflowData: starts the Workflow Execution. Containsemail: a string to pass the user's email.EmailDetails: holds data about the current state of the subscription.email: a string to pass a user's email.message: a string to pass a message to the user.count: an integer to track the number of emails sent.subscribed: a boolean to track whether the user is currently subscribed.
Message: holds data for a single message. Containsmessage: a string to pass a message to the user.
When you Query your Workflow to retrieve the current state of the Workflow, you'll use the EmailDetails data class.
Now that you have the Task Queue and the data classes defined, you can write the Workflow Definition. Create new files called SendEmailWorkflow.java and SendEmailWorkflowImpl.java:
package subscription.workflows;
import subscription.model.EmailDetails;
import subscription.model.WorkflowData;
import io.temporal.workflow.QueryMethod;
import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;
@WorkflowInterface
public interface SendEmailWorkflow {
@WorkflowMethod
public void run(WorkflowData data);
@QueryMethod
public EmailDetails details();
}
package subscription.workflows;
import io.temporal.spring.boot.WorkflowImpl;
import subscription.activities.SendEmailActivities;
import subscription.model.EmailDetails;
import subscription.model.WorkflowData;
import io.temporal.activity.ActivityOptions;
import io.temporal.failure.CanceledFailure;
import io.temporal.workflow.CancellationScope;
import io.temporal.workflow.Workflow;
import java.time.Duration;
@WorkflowImpl(workers = "send-email-worker")
public class SendEmailWorkflowImpl implements SendEmailWorkflow {
private EmailDetails emailDetails = new EmailDetails();
private final ActivityOptions options =
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.build();
private final SendEmailActivities activities =
Workflow.newActivityStub(SendEmailActivities.class, options);
@Override
public void run(WorkflowData data) {
int duration = 12;
emailDetails.email = data.email;
emailDetails.message = "Welcome to our Subscription Workflow!";
emailDetails.subscribed = true;
emailDetails.count = 0;
while (emailDetails.subscribed) {
emailDetails.count += 1;
if (emailDetails.count > 1) {
emailDetails.message = "Thank you for staying subscribed!";
}
try {
activities.sendEmail(emailDetails);
Workflow.sleep(Duration.ofSeconds(duration));
}
catch (CanceledFailure e) {
emailDetails.subscribed = false;
emailDetails.message = "Sorry to see you go";
CancellationScope sendGoodbye =
Workflow.newDetachedCancellationScope(() -> activities.sendEmail(emailDetails));
sendGoodbye.run();
throw e;
}
}
}
@Override
public EmailDetails details() {
return emailDetails;
}
}
The run() method, annotated with @WorkflowMethod, takes in the email address as an argument. This method initializes the email, message, subscribed, and count fields of the emailDetails instance.
The SendEmailWorkflow class has a loop that checks if the subscription is active by checking if emailDetails.subscribed is true. If it is, it starts the sendEmail() Activity.
The while loop increments the count and calls the sendEmail() Activity with the current EmailDetails object. The loop continues as long as emailDetails.subscribed is true. A start_to_close_timeout parameter tells the Temporal Server to time out the Activity 10 seconds from when the Activity starts.
The loop also includes a Workflow.sleep() statement that causes the Workflow to pause for a set amount of time between emails. You can define this in seconds, days, months, or even years, depending on your business logic.
If there's a cancellation request, the request throws a CanceledFailure error, which you can catch and respond to. You'll use cancellation requests to unsubscribe users, sending one last email before completing the Workflow Execution.
Since the user's email address is set to the Workflow Id, attempting to subscribe with the same email address twice will result in a Workflow Execution already started error, ensuring only one running Workflow Execution per email address.
Develop an Activity
You'll need an Activity to send the email to the subscriber so you can handle failures. Create SendEmailActivities.java and SendEmailActivitiesImpl.java in src/main/java/subscription/activities:
package subscription.activities;
import subscription.model.EmailDetails;
import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;
@ActivityInterface
public interface SendEmailActivities {
@ActivityMethod
public String sendEmail(EmailDetails details);
}
package subscription.activities;
import io.temporal.spring.boot.ActivityImpl;
import org.springframework.stereotype.Component;
import subscription.model.EmailDetails;
import java.text.MessageFormat;
@Component
@ActivityImpl(workers = "send-email-worker")
public class SendEmailActivitiesImpl implements SendEmailActivities {
@Override
public String sendEmail(EmailDetails details) {
String response = MessageFormat.format(
"Sending email to {0} with message: {1}, count: {2}",
details.email, details.message, details.count);
System.out.println(response);
return "success";
}
}
This implementation only prints a message, but you could replace the implementation with one that uses an email API. Each iteration of the Workflow loop will execute this Activity, which simulates sending a message to the user.
Create the Worker to handle the Workflow and Activity Executions
Temporal's Java SDK Spring Boot integration package lets you write a Worker process for your Workflows and Activities without a dedicated Worker class. Your Worker will start automatically by running your Spring Boot application.
Create a new file in the src/main/resources directory called application.yml:
spring:
application:
name: email-subscription
temporal:
namespace: default
connection:
target: 127.0.0.1:7233
workers:
- name: send-email-worker
task-queue: email_subscription
workersAutoDiscovery:
packages:
- subscription.workflows
- subscription.activities
Specify rootProject.name = 'email-subscription' in settings.gradle to link the application.yml file. Hand-match the task-queue: string to the TASK_QUEUE_NAME defined in Constants.java. Since this implementation uses Spring Boot, the Java and YAML sources cannot share the string constant. Both the Workflows and Activities packages must be specified separately under packages:.
For a Spring-integrated Worker to run your Workflows and Activities, you must use the @WorkflowImpl(workers = "send-email-worker") and @ActivityImpl(workers = "send-email-worker") annotations in your implementation classes.
Build the API server to handle subscription requests
This tutorial uses the Spring Boot web framework to build a web server that acts as the entry point for initiating Workflow Execution and communicating with the subscribe, get-details, and unsubscribe routes.
Create Controller.java in src/main/java/subscription. First, register the Temporal Client method to run before the first request. A Temporal Client enables you to communicate with the Temporal Cluster:
package subscription;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import io.temporal.client.WorkflowStub;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import subscription.model.*;
import subscription.workflows.SendEmailWorkflow;
@RestController
public class Controller {
@Autowired
WorkflowClient client;
Initialize WorkflowClient private variable client with @Autowired. This lets the Temporal WorkflowClient use the specifications in application.yml.
In the Controller.java file, define a /subscribe endpoint so that users can subscribe to the emails:
@PostMapping(value = "/subscribe", produces = MediaType.APPLICATION_JSON_VALUE)
public Message startSubscription(@RequestBody WorkflowData data) {
WorkflowOptions options = WorkflowOptions.newBuilder()
.setWorkflowId(data.getEmail())
.setTaskQueue(Constants.TASK_QUEUE_NAME)
.build();
SendEmailWorkflow workflow = client.newWorkflowStub(SendEmailWorkflow.class, options);
WorkflowClient.start(workflow::run,data);
return new Message("Resource created successfully");
}
In the startSubscription() method, use the WorkflowClient instance to start your Workflow Execution. The WorkflowData object is used to pass the email address given by the user to the Workflow Execution and sets the Workflow Id.
With this endpoint in place, you can now send a POST request to /subscribe with an email address in the request body to start a new Workflow.
Add a Query
Now create a method in which a user can get information about their subscription details. Add a new method called details() to the SendEmailWorkflow class and use the @QueryMethod annotation:
@QueryMethod
public EmailDetails details();
Add the implementation to the SendEmailWorkflowImpl class:
@Override
public EmailDetails details() {
return emailDetails;
}
The emailDetails object is an instance of EmailDetails. Queries can be used even after the Workflow completes. Queries should never mutate anything in the Workflow.
To enable users to query the Workflow from the Spring Boot application, add a new endpoint called /get_details to the Controller.java file:
@GetMapping(value = "/get_details", produces = MediaType.APPLICATION_JSON_VALUE)
public EmailDetails getQuery(@RequestParam String email) {
SendEmailWorkflow workflow = client.newWorkflowStub(SendEmailWorkflow.class, email);
return workflow.details();
}
Using the Workflow, call the details() Query method to get the value of the variables. This method enables you to return all the information about the user's email subscription that's declared in the Workflow.
Unsubscribe users with a Workflow Cancellation Request
Users will want to unsubscribe from the email list at some point, so give them a way to do that. You cancel a Workflow by sending a cancellation request to the Workflow Execution. Your Workflow code can respond to this cancellation and perform additional operations in response.
With the Controller.java file open, add a new endpoint called /unsubscribe. Use the HTTP DELETE method on the unsubscribe endpoint to cancel() the Workflow:
@DeleteMapping(value = "/unsubscribe", produces = MediaType.APPLICATION_JSON_VALUE)
public Message endSubscription(@RequestBody WorkflowData data) {
SendEmailWorkflow workflow = client.newWorkflowStub(SendEmailWorkflow.class, data.getEmail());
WorkflowStub workflowStub = WorkflowStub.fromTyped(workflow);
workflowStub.cancel();
return new Message("Requesting cancellation");
}
The workflowStub.cancel() method sends a cancellation request to the Workflow Execution that was started with the /subscribe endpoint.
When the Temporal Service receives the cancellation request, it will cancel the Workflow Execution and throw a CanceledFailure error to the Workflow Execution, which your Workflow Definition already handles in the try/catch block:
try {
activities.sendEmail(emailDetails);
Workflow.sleep(Duration.ofSeconds(duration));
}
catch (CanceledFailure e) {
emailDetails.subscribed = false;
emailDetails.message = "Sorry to see you go";
CancellationScope sendGoodbye =
Workflow.newDetachedCancellationScope(() -> activities.sendEmail(emailDetails));
sendGoodbye.run();
throw e;
}
With this endpoint in place, users can send a DELETE request to unsubscribe with an email address in the request body to cancel the Workflow associated with that email address.
Build the server app
Create Starter.java in the subscription directory. It will run your Spring Boot app:
package subscription;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Starter {
public static void main(String[] args) {
SpringApplication.run(Starter.class, args);
}
}
Next, test your application code to ensure it works as expected.
Create an integration test
Integration testing is an essential part of software development that helps ensure that different components of an application work together correctly.
The Temporal Java SDK includes classes and methods that help you test your Workflow Executions. Workflow testing can be done in an integration test fashion against a test server or from a given Client.
Create a file in the src/test/java directory called StarterTest.java:
import subscription.activities.SendEmailActivitiesImpl;
import subscription.workflows.SendEmailWorkflow;
import subscription.workflows.SendEmailWorkflowImpl;
import subscription.model.WorkflowData;
import io.temporal.api.common.v1.WorkflowExecution;
import io.temporal.api.enums.v1.WorkflowExecutionStatus;
import io.temporal.api.workflowservice.v1.DescribeWorkflowExecutionRequest;
import io.temporal.api.workflowservice.v1.DescribeWorkflowExecutionResponse;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowStub;
import io.temporal.testing.TestWorkflowEnvironment;
import io.temporal.testing.TestWorkflowExtension;
import io.temporal.worker.Worker;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class StarterTest {
@RegisterExtension
public static final TestWorkflowExtension testWorkflowExtension =
TestWorkflowExtension.newBuilder()
.setWorkflowTypes(SendEmailWorkflowImpl.class)
.setDoNotStart(true)
.build();
@Test
public void testCreateEmail(TestWorkflowEnvironment testEnv, Worker worker, SendEmailWorkflow workflow) {
WorkflowClient client = testEnv.getWorkflowClient();
worker.registerActivitiesImplementations(new SendEmailActivitiesImpl());
testEnv.start();
WorkflowData data = new WorkflowData("test@example.com");
WorkflowExecution execution = WorkflowClient.start(workflow::run,data);
DescribeWorkflowExecutionResponse response = client.getWorkflowServiceStubs().blockingStub().describeWorkflowExecution(
DescribeWorkflowExecutionRequest.newBuilder()
.setNamespace(testEnv.getNamespace())
.setExecution(execution)
.build()
);
WorkflowExecutionStatus status = response.getWorkflowExecutionInfo().getStatus();
assertEquals(WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_RUNNING, status);
testEnv.close();
}
@Test
public void testCancelWorkflow (TestWorkflowEnvironment testEnv, Worker worker, SendEmailWorkflow workflow) {
WorkflowClient client = testEnv.getWorkflowClient();
worker.registerActivitiesImplementations(new SendEmailActivitiesImpl());
testEnv.start();
WorkflowData data = new WorkflowData("test@example.com");
WorkflowExecution execution = WorkflowClient.start(workflow::run,data);
WorkflowStub workflowStub = client.newUntypedWorkflowStub(execution.getWorkflowId());
workflowStub.cancel();
DescribeWorkflowExecutionResponse response;
WorkflowExecutionStatus status;
do {
response = client.getWorkflowServiceStubs().blockingStub().describeWorkflowExecution(
DescribeWorkflowExecutionRequest.newBuilder()
.setNamespace(testEnv.getNamespace())
.setExecution(execution)
.build()
);
status = response.getWorkflowExecutionInfo().getStatus();
}
while (status != WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED);
assertEquals(WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CANCELED, status);
testEnv.close();
}
}
The testCreateEmail() method creates a Workflow Execution by starting the SendEmailWorkflow with some test data. The method then asserts that the status of the Workflow Execution is RUNNING.
The testCancelWorkflow() method also starts a Workflow Execution, but it then immediately cancels it using the cancel() method on the WorkflowStub. It then waits for the Workflow Execution to complete and asserts that the status is CANCELED.
To test the method, run ./gradlew test --info from the command line to automatically discover and execute tests.
BUILD SUCCESSFUL in 5s
4 actionable tasks: 4 executed
You've successfully written, executed, and passed a Cancellation Workflow test, just as you would any other code written in Java.
Conclusion
This tutorial demonstrates how to build an email subscription web application using Temporal, Java, Spring Boot, and Gradle. By leveraging Temporal's Workflows, Activities, and Queries, the tutorial shows how to create a web server that interacts with Temporal to manage the email subscription process.
With this knowledge, you will be able to take on more complex Workflows and Activities to create even stronger applications.
Continue exploring with other Temporal Java tutorials or learn more by taking our free self-paced courses.
Get notified when we launch new educational content
New courses, tutorials, and learning resources - straight to your inbox.