mirror of
https://github.com/temporalio/skill-temporal-developer.git
synced 2026-09-14 13:52:58 +08:00
Add Spring Boot integration reference for Java SDK (#74)
Covers auto-discovery mechanics, annotation layering (@WorkflowImpl vs @ActivityImpl + @Component), WorkflowClient injection, worker lifecycle, testing strategies, and Spring-specific gotchas. Updates java.md and testing.md with pointers to the new reference. Co-authored-by: Donald Pinckney <donald.pinckney@temporal.io>
This commit is contained in:
@@ -193,6 +193,8 @@ public class Starter {
|
||||
- `Worker` -- polls a single Task Queue, register workflows and activities on it
|
||||
- Call `factory.start()` to begin polling
|
||||
|
||||
For Spring Boot apps, `temporal-spring-boot-starter` handles all of the above automatically via auto-configuration. See `references/java/spring-boot.md`.
|
||||
|
||||
## File Organization Best Practice
|
||||
|
||||
**Keep Workflow and Activity definitions in separate files.** Separating them is good practice for clarity and maintainability.
|
||||
@@ -252,6 +254,7 @@ See `references/java/testing.md` for info on writing tests.
|
||||
|
||||
### Reference Files
|
||||
|
||||
- **`references/java/spring-boot.md`** - Spring Boot integration: auto-discovery, dependency injection, worker lifecycle, testing
|
||||
- **`references/java/patterns.md`** - Signals, queries, child workflows, saga pattern, etc.
|
||||
- **`references/java/determinism.md`** - Determinism rules and safe alternatives for Java
|
||||
- **`references/java/gotchas.md`** - Java-specific mistakes and anti-patterns
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# Temporal Spring Boot Integration
|
||||
|
||||
## Overview
|
||||
|
||||
`temporal-spring-boot-starter` auto-configures workers, registers workflow/activity implementations, and exposes `WorkflowClient` as a Spring bean. This eliminates the manual `WorkflowServiceStubs` → `WorkflowClient` → `WorkerFactory` setup required without Spring.
|
||||
|
||||
## Dependency Setup
|
||||
|
||||
Maven:
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>io.temporal</groupId>
|
||||
<artifactId>temporal-spring-boot-starter</artifactId>
|
||||
<version>[1.0,)</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Gradle:
|
||||
```groovy
|
||||
implementation 'io.temporal:temporal-spring-boot-starter:1.+'
|
||||
```
|
||||
|
||||
The starter transitively includes `temporal-sdk` and the autoconfigure module. You can declare both `temporal-sdk` and `temporal-spring-boot-starter` explicitly, but the starter alone is sufficient.
|
||||
|
||||
## Minimal Configuration
|
||||
|
||||
`application.properties`:
|
||||
```properties
|
||||
spring.temporal.connection.target=local
|
||||
spring.temporal.start-workers=true
|
||||
spring.temporal.workersAutoDiscovery.packages=greetingapp
|
||||
```
|
||||
|
||||
`application.yml` equivalent:
|
||||
```yaml
|
||||
spring:
|
||||
temporal:
|
||||
connection:
|
||||
target: local # shorthand for localhost:7233
|
||||
start-workers: true
|
||||
workersAutoDiscovery:
|
||||
packages:
|
||||
- greetingapp
|
||||
workers:
|
||||
- task-queue: greeting-queue
|
||||
name: greeting-worker
|
||||
```
|
||||
|
||||
For self-hosted Temporal, replace `local` with the server address:
|
||||
```properties
|
||||
spring.temporal.connection.target=temporal.internal:7233
|
||||
```
|
||||
|
||||
## Interface Design + Spring Annotation Layering
|
||||
|
||||
The key concept: Temporal SDK annotations go on **interfaces**, Spring Boot autoconfigure annotations go on **implementation classes**. This is identical to non-Spring usage at the interface level.
|
||||
|
||||
### Workflow Interface (unchanged from non-Spring)
|
||||
```java
|
||||
package greetingapp;
|
||||
|
||||
import io.temporal.workflow.WorkflowInterface;
|
||||
import io.temporal.workflow.WorkflowMethod;
|
||||
|
||||
@WorkflowInterface
|
||||
public interface GreetingWorkflow {
|
||||
@WorkflowMethod
|
||||
String greet(String name);
|
||||
}
|
||||
```
|
||||
|
||||
### Workflow Implementation
|
||||
```java
|
||||
package greetingapp;
|
||||
|
||||
import io.temporal.activity.ActivityOptions;
|
||||
import io.temporal.spring.boot.WorkflowImpl;
|
||||
import io.temporal.workflow.Workflow;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
// @WorkflowImpl replaces manual worker.registerWorkflowImplementationTypes()
|
||||
// No @Component — workflows are NOT Spring beans; Temporal creates a new instance per execution
|
||||
@WorkflowImpl(taskQueues = "greeting-queue")
|
||||
public class GreetingWorkflowImpl implements GreetingWorkflow {
|
||||
|
||||
// Activity stubs created via Workflow.newActivityStub() as usual
|
||||
private final GreetActivities activities = Workflow.newActivityStub(
|
||||
GreetActivities.class,
|
||||
ActivityOptions.newBuilder()
|
||||
.setStartToCloseTimeout(Duration.ofSeconds(30))
|
||||
.setTaskQueue("greeting-queue")
|
||||
.build()
|
||||
);
|
||||
|
||||
@Override
|
||||
public String greet(String name) {
|
||||
return activities.greet(name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Activity Interface (unchanged from non-Spring)
|
||||
```java
|
||||
package greetingapp;
|
||||
|
||||
import io.temporal.activity.ActivityInterface;
|
||||
import io.temporal.activity.ActivityMethod;
|
||||
|
||||
@ActivityInterface
|
||||
public interface GreetActivities {
|
||||
@ActivityMethod
|
||||
String greet(String name);
|
||||
}
|
||||
```
|
||||
|
||||
### Activity Implementation
|
||||
```java
|
||||
package greetingapp;
|
||||
|
||||
import io.temporal.spring.boot.ActivityImpl;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
// @Component makes this a Spring bean — dependencies can be injected normally
|
||||
// @ActivityImpl replaces manual worker.registerActivitiesImplementations()
|
||||
@Component
|
||||
@ActivityImpl(taskQueues = "greeting-queue")
|
||||
public class GreetActivitiesImpl implements GreetActivities {
|
||||
|
||||
private final GreetingService greetingService;
|
||||
|
||||
// Constructor injection works because this is a Spring bean
|
||||
public GreetActivitiesImpl(GreetingService greetingService) {
|
||||
this.greetingService = greetingService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String greet(String name) {
|
||||
return greetingService.composeGreeting(name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Discovery
|
||||
|
||||
Auto-discovery is how the autoconfigure finds and registers implementations without explicit configuration. It requires **both** of the following:
|
||||
|
||||
1. `@WorkflowImpl(taskQueues = "...")` or `@ActivityImpl(taskQueues = "...")` on the implementation class
|
||||
2. `spring.temporal.workersAutoDiscovery.packages` pointing to a package that contains those classes
|
||||
|
||||
Missing either one results in silent non-registration — no error, nothing polls the task queue.
|
||||
|
||||
The `taskQueues` attribute routes implementations to the right worker when multiple task queues exist. A worker configured with task queue `"greeting-queue"` only picks up implementations annotated with `taskQueues = "greeting-queue"`.
|
||||
|
||||
**Important:** `@ActivityImpl(taskQueues = "greeting-queue")` only registers the activity bean with that worker. It does not route individual activity task executions. Inside the workflow, `ActivityOptions.setTaskQueue("greeting-queue")` must also be set on the activity stub to route activity tasks to the correct queue.
|
||||
|
||||
### Comparison: Auto-Discovery vs Explicit YAML Registration
|
||||
|
||||
Auto-discovery via annotations:
|
||||
```properties
|
||||
spring.temporal.workersAutoDiscovery.packages=greetingapp
|
||||
```
|
||||
```java
|
||||
@Component
|
||||
@ActivityImpl(taskQueues = "greeting-queue")
|
||||
public class GreetActivitiesImpl implements GreetActivities { ... }
|
||||
```
|
||||
|
||||
Explicit YAML registration (alternative):
|
||||
```yaml
|
||||
spring:
|
||||
temporal:
|
||||
workers:
|
||||
- task-queue: greeting-queue
|
||||
name: greeting-worker
|
||||
activity-beans:
|
||||
- greetActivitiesImpl
|
||||
workflow-classes:
|
||||
- greetingapp.GreetingWorkflowImpl
|
||||
```
|
||||
|
||||
Use auto-discovery when implementations are colocated in a single package tree (most apps). Use explicit YAML when you need fine-grained control, want to exclude specific classes, or are registering beans defined elsewhere.
|
||||
|
||||
## WorkflowClient Injection
|
||||
|
||||
`WorkflowClient` is automatically registered as a Spring bean by the autoconfigure. Inject it into any `@Service` or `@RestController`:
|
||||
|
||||
```java
|
||||
package greetingapp;
|
||||
|
||||
import io.temporal.client.WorkflowClient;
|
||||
import io.temporal.client.WorkflowOptions;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class GreetingStarter {
|
||||
|
||||
private final WorkflowClient client;
|
||||
|
||||
public GreetingStarter(WorkflowClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public String startGreeting(String name) {
|
||||
var stub = client.newWorkflowStub(
|
||||
GreetingWorkflow.class,
|
||||
WorkflowOptions.newBuilder()
|
||||
.setWorkflowId(UUID.randomUUID().toString())
|
||||
.setTaskQueue("greeting-queue") // must match the worker's task queue
|
||||
.build()
|
||||
);
|
||||
// Synchronous — blocks until workflow completes
|
||||
return stub.greet(name);
|
||||
}
|
||||
|
||||
public void startGreetingAsync(String name) {
|
||||
var stub = client.newWorkflowStub(
|
||||
GreetingWorkflow.class,
|
||||
WorkflowOptions.newBuilder()
|
||||
.setWorkflowId(UUID.randomUUID().toString())
|
||||
.setTaskQueue("greeting-queue")
|
||||
.build()
|
||||
);
|
||||
// Fire-and-forget — returns immediately
|
||||
WorkflowClient.start(stub::greet, name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Worker Lifecycle
|
||||
|
||||
Workers start on `ApplicationReadyEvent` — after the full Spring context is initialized (DB migrations run, all beans wired). This means activity beans are fully ready before any workflow tasks are processed.
|
||||
|
||||
To run a client-only app (one that submits workflows but does not execute them):
|
||||
```properties
|
||||
spring.temporal.start-workers=false
|
||||
```
|
||||
|
||||
## Testing Strategies
|
||||
|
||||
See `references/java/testing.md` for full details on both approaches.
|
||||
|
||||
**Spring integration tests** — uses an embedded Temporal test server wired into the Spring context:
|
||||
```properties
|
||||
# src/test/resources/application-test.properties
|
||||
spring.temporal.test-server.enabled=true
|
||||
```
|
||||
```java
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class GreetingIntegrationTest {
|
||||
@Autowired WorkflowClient client; // points at the embedded test server
|
||||
|
||||
@Test
|
||||
void testWorkflowThroughSpringContext() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Unit tests without Spring** — use `TestWorkflowEnvironment` or `TestWorkflowExtension` directly. No Spring context, faster startup, full time-skipping support:
|
||||
```java
|
||||
@RegisterExtension
|
||||
static final TestWorkflowExtension testWorkflow = TestWorkflowExtension.newBuilder()
|
||||
.setWorkflowTypes(GreetingWorkflowImpl.class)
|
||||
.setDoNotStart(true)
|
||||
.build();
|
||||
```
|
||||
|
||||
Do not mix approaches in the same test class — choose one or the other.
|
||||
|
||||
## Spring-Specific Gotchas
|
||||
|
||||
**Workflow impls must not have `@Component`**
|
||||
Temporal creates a new workflow instance per execution via `beanFactory.createBean()` (not `getBean()`). Adding `@Component` means Spring also registers it as a singleton bean, which can cause confusing lifecycle behavior. Leave `@WorkflowImpl` classes as plain classes with no Spring annotations.
|
||||
|
||||
**Activity beans are Spring singletons**
|
||||
Temporal may invoke activity methods concurrently across many workflow executions. Keep activity implementations stateless — no mutable instance fields. Use injected services (which are themselves stateless or thread-safe) for all state.
|
||||
|
||||
**`@WorkflowImpl` / `@ActivityImpl` without `workersAutoDiscovery.packages` → silently ignored**
|
||||
This is the most common setup mistake. If auto-discovery packages are not configured, the annotations are never scanned and nothing registers with the worker. Verify with the Temporal UI that the worker is registering the expected workflow/activity types.
|
||||
|
||||
**`ActivityOptions.setTaskQueue(...)` is required on activity stubs**
|
||||
`@ActivityImpl(taskQueues = "greeting-queue")` registers the activity bean with the worker — it does not set the default task queue for activity execution. Inside workflow code, always set `.setTaskQueue(...)` in `ActivityOptions` to explicitly route activity tasks to the correct worker.
|
||||
|
||||
**Multiple `DataConverter` beans**
|
||||
If you define more than one `DataConverter` bean (e.g., a custom JSON converter and a default), the autoconfigure fails with an ambiguity error. Name one of them `mainDataConverter` to designate it as the primary.
|
||||
@@ -182,3 +182,74 @@ For activities that use `Activity.getExecutionContext()` or heartbeating, use `T
|
||||
4. Test replay compatibility when changing workflow code (see `references/java/determinism.md`)
|
||||
5. Test signal/query handlers explicitly
|
||||
6. Use unique task queues per test to avoid conflicts (handled automatically by `TestWorkflowExtension`)
|
||||
|
||||
## Spring Boot Testing
|
||||
|
||||
Two strategies — choose one per test class, do not mix them.
|
||||
|
||||
### Embedded test server in Spring context
|
||||
|
||||
For full integration tests that exercise the Spring context (DB, beans, config):
|
||||
|
||||
```properties
|
||||
# src/test/resources/application-test.properties
|
||||
spring.temporal.test-server.enabled=true
|
||||
```
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class TeeTimeMonitorIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
WorkflowClient client; // auto-configured to point at the embedded test server
|
||||
|
||||
@Test
|
||||
void testWorkflow() {
|
||||
var stub = client.newWorkflowStub(
|
||||
TeeTimeMonitorWorkflow.class,
|
||||
WorkflowOptions.newBuilder()
|
||||
.setWorkflowId("test-" + UUID.randomUUID())
|
||||
.setTaskQueue("golfnow")
|
||||
.build()
|
||||
);
|
||||
var result = stub.monitorTeeTimes(new TTMonitorRequest(...));
|
||||
assertNotNull(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The embedded server does not support time-skipping. Use this when you need Spring beans (real DB, email service, etc.) wired alongside Temporal.
|
||||
|
||||
### Unit tests without Spring context
|
||||
|
||||
For faster, isolated tests with time-skipping support, use `TestWorkflowExtension` or `TestWorkflowEnvironment` directly. No Spring context starts, so activity dependencies must be provided manually (real instances or Mockito mocks):
|
||||
|
||||
```java
|
||||
public class TeeTimeMonitorWorkflowTest {
|
||||
|
||||
@RegisterExtension
|
||||
static final TestWorkflowExtension testWorkflow = TestWorkflowExtension.newBuilder()
|
||||
.setWorkflowTypes(TeeTimeMonitorWorkflowImpl.class)
|
||||
.setDoNotStart(true)
|
||||
.build();
|
||||
|
||||
@Test
|
||||
void testWorkflow(TestWorkflowEnvironment env, Worker worker, WorkflowClient client) {
|
||||
GolfNowActivities activities = mock(GolfNowActivities.class, withSettings().withoutAnnotations());
|
||||
when(activities.searchTeeTimes(any())).thenReturn(List.of());
|
||||
|
||||
worker.registerActivitiesImplementations(activities);
|
||||
env.start();
|
||||
|
||||
var stub = client.newWorkflowStub(
|
||||
TeeTimeMonitorWorkflow.class,
|
||||
WorkflowOptions.newBuilder().setTaskQueue(worker.getTaskQueue()).build()
|
||||
);
|
||||
stub.monitorTeeTimes(new TTMonitorRequest(...));
|
||||
verify(activities).searchTeeTimes(any());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the sections above for more detail on mocking, signals/queries, and replay testing.
|
||||
|
||||
Reference in New Issue
Block a user