Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions references/java/java.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,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.
Expand Down Expand Up @@ -238,6 +240,7 @@ See `references/java/testing.md` for info on writing tests.
## Additional Resources

### 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
Expand Down
287 changes: 287 additions & 0 deletions references/java/spring-boot.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions references/java/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.