diff --git a/references/java/java.md b/references/java/java.md index e18d723..2583913 100644 --- a/references/java/java.md +++ b/references/java/java.md @@ -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. @@ -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 diff --git a/references/java/spring-boot.md b/references/java/spring-boot.md new file mode 100644 index 0000000..ceaaaec --- /dev/null +++ b/references/java/spring-boot.md @@ -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 + + io.temporal + temporal-spring-boot-starter + [1.0,) + +``` + +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. diff --git a/references/java/testing.md b/references/java/testing.md index 80ed9b2..b46db29 100644 --- a/references/java/testing.md +++ b/references/java/testing.md @@ -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.