Skip to content

adiputera/spring-boot-temporal

Repository files navigation

spring-temporal

Sample project showing how to orchestrate a place-order + post-payment consignment flow across five Spring Boot services with Temporal, using a parent OrderWorkflow that blocks on a child ConsignmentWorkflow after payment succeeds.

  • Stack: Spring Boot 4.0.5, Java 25, Temporal Java SDK 1.32.1 (temporal-spring-boot-starter).
  • Transport between services is Temporal itself — no HTTP calls between services. Each service runs its own Temporal worker on a dedicated task queue.
  • All business actions are dummy — they only emit a decorated 3-line banner to the log so you can watch the workflow progress in real time.

Architecture

                             ┌─────────────────────────┐
                             │  Temporal server :7233  │
                             │  Temporal UI    :8080   │
                             └──────┬──────────────────┘
                                    │
     ┌───────────────┬──────────────┼──────────────┬──────────────┐
     │               │              │              │              │
┌────┴─────────┐ ┌───┴──────────┐ ┌─┴──────────┐ ┌─┴──────────┐ ┌─┴─────────────┐
│order-service │ │email-service │ │payment-svc │ │cms-service │ │consignment-   │
│:8081         │ │:8082         │ │:8083       │ │:8084       │ │   service     │
│              │ │              │ │            │ │            │ │:8085          │
│POST /place-  │ │worker on     │ │worker on   │ │POST /trig- │ │worker on      │
│     order    │ │  email-tq    │ │ payment-tq │ │  ger-work- │ │ consignment-tq│
│parent WF on  │ │activities:   │ │activities: │ │  flow      │ │CHILD WF on    │
│  order-tq    │ │ sendOrder..  │ │ checkPay.. │ │POST /reset-│ │ consignment-tq│
│              │ │ sendPayment  │ │ cancelOrd. │ │  workflow  │ │activities:    │
│on payment    │ │ sendCancel   │ │            │ │POST /cancel│ │ checkShipping │
│ success:     │ │              │ │POST        │ │POST /term- │ │               │
│ starts CHILD │ │              │ │  /payment  │ │  inate     │ │POST /shipment │
│ ConsignmentWF│ │              │ │GET /check- │ │GET /work-  │ │POST /customer-│
│ on           │ │              │ │    payment │ │    flows   │ │     confirm   │
│ consignment- │ │              │ │            │ │GET /work-  │ │GET  /check-   │
│ tq, blocks   │ │              │ │            │ │  flows/{id}│ │     shipping  │
│ until return │ │              │ │            │ │ [+ /query] │ │               │
└──────────────┘ └──────────────┘ └────────────┘ └────────────┘ └───────────────┘

Flow

  1. Client calls POST /place-order on order-service with {"orderId":"..."}.
  2. order-service starts OrderWorkflow with workflow id order-{orderId} on order-tq.
  3. Workflow dispatches EmailActivities.sendOrderPlacedNotification to email-tq.
  4. Workflow does Workflow.await(Duration.ofMinutes(1), () -> paymentReceived).
  5. Either:
    • Signal arrives in time — client hit POST /payment?orderId=... on payment-service → controller signals the workflow → workflow dispatches sendPaymentCompletedNotification.
    • 1-minute timeout — workflow dispatches PaymentActivities.checkPayment to payment-tq. Rule: true if the numeric order id is even, false if odd.
      • truesendPaymentCompletedNotification.
      • falsecancelOrder + sendOrderCancelledNotification.
  6. On payment success (either signal or checkPayment=true) — OrderWorkflow starts a child ConsignmentWorkflow with id consignment-{orderId} on consignment-tq and blocks on it. The parent does not finish until the child returns.

Consignment child flow (consignment-service)

ConsignmentWorkflow owns the post-payment fulfilment:

  1. Banner CONSIGNMENT STARTED, then Workflow.await(() -> shipmentDispatched) — indefinite wait for POST /shipment?orderId=... on consignment-service.
  2. Signal arrives → banner SHIPMENT RECEIVED, then OUT FOR DELIVERYWorkflow.sleep(Duration.ofMinutes(2)) (durable timer).
  3. After sleep, activity ShippingActivities.checkShipping on consignment-tq — rule: orderId ending in 9 → undelivered, otherwise delivered. The activity is flaky on attempt 1 (throws ApplicationFailure("FlakySimulated")) and the stub's RetryPolicy retries 2s later; attempt 2 returns the real result.
  4. If delivered → banner DELIVEREDWorkflow.await(Duration.ofMinutes(1), () -> customerConfirmed) (optional customer confirmation via POST /customer-confirm?orderId=...; either signal or 1-minute timeout, flow proceeds either way) → banner CONSIGNMENT COMPLETE → child returns ConsignmentResult.DELIVERED → parent banner WORKFLOW COMPLETED → parent run completes.
  5. If NOT delivered → banner UNDELIVERED → child returns ConsignmentResult.UNDELIVERED immediately (no customer-confirm step) → parent runs compensation: PaymentActivities.refundPayment + EmailActivities.sendOrderCancelledNotificationWORKFLOW COMPLETED.

Recovery path (cms-service)

If a workflow already cancelled but reconciliation shows the customer actually paid, POST /reset-workflow on cms-service calls Temporal's ResetWorkflowExecution to rewind the workflow to an earlier WorkflowTaskCompleted event — typically event 10 (just before the 60s timer starts) — creating a new run that re-enters Workflow.await(...). A follow-up POST /payment then takes the happy branch.

Project layout

spring-temporal/
├── pom.xml                       parent (Spring Boot 4.0.5, Java 25, Temporal 1.32.1)
├── docker-compose.yml            postgres + temporal + temporal-ui + 5 services
├── common/                       shared workflow/activity interfaces, DTOs, task-queue names
├── order-service/                OrderController, OrderWorkflowImpl (parent — dispatches child on payment success)
├── email-service/                EmailActivitiesImpl
├── payment-service/              PaymentController, PaymentActivitiesImpl, PaymentRules
├── cms-service/                  TriggerController, ResetController, LifecycleController, ListController, DetailController, QueryController (operational gRPC)
└── consignment-service/          ConsignmentWorkflowImpl (child), ShippingActivitiesImpl, ShippingController, ShippingRules

The common module

common is a pure contracts jar — it contains no service logic, only the types both sides of every Temporal interaction must agree on.

Class Role
id.adiputera.common.TaskQueues Constants for the four task queues (order-tq, email-tq, payment-tq, consignment-tq). One spelling, referenced everywhere.
id.adiputera.common.dto.PlaceOrderRequest Request body for POST /place-order and input to OrderWorkflow.placeOrder.
id.adiputera.common.workflow.OrderWorkflow @WorkflowInterface for the parent workflow (placeOrder + paymentReceived signal). order-service implements, payment-service signals.
id.adiputera.common.workflow.ConsignmentWorkflow @WorkflowInterface for the child workflow (startConsignment + shipmentDispatched / customerConfirmed signals). consignment-service implements, order-service invokes as child, consignment-service's own HTTP endpoints signal it.
id.adiputera.common.activity.EmailActivities @ActivityInterface used to build a stub in the workflow and implemented by email-service.
id.adiputera.common.activity.PaymentActivities Same pattern — stub in workflow, implementation in payment-service.
id.adiputera.common.activity.ShippingActivities Same pattern — stub in child workflow, implementation in consignment-service.

Why put contracts in a shared module?

Temporal requires the same interface on both sides of every stub/implementation pair. The workflow builds a stub via Workflow.newActivityStub(EmailActivities.class, …) and the worker registers an implementation of the same EmailActivities type; if each service carried its own copy, a method rename in one would diverge silently and blow up at runtime (method not found on the worker). Keeping the interface in common makes them compile against the same signatures.

It also means services depend on each other's contracts without depending on each other's code: order-service/pom.xml depends only on common, not on email-service or payment-service. You could swap out either implementation without touching order-service.

Input/output types live here too for the same reason: PlaceOrderRequest is the HTTP body Jackson deserializes in OrderController and the value Temporal serializes into the workflow's history — one class, one shape.

Prerequisites

  • Docker + Docker Compose (for the one-shot path below), or
  • Java 25, Maven 3.9+, and a running Temporal server at localhost:7233 (e.g. temporal server start-dev) for local dev.

Run with Docker Compose

docker compose up --build

Brings up eight containers: Postgres, Temporal server, Temporal UI, and the five Spring Boot services. Services come up on:

Service URL
order-service http://localhost:8081
email-service http://localhost:8082 (no public API)
payment-service http://localhost:8083
cms-service http://localhost:8084
consignment-service http://localhost:8085
Temporal UI http://localhost:8080
Temporal gRPC localhost:7233

Stop with Ctrl+C then docker compose down (add -v to wipe the postgres volume).

Run locally without Docker

Start a dev Temporal server in one terminal:

temporal server start-dev

Build once, then run each service (separate terminals):

mvn -q -DskipTests package
java -jar order-service/target/order-service-0.1.0.jar
java -jar email-service/target/email-service-0.1.0.jar
java -jar payment-service/target/payment-service-0.1.0.jar
java -jar cms-service/target/cms-service-0.1.0.jar
java -jar consignment-service/target/consignment-service-0.1.0.jar

Each service reads TEMPORAL_TARGET (default localhost:7233).

API reference

order-service

Method Path Body / Params Description
POST /place-order { "orderId": "1001" } Starts OrderWorkflow with id order-1001.

payment-service

Method Path Params Description
POST /payment ?orderId=1001 Sends the paymentReceived signal to workflow order-1001.
GET /check-payment ?orderId=42 Returns {"completed": true|false}true iff the id is even.

consignment-service

Method Path Params Description
POST /shipment ?orderId=1001 Sends the shipmentDispatched signal to child workflow consignment-1001. Unblocks step 1 of the child.
POST /customer-confirm ?orderId=1001 Sends the customerConfirmed signal. Optional — the workflow waits up to 1 minute for this, then proceeds anyway.
GET /check-shipping ?orderId=42 Returns {"delivered": true|false}false iff the orderId ends in 9, otherwise true. Orthogonal to /check-payment's parity rule so an even-id-ending-in-9 (e.g. 2009) passes payment but fails shipping, demoing the saga.

cms-service

Method Path Body / Params Description
POST /trigger-workflow { "id": "order-1001", "workflowType": "OrderWorkflow", "taskQueue": "order-tq", "input": {...} } Generic workflow launcher — starts any registered workflow type on any task queue via an untyped stub. input is optional and accepts any JSON (object, string, number). Returns 202 with the new runId; 409 if the workflow id is already running. See the caveats section below.
POST /reset-workflow { "orderId": "1001", "eventId": 10, "reason": "..." } Resets workflow order-{orderId} to the given WorkflowTaskCompleted event id; returns the new run id.
POST /cancel ?workflowId=... Graceful cancel (workflow can catch CanceledFailure and compensate; in this sample it doesn't, so run ends with status CANCELED). Cascades to children via parentClosePolicy.
POST /terminate ?workflowId=...&reason=... Hard kill — no chance to compensate. Parent and any in-flight children end with status TERMINATED.
GET /workflows ?status=, ?workflowType=, ?workflowId=, ?query= Lists every workflow in the namespace with its status + most recently scheduled activity and that activity's state. Each structured param accepts a comma-separated list (or the param repeated); single value → =, multi-value → IN (...). All filters are optional and combined with AND.
GET /workflows/{workflowId} ?runId= (optional — defaults to latest run) Full detail for a single run: identity, status, timing, input, result/failure, signal timeline, activity timeline (per-step timing + input/result + a resetEventId you can pass to /reset-workflow to roll back to before each activity was scheduled), timer timeline, pending activities + pending children, execution timeouts, pending workflow task, and reset/continue-as-new lineage. Returns 404 if the workflow id has no run.
GET /workflows/{workflowId}/query ?name=currentStep&runId=<optional> Invokes a @QueryMethod handler on the workflow (synchronous, no history event emitted). name=currentStep returns a short identifier of where the workflow is right now. Works on both running and closed workflows.

GET /workflows response shape

One element per run (so a reset workflow shows up twice — once per run id). lastActivity is the name of the most recently scheduled activity; lastActivityState is one of scheduled, started, completed, failed, timed_out, canceled.

[
  {
    "workflowId": "order-2003",
    "runId": "19f82473-...",
    "workflowType": "OrderWorkflow",
    "status": "COMPLETED",
    "startTime": "2026-04-23T07:30:15Z",
    "closeTime": "2026-04-23T07:30:21Z",
    "lastActivity": "SendPaymentCompletedNotification",
    "lastActivityState": "completed"
  },
  {
    "workflowId": "order-3001",
    "runId": "5501e9a9-...",
    "workflowType": "OrderWorkflow",
    "status": "RUNNING",
    "startTime": "2026-04-23T07:41:53Z",
    "closeTime": null,
    "lastActivity": "SendOrderPlacedNotification",
    "lastActivityState": "completed"
  }
]

Filter params

All four params are optional and any combination is AND-ed together before being sent to Temporal as a visibility query. Each structured param accepts a single value, a comma-separated list, or the param repeated — the builder emits = for a single value and IN (...) for multiple.

Param Example Becomes
status ?status=RUNNING ExecutionStatus='Running'
status ?status=RUNNING,COMPLETED ExecutionStatus IN ('Running', 'Completed')
workflowType ?workflowType=OrderWorkflow,ApprovalWorkflow WorkflowType IN ('OrderWorkflow', 'ApprovalWorkflow')
workflowId ?workflowId=order-1001,order-2003 WorkflowId IN ('order-1001', 'order-2003')
query ?query=StartTime>'...' (<verbatim>) — escape hatch for ranges, OR clauses, time windows, etc.

status is case-insensitive (RUNNING, running, Running all resolve to the canonical Running). Valid values: Running, Completed, Failed, Canceled, Terminated, ContinuedAsNew, TimedOut — see Workflow statuses for what each one means and when it occurs in this sample. Comma-split and param-repetition are equivalent — ?status=RUNNING,COMPLETED and ?status=RUNNING&status=COMPLETED produce the same query.

curl 'localhost:8084/workflows?status=RUNNING'
curl 'localhost:8084/workflows?status=RUNNING,COMPLETED'                     # IN clause
curl 'localhost:8084/workflows?status=RUNNING&status=COMPLETED'              # same, repeated param form
curl 'localhost:8084/workflows?workflowType=OrderWorkflow'
curl 'localhost:8084/workflows?workflowId=order-1001,order-2003'             # 3 runs across 2 workflow ids
curl 'localhost:8084/workflows?status=COMPLETED&workflowId=order-2003'       # AND: just the completed ones
curl "localhost:8084/workflows?status=COMPLETED&query=StartTime%3E'2026-04-23T00:00:00Z'"  # structured + raw

Cost note: the endpoint fetches the full history for every workflow returned by the list call (to find the last activity). Fine for this sample (a few dozen workflows); for a production namespace with tens of thousands, persist "last activity" into a Search Attribute instead so the list call alone can answer the question.

GET /workflows/{workflowId} response shape

Assembled from two Temporal calls per request: DescribeWorkflowExecution (identity, status, timing, pending activities) + GetWorkflowExecutionHistory (signal/activity/timer timelines, input/result/failure payloads). Pass ?runId=... to pick a specific run; omit to get the latest run of the workflow id.

{
  "workflowId": "order-2003",
  "runId": "19f82473-...",
  "workflowType": "OrderWorkflow",
  "status": "COMPLETED",
  "taskQueue": "order-tq",
  "startTime": "2026-04-23T07:30:15.810Z",
  "closeTime": "2026-04-23T07:30:21.547Z",
  "historyLength": 25,
  "firstRunId": "f59b1cde-...",          // original (pre-reset) run id
  "continuedFromRunId": null,            // set on continue-as-new, null on reset
  "input":  ["{\"orderId\":\"2003\"}"],
  "result": [],
  "failure": null,
  "signals": [
    { "eventId": 15, "name": "paymentReceived", "time": "...", "input": [] }
  ],
  "activities": [
    {
      "scheduledEventId": 5,
      "type": "SendOrderPlacedNotification",
      "taskQueue": "email-tq",
      "input": ["\"2003\""],
      "state": "completed",
      "attempt": 1,
      "scheduledTime": "...",
      "startedTime": "...",
      "closedTime": "...",
      "result": [],
      "failure": null,
      "resetEventId": null               // scheduled by the first workflow task — nothing before it to reset to
    },
    { "scheduledEventId": 19, "type": "SendPaymentCompletedNotification", "state": "completed", "resetEventId": 13, ... }
                                         //                                                     ↑ pass this to /reset-workflow to undo this activity being scheduled
  ],
  "timers": [
    { "eventId": 14, "timerId": "086b3a4f-...", "duration": "PT1M", "startedTime": "...", "firedTime": null, "canceledTime": null }
  ],
  "executionTimeouts": {                 // from WorkflowExecutionConfig — fixed at start time
    "workflowExecutionTimeout": null,    // null = no limit (not set in this sample)
    "workflowRunTimeout": "PT10M",       // set on parent; 20 min on child via ChildWorkflowOptions
    "workflowTaskTimeout": "PT10S"       // Temporal default
  },
  "pendingActivities":  [],              // non-empty only while workflow is running AND blocked on an activity
  "pendingChildren":    [],              // non-empty only while workflow is running AND blocked on a child workflow
  "pendingWorkflowTask": null            // populated only while a worker is actively progressing the workflow
}

What Temporal exposes (that this endpoint surfaces):

Temporal source Fields on response
DescribeWorkflowExecutionWorkflowExecutionInfo status, startTime, closeTime, historyLength, workflowType
DescribeWorkflowExecutionWorkflowExecutionConfig taskQueue, executionTimeouts (workflowExecutionTimeout, workflowRunTimeout, workflowTaskTimeout)
DescribeWorkflowExecutionPendingActivities pendingActivities[] — activities scheduled on a task queue but not yet terminal (with attempt, maxAttempts, lastFailure)
DescribeWorkflowExecutionPendingChildren pendingChildren[] — child workflows started by this workflow that haven't yet completed (with their workflowId, runId, type, and parentClosePolicy)
DescribeWorkflowExecutionPendingWorkflowTask pendingWorkflowTask — the decision task currently scheduled/in-progress (with state, attempt); null between tasks
WorkflowExecutionStartedEventAttributes (event 1) input, firstRunId, continuedFromRunId
WorkflowExecutionCompleted/Failed/Canceled (terminal event) result or failure
WorkflowExecutionSignaledEventAttributes signals[]
ActivityTaskScheduled/Started/Completed/Failed/TimedOut/Canceled activities[] (the builder coalesces all five event types per scheduledEventId)
WorkflowTaskCompleted (id of the one before each ActivityTaskScheduled) activities[].resetEventId — pass this to /reset-workflow to roll back to before this activity was scheduled; null for activities scheduled by the very first workflow task
TimerStarted/Fired/Canceled timers[]

Not yet surfaced (but available via the same two gRPC calls if you want to extend):

  • Search attributes / memo (WorkflowExecutionInfo.searchAttributes, .memo)
  • Parent workflow back-reference on the child (WorkflowExecutionInfo.parentExecution)
  • Per-event causation metadata (WorkflowTaskScheduled/Started/Completed/Failed/Timeout)
  • Heartbeat details on pending activities (PendingActivityInfo.heartbeatDetails)

Custom business state via @QueryMethod is surfaced as a separate GET /workflows/{id}/query endpoint (not on the detail response).

Why a reset endpoint?

The default happy/sad paths are deterministic — signal in time → paid, timeout + odd id → cancelled. Reality adds a third case: the workflow already cancelled the order because checkPayment said false, but later reconciliation shows the customer did pay (gateway outage, webhook lost, etc.). Starting a brand-new workflow would resend the initial "order placed" email, pollute metrics with a duplicate order, and lose the original history.

Temporal's ResetWorkflowExecution solves this precisely: pick an event id from the original history (typically the WorkflowTaskCompleted just after sendOrderPlacedNotification and just before the timer fires), and Temporal replays up to that event, discards everything after, and re-enters the workflow from that state. A subsequent POST /payment on payment-service then drives the happy path. The new run id is returned so callers can follow it in the UI.

The event id is workflow-history specific. Two ways to obtain it: read it from GET /workflows/{id} — every scheduled activity in the response carries a resetEventId field that is exactly the value to pass here to undo that activity being scheduled — or browse the Temporal UI (http://localhost:8080 → pick the workflow → History tab) and pick a WorkflowTaskCompleted event id by hand. The first form is preferred for ops-from-CLI; the UI form is the fallback when you need a reset point that isn't tied to an activity (rare). The endpoint is kept on its own service because reset is an operational action (post-incident, manual audit), not a business flow — keeping it off the customer-facing services means you can lock it down separately (different network, different auth) without touching order/payment.

Choosing which activity's resetEventId to read. Pick the first activity that put the workflow on the wrong path, not the last one you wish hadn't happened. For OrderWorkflow's cancel-after-timeout scenario, that's CheckPayment (the activity that returned false and triggered the cancel branch) — its resetEventId rewinds past the timer-fired task and back into the Workflow.await(...) state. Reading CancelOrder.resetEventId instead would only rewind one task: the workflow would re-run with checkPayment's false result still in history and re-cancel.

POST /trigger-workflow — generic launcher

Each owning service starts its own workflow from a typed controller (order-serviceOrderWorkflow). That coupling is fine for the business path, but leaves no way to trigger a workflow from outside its owner — useful when replaying an incident, bootstrapping test data, or starting a workflow whose owner isn't up. POST /trigger-workflow on cms-service fills that gap: it's a thin pass-through to WorkflowClient.newUntypedWorkflowStub(workflowType, options).start(input), with workflowType, taskQueue, id, and input supplied verbatim by the caller.

Design constraint: adding a new workflow type to the project must not require editing cms-service. The controller therefore keeps no registry — caller supplies the Temporal workflow type name and task queue directly. Trade-off: the caller has to know those two values (both are already visible in application.yml of the owning service).

# OrderWorkflow — input is the same JSON shape that order-service's PlaceOrderRequest serialises to
curl -X POST localhost:8084/trigger-workflow \
  -H 'Content-Type: application/json' \
  -d '{"id":"order-9001","workflowType":"OrderWorkflow","taskQueue":"order-tq","input":{"orderId":"9001"}}'
# → 202 {"workflowId":"order-9001","runId":"…","workflowType":"OrderWorkflow"}

# ConsignmentWorkflow — a @WorkflowMethod that takes a raw String; pass the string as-is
curl -X POST localhost:8084/trigger-workflow \
  -H 'Content-Type: application/json' \
  -d '{"id":"consignment-9002","workflowType":"ConsignmentWorkflow","taskQueue":"consignment-tq","input":"9002"}'

Caveats:

  • Temporal does not reject unknown workflow types at start(). The server has no per-task-queue registry of workflow types; start() always succeeds and returns a runId. A typo surfaces only on the worker side as repeated WorkflowTaskFailed events (visible in GET /workflows/{id}pendingWorkflowTask.attempt climbing, lastFailure.message = "Unknown workflow type: …"). Wrong task queue with no worker polling it leaves the run stuck at WorkflowExecutionStarted with zero history progress. In both cases, clean up with POST /terminate?workflowId=….
  • Starting a child-only workflow standalone skips the parent's compensation. ConsignmentWorkflow is normally run as a child of OrderWorkflow, whose impl reacts to ConsignmentResult.UNDELIVERED with refundPayment + sendOrderCancelledNotification. Triggering ConsignmentWorkflow via this endpoint has no parent, so on UNDELIVERED the workflow simply closes with that result and compensation does not run. Correct for an ops-initiated trigger, but don't use this endpoint to drive the regular business path.
  • 409 on duplicate id. Temporal's WorkflowExecutionAlreadyStarted is translated to HTTP 409. To retry, either terminate the prior run via POST /terminate?workflowId=… and re-trigger, or pick a fresh id.

Workflow statuses

Every Temporal workflow execution has exactly one of seven status values. The /workflows list, the /workflows/{id} detail endpoint, and the Temporal UI all use the short form (the gRPC enum is WORKFLOW_EXECUTION_STATUS_<STATUS>; this sample strips that prefix everywhere).

Status Meaning How to reach it in this sample
RUNNING Active — at least one pending workflow task, activity, timer, or signal wait Any in-flight workflow: parent waiting for payment signal, parent blocked on child, child sleeping 2 min for delivery
COMPLETED Workflow returned normally (success or any "business finished" branch) Happy path, checkPayment=true path, cancel-order branch, UNDELIVERED compensation branch (all return normally)
FAILED Workflow threw an unhandled ApplicationFailure / runtime exception Not produced by this sample — activities are retried 3× and eventually succeed; no branch rethrows out of the workflow
CANCELED Someone called CancelWorkflow RPC and the workflow acknowledged POST /cancel?workflowId=order-… on cms-service — cascades to any in-flight child
TERMINATED Someone called TerminateWorkflow RPC — no rollback, just hard stop POST /terminate?workflowId=order-…&reason=… on cms-service — cascades to child via parentClosePolicy
CONTINUED_AS_NEW Workflow called Workflow.continueAsNew(...) — a new run replaces this one Not used here (a typical use is trimming long histories by restarting with fresh state)
TIMED_OUT Exceeded a WorkflowRunTimeout or WorkflowExecutionTimeout Place an order and don't signal anything — the parent's 10-min workflowRunTimeout fires, status flips to TIMED_OUT. Child has its own 20-min timeout.

Status vs. activity state. The status on the detail endpoint is workflow-level (one of the seven above). Individual activities within a workflow track their own lifecycle in activities[].statescheduledstartedcompleted / failed / timed_out / canceled. A workflow with status: RUNNING may legitimately show all of its past activities as completed; the "running" part is that the workflow is currently on a timer or await.

Status after reset. ResetWorkflowExecution leaves the original run with its terminal status (typically COMPLETED for the cancel branch) and creates a new run — a new row in /workflows with status: RUNNING sharing the same workflowId, pointing back via firstRunId to the original. Neither run moves between statuses; the reset produces a fresh execution.

Parent / child status coupling. When OrderWorkflow starts ConsignmentWorkflow as a blocking child, both runs are RUNNING simultaneously — two rows in /workflows. The parent transitions to COMPLETED only after the child hits COMPLETED. If the child fails or is terminated, the parent's child-workflow call rethrows the failure, which surfaces on the parent as FAILED (unless caught).

# All active work right now
curl 'localhost:8084/workflows?status=RUNNING'

# Everything that ran a business-cancel branch (still COMPLETED at Temporal level)
curl "localhost:8084/workflows?status=COMPLETED" \
  | jq '.[] | select(.lastActivity=="SendOrderCancelledNotification")'

# Both runs of a reset workflow — the original (terminal) + the reset (current)
curl 'localhost:8084/workflows?workflowId=order-2003'

Status inventory after running the sample

After driving the Try-it recipes end-to-end against a fresh namespace, the list endpoint shows workflows across four distinct terminal states:

$ curl -s 'localhost:8084/workflows' | jq 'group_by(.status) | map({status: .[0].status, count: length})'
[
  { "status": "CANCELED",   "count": 2 },    // order-9100 + consignment-9100 (cascade)
  { "status": "COMPLETED",  "count": 14 },   // happy path, saga compensation, cancel-payment branch, reset runs
  { "status": "TERMINATED", "count": 4 }     // order-9200/6000 + their cascaded children
]

RUNNING is ephemeral and only visible while work is in flight; TIMED_OUT is reachable via the 10-minute workflowRunTimeout by placing an order and walking away. FAILED and CONTINUED_AS_NEW are not exercised by the current code — see the Workflow-statuses table above for what it would take.

Live response while consignment is active

After POST /place-order + POST /payment but before POST /shipment, the parent is blocked on the child and both runs are RUNNING. Here's exactly what the two endpoints return in that state (captured live for orderId=6000).

GET /workflows?workflowId=order-6000,consignment-6000 — list view shows both rows RUNNING, parent's lastActivity is the last thing it did (send the payment email), not the child it's currently blocked on:

[
  {
    "workflowId": "consignment-6000",
    "runId": "f741e35d-...",
    "workflowType": "ConsignmentWorkflow",
    "status": "RUNNING",
    "closeTime": null,
    "lastActivity": null,            // child hasn't scheduled any activity yet — still in await-for-shipment
    "lastActivityState": null
  },
  {
    "workflowId": "order-6000",
    "runId": "6b8a6f28-...",
    "workflowType": "OrderWorkflow",
    "status": "RUNNING",
    "closeTime": null,
    "lastActivity": "SendPaymentCompletedNotification",   // last thing the PARENT did
    "lastActivityState": "completed"
  }
]

The list endpoint alone doesn't tell you "parent is blocked on child" — you need the detail endpoint for that.

GET /workflows/order-6000 — parent detail, key fields only:

{
  "workflowId": "order-6000",
  "status": "RUNNING",
  "historyLength": 26,
  "signals":          [ { "eventId": 12, "name": "paymentReceived", ... } ],
  "activities":       [ /* SendOrderPlaced (completed), SendPaymentCompleted (completed) */ ],
  "pendingActivities": [],         // NOT blocked on an activity
  "pendingChildren":  [            // blocked on a child workflow — this is the "why"
    {
      "workflowId": "consignment-6000",
      "runId": "f741e35d-...",
      "workflowType": "ConsignmentWorkflow",
      "initiatedEventId": 22,
      "parentClosePolicy": "terminate"
    }
  ]
}

GET /workflows/consignment-6000 — child detail, awaiting its first signal:

{
  "workflowId": "consignment-6000",
  "status": "RUNNING",
  "historyLength": 4,            // only Started + WorkflowTask Scheduled/Started/Completed — then await
  "input":  ["\"6000\""],         // orderId passed by parent
  "signals":          [],         // shipmentDispatched hasn't arrived
  "activities":       [],
  "timers":           [],         // no timer — Workflow.await(() -> flag) is indefinite
  "pendingActivities": [],
  "pendingChildren":  []
}

Interpretation: pendingChildren on the parent is the signal that it's waiting on a child — directly analogous to how pendingActivities signals it's waiting on an activity. Neither populates a field in the list endpoint; to see them you always need the detail call. parentClosePolicy: "terminate" (the Temporal default) means: if the parent is terminated, Temporal cascades a terminate to the child — so you usually don't orphan a child by killing the parent. Other options: abandon (child keeps running independently), request_cancel (child receives a cancellation request).

Status transition at the moment the child returns:

  1. Child emits CONSIGNMENT COMPLETE banner, workflow method returns.
  2. Child's ChildWorkflowExecutionCompleted event lands in the parent's history.
  3. Parent's blocking consignment.startConsignment(orderId) call returns in order-service's worker.
  4. Parent logs WORKFLOW COMPLETED and its workflow method returns.
  5. Both runs' status flip to COMPLETED within milliseconds of each other (verified end-to-end for order-5000: child closed at 08:17:39.647Z, parent at 08:17:39.671Z — 24ms apart).

Try it

Full happy path — signal payment within 1 minute, then drive the child consignment workflow:

curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"1001"}'
curl -X POST 'localhost:8083/payment?orderId=1001'        # parent receives signal, starts child
curl -X POST 'localhost:8085/shipment?orderId=1001'       # unblocks child step 1 (waits 2 minutes)
# ... wait ~2 minutes for the out-for-delivery timer ...
curl -X POST 'localhost:8085/customer-confirm?orderId=1001'  # optional; child auto-proceeds after 1 minute

Minimal flow without the child (when payment succeeds, child still starts — you must signal /shipment or the parent will block forever, timing-out only if you set a WorkflowRunTimeout, which this sample does not). To test just the order+cancel path, use an odd orderId that will go down the cancel branch and skip consignment entirely:

curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"2001"}'
# wait > 60s → timeout → checkPayment=false (odd) → cancelled, no consignment

Timeout + even id (checkPayment returns true → payment completed):

curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"2000"}'
# wait > 60s

Timeout + odd id (checkPayment returns false → order cancelled):

curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"2001"}'
# wait > 60s

Direct /check-payment sanity:

curl 'localhost:8083/check-payment?orderId=42'   # {"completed":true}
curl 'localhost:8083/check-payment?orderId=43'   # {"completed":false}

Saga / compensation path — orderId ending in 9 triggers UNDELIVERED → parent refunds + cancels:

curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"2009"}'
curl -X POST 'localhost:8083/payment?orderId=2009'
curl -X POST 'localhost:8085/shipment?orderId=2009'
# wait ~2 min → checkShipping returns false → UNDELIVERED → refundPayment → sendOrderCancelledNotification → WORKFLOW COMPLETED

Read-only query for current workflow step (no history event emitted; works on both running and closed workflows):

curl 'localhost:8084/workflows/order-7000/query?name=currentStep'          # e.g. PAID_WAITING_FOR_CONSIGNMENT
curl 'localhost:8084/workflows/consignment-7000/query?name=currentStep'     # e.g. WAITING_FOR_SHIPMENT → OUT_FOR_DELIVERY → DELIVERED_WAITING_FOR_CUSTOMER → COMPLETE

Graceful cancel (cascades to child) — workflow status CANCELED:

curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"9100"}'
curl -X POST 'localhost:8083/payment?orderId=9100'
curl -X POST 'localhost:8084/cancel?workflowId=order-9100'
# both order-9100 and consignment-9100 end up with status CANCELED

Hard terminate (cascades to child) — workflow status TERMINATED:

curl -X POST 'localhost:8084/terminate?workflowId=order-9200&reason=ops+test'
# both order-9200 and consignment-9200 end up with status TERMINATED

Timeout demo — place an order and don't signal anything. After 10 minutes the parent's workflowRunTimeout fires → status TIMED_OUT:

curl -X POST localhost:8081/place-order -H 'Content-Type: application/json' -d '{"orderId":"9999"}'
# wait > 10 min, do not signal /payment

Reset a cancelled workflow back to wait-for-payment (e.g. after orderId=2001 went down the cancel path) — pull the reset point straight from the detail endpoint, no Temporal UI round-trip:

# 1. Read CheckPayment.resetEventId — the value that rewinds past the timer-fired task
#    and back into Workflow.await(...). Picking CancelOrder.resetEventId would only
#    rewind one task and re-cancel; see "Choosing which activity's resetEventId to
#    read" in the reset section above.
event_id=$(curl -s localhost:8084/workflows/order-2001 \
  | jq '.activities[] | select(.type=="CheckPayment") | .resetEventId')

# 2. Reset to that event, then signal.
curl -X POST localhost:8084/reset-workflow \
  -H 'Content-Type: application/json' \
  -d "{\"orderId\":\"2001\",\"eventId\":$event_id,\"reason\":\"gateway reconciliation\"}"
curl -X POST 'localhost:8083/payment?orderId=2001'

The workflow resumes at Workflow.await(...) with a fresh run id; the POST /payment signal then completes it via the happy path.

Log banner format

Each event produces three log lines: a decoration line (the glyph repeated 10×), the message, another decoration line. Example:

##########
sendOrderPlacedNotification 1001
##########

Expected log output (happy path with consignment)

Traced end-to-end for orderId=7000 (even → happy-path payment + not ending in 9 → delivered):

# t=0s — place
>>>>>>>>>>  POST /place-order 7000           (order-service)
**********  ORDER PLACED: 7000                (order-service)
##########  sendOrderPlacedNotification 7000  (email-service)

# t=2s — signal payment + shipment, signal customer-confirm early
<<<<<<<<<<  POST /payment 7000                (payment-service)
@@@@@@@@@@  PAYMENT SIGNAL RECEIVED 7000      (order-service)
++++++++++  sendPaymentCompletedNotification  (email-service)
::::::::::  CONSIGNMENT STARTED 7000          (consignment-service)
{{{{{{{{{{  POST /shipment 7000               (consignment-service)
||||||||||  SHIPMENT RECEIVED 7000            (consignment-service)
//////////  OUT FOR DELIVERY 7000             (consignment-service, starts 2-min timer)
[[[[[[[[[[  POST /customer-confirm 7000       (consignment-service, buffered)

# t=2m05s — 2-min timer fires; checkShipping is flaky on attempt 1
\\\\\\\\\\  checkShipping 7000 FLAKY-FAIL attempt=1  (consignment-service)
# ...2s retry backoff (visible in pendingActivities[].attempt=2 during the gap)...
((((((((((  checkShipping 7000 -> true (attempt=2)   (consignment-service)
..........  DELIVERED 7000                    (consignment-service)
""""""""""  CUSTOMER CONFIRMED 7000           (consignment-service, signal was already buffered)
''''''''''  CONSIGNMENT COMPLETE 7000         (consignment-service)

# child returns DELIVERED → parent resumes
$$$$$$$$$$  WORKFLOW COMPLETED 7000           (order-service)

Expected log output (saga compensation)

Traced end-to-end for orderId=2009 (even → payment succeeds; ends in 9 → checkShipping returns false → UNDELIVERED → parent compensates):

# t=0–5s — place, signal payment, signal shipment (same as happy path)
...
//////////  OUT FOR DELIVERY 2009             (consignment-service)

# t=2m05s
\\\\\\\\\\  checkShipping 2009 FLAKY-FAIL attempt=1   (consignment-service)
((((((((((  checkShipping 2009 -> false (attempt=2)   (consignment-service)
]]]]]]]]]]  UNDELIVERED 2009                  (consignment-service)

# child returns UNDELIVERED → parent runs compensation
))))))))))  refundPayment 2009                (payment-service)
----------  sendOrderCancelledNotification 2009  (email-service)
$$$$$$$$$$  WORKFLOW COMPLETED 2009           (order-service)

Expected log output (reset scenario)

Traced end-to-end for orderId=2003 (odd id → cancelled after timeout → reset to event 10 → signal → completed happy path):

# t=0s — place order
>>>>>>>>>>  POST /place-order 2003              (order-service)
**********  ORDER PLACED: 2003                  (order-service)
##########  sendOrderPlacedNotification 2003    (email-service)

# t=60s — 1-minute timer fires, odd id → cancel branch
!!!!!!!!!!  TIMEOUT - CHECKING PAYMENT 2003     (order-service)
==========  checkPayment 2003 -> false          (payment-service)
~~~~~~~~~~  cancelOrder 2003                    (payment-service)
----------  sendOrderCancelledNotification 2003 (email-service)
$$$$$$$$$$  WORKFLOW COMPLETED 2003             (order-service)    # cancelled run

# t=95s — reconciliation: customer actually paid; reset + signal
&&&&&&&&&&  POST /reset-workflow order-2003 eventId=10  (cms-service)
<<<<<<<<<<  POST /payment 2003                          (payment-service)
@@@@@@@@@@  PAYMENT SIGNAL RECEIVED 2003                (order-service)  # on NEW run
++++++++++  sendPaymentCompletedNotification 2003       (email-service)
$$$$$$$$$$  WORKFLOW COMPLETED 2003                     (order-service)  # happy-path run

Note that sendOrderPlacedNotification is not re-emitted after reset — event 10 sits after that activity completed, so replay stops just short of re-scheduling it. Pick an earlier event id (e.g. 4) if you want the initial notification to re-fire.

Log legend

Every action uses its own glyph so a scan of the combined logs tells you exactly where you are in the flow.

Where Action Glyph
order-service POST /place-order controller >
order-service Workflow: ORDER PLACED *
order-service Workflow: PAYMENT SIGNAL RECEIVED @
order-service Workflow: TIMEOUT — CHECKING PAYMENT !
order-service Workflow: WORKFLOW COMPLETED $
email-service sendOrderPlacedNotification #
email-service sendPaymentCompletedNotification +
email-service sendOrderCancelledNotification -
payment-service checkPayment activity =
payment-service cancelOrder activity ~
payment-service POST /payment controller <
payment-service GET /check-payment controller %
cms-service POST /reset-workflow controller &
cms-service GET /workflows controller ?
cms-service GET /workflows/{id} controller ^
consignment-svc Child WF: CONSIGNMENT STARTED :
consignment-svc Child WF: SHIPMENT RECEIVED |
consignment-svc Child WF: OUT FOR DELIVERY /
consignment-svc checkShipping activity (
consignment-svc Child WF: DELIVERED .
consignment-svc Child WF: CUSTOMER CONFIRMED / TIMEOUT "
consignment-svc Child WF: CONSIGNMENT COMPLETE '
consignment-svc POST /shipment controller {
consignment-svc POST /customer-confirm controller [
consignment-svc GET /check-shipping controller _
consignment-svc Child WF: UNDELIVERED ]
payment-service refundPayment activity (compensation) )
payment-service checkPayment / checkShipping FLAKY-FAIL (attempt 1) \
cms-service POST /cancel controller ;
cms-service POST /terminate controller ,
cms-service GET /workflows/{id}/query controller }
cms-service POST /trigger-workflow controller `

Key bits of code

  • common/src/main/java/id/adiputera/common/workflow/OrderWorkflow.java@WorkflowInterface with the paymentReceived signal method.
  • order-service/src/main/java/id/adiputera/order/workflow/OrderWorkflowImpl.java — workflow logic; builds one activity stub per task queue via Workflow.newActivityStub(...) with ActivityOptions.setTaskQueue(...).
  • payment-service/src/main/java/id/adiputera/payment/api/PaymentController.java/payment calls workflowClient.newUntypedWorkflowStub("order-" + orderId).signal("paymentReceived").
  • cms-service/src/main/java/id/adiputera/cms/api/ResetController.java/reset-workflow calls the low-level WorkflowServiceStubs.blockingStub().resetWorkflowExecution(...) gRPC with a workflowTaskFinishEventId pointing at the desired reset point in history.
  • cms-service/src/main/java/id/adiputera/cms/api/ListController.java/workflows pages ListWorkflowExecutions, then for each run fetches GetWorkflowExecutionHistory and walks the event list to find the most recently scheduled activity plus its current state (scheduled/started/completed/failed).
  • cms-service/src/main/java/id/adiputera/cms/api/DetailController.java/workflows/{workflowId} combines DescribeWorkflowExecution (identity, status, pending activities) with a full history walk to assemble input/result/failure payloads, signal/activity/timer timelines, and reset lineage.
  • common/src/main/java/id/adiputera/common/workflow/ConsignmentWorkflow.java@WorkflowInterface for the child workflow with two signal methods (shipmentDispatched, customerConfirmed).
  • consignment-service/src/main/java/id/adiputera/consignment/workflow/ConsignmentWorkflowImpl.java — child workflow logic: indefinite await for shipment → Workflow.sleep(2m)checkShipping activity → 1-minute await for customer confirmation → return (parent resumes).
  • order-service/.../OrderWorkflowImpl.java#runConsignment — parent starts the child via Workflow.newChildWorkflowStub(ConsignmentWorkflow.class, ChildWorkflowOptions.setTaskQueue(CONSIGNMENT_TQ)) and calls startConsignment(orderId) synchronously; the blocking call returns only after the child completes. Return value is ConsignmentResultUNDELIVERED triggers the compensation path (refundPayment + sendOrderCancelledNotification).
  • cms-service/src/main/java/id/adiputera/cms/api/LifecycleController.javaPOST /cancel and POST /terminate using WorkflowStub.cancel() / WorkflowStub.terminate(reason). Both cascade to children.
  • cms-service/src/main/java/id/adiputera/cms/api/QueryController.javaGET /workflows/{id}/query?name=... invokes @QueryMethod handlers via WorkflowStub.query(name, String.class).
  • cms-service/src/main/java/id/adiputera/cms/api/TriggerController.javaPOST /trigger-workflow is a generic launcher; takes {id, workflowType, taskQueue, input} and forwards to workflowClient.newUntypedWorkflowStub(workflowType, WorkflowOptions).start(input). No per-type code — a new workflow type added elsewhere in the project works here without edits.
  • common/src/main/java/id/adiputera/common/dto/ConsignmentResult.java — enum { DELIVERED, UNDELIVERED } returned by the child and inspected by the parent to branch into compensation.
  • Flaky activities: PaymentActivitiesImpl.checkPayment and ShippingActivitiesImpl.checkShipping throw ApplicationFailure on Activity.getExecutionContext().getInfo().getAttempt() == 1; the workflow stubs attach a RetryOptions.setMaximumAttempts(3).setInitialInterval(2s) retry policy.

Notes / gotchas

  • Workflow id convention is order-{orderId}. POST /payment with an unknown id returns a Temporal NotFound error.
  • orderId must be numeric (Long.parseLong is used for parity); non-numeric ids will throw.
  • Temporal UI at http://localhost:8080 shows full history — handy to inspect the TimerFired vs WorkflowSignaled branch. For /reset-workflow you usually don't need it: every activity in GET /workflows/{id} carries a resetEventId (the previous WorkflowTaskCompleted event id) — pass that value to roll back to before that activity was scheduled.
  • /reset-workflow must be given a WorkflowTaskCompleted event id; other event types are rejected by Temporal with InvalidArgument. The detail endpoint's resetEventId field is always such an event (or null for the very first task). In this sample, CheckPayment.resetEventId rewinds to "about to wait for signal" without re-sending the order-placed email; SendOrderPlacedNotification.resetEventId is null because there is no preceding task close — to rewind all the way to "start" and re-send the email, look up event 4 in the UI.
  • Activities are local no-op logs; no email or payment gateway is actually called.

About

Sample project showing how to orchestrate a place-order + post-payment consignment flow across five Spring Boot services with Temporal, using a parent OrderWorkflow that blocks on a child ConsignmentWorkflow after payment succeeds.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors