Skip to main content

Entity Workflow Pattern

Overview

The Entity Workflow pattern models long-lived business entities (users, accounts, devices, orders) as individual Workflows that persist for the entity's entire lifetime — potentially months or years. Each entity gets its own Workflow instance identified by the entity ID, handling all state transitions and operations through Signals and Updates.

Problem

Many business domains have entities that exist for extended periods, undergo multiple state transitions over their lifetime, need to maintain consistent state across operations, require audit trails of all changes, and must handle concurrent operations safely.

Traditional approaches struggle with these requirements:

  • Database-centric: Complex locking, race conditions, scattered business logic.
  • Event Sourcing: Requires rebuilding state from events, complex infrastructure.
  • Stateless Services: No built-in consistency, must coordinate state externally.
  • Short-lived Workflows: Do not model the full entity lifecycle.

Solution

You create one Workflow per entity, using the entity ID as the Workflow ID. The Workflow runs for the entity's entire lifetime, maintaining state in Workflow variables and handling operations via Signals and Updates. You use Continue-As-New periodically to prevent unbounded history growth.

The following describes each step in the diagram:

  1. The client starts the Workflow with a user ID. The Workflow initializes in the ACTIVE state.
  2. The client sends an Update to modify the profile. The Workflow validates the data via an Activity, persists the change, and returns success.
  3. The client sends a Signal to suspend the account. The Workflow transitions to SUSPENDED and starts a Child Workflow to send a notification email.
  4. The client sends an Update to reactivate the account. The Workflow transitions back to ACTIVE.
  5. After 1000 operations, the Workflow calls Continue-As-New to reset its history while preserving state.
  6. The client sends a Signal to delete the account. The Workflow transitions to DELETED and completes.

Implementation

The following examples show how each SDK implements the Entity Workflow pattern. Each implementation defines Update handlers for synchronous operations, Signal handlers for asynchronous events, and Query handlers for state inspection.

# workflows.py
from dataclasses import dataclass
from datetime import datetime, timedelta
from temporalio import workflow

with workflow.unsafe.imports_passed_through():
from activities import validate_profile

@dataclass
class UserState:
status: str = "ACTIVE"
profile: ProfileData | None = None
pending_email: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None

@workflow.defn
class UserAccountWorkflow:
def __init__(self) -> None:
self.state = UserState(created_at=datetime.utcnow())
self.deleted = False
self.operation_count = 0

@workflow.run
async def run(self, user_id: str) -> None:
# Block until deleted or Continue-As-New is suggested
await workflow.wait_condition(
lambda: self.deleted or workflow.info().is_continue_as_new_suggested()
)

if not self.deleted and workflow.info().is_continue_as_new_suggested():
await workflow.wait_condition(workflow.all_handlers_finished)
workflow.continue_as_new(user_id)

self.state.status = "DELETED"

@workflow.update
async def update_profile(self, data: ProfileData) -> None:
if self.deleted:
raise ValueError("User account is deleted")

await workflow.execute_activity(
validate_profile, data,
start_to_close_timeout=timedelta(seconds=30),
)

self.state.profile = data
self.state.updated_at = datetime.utcnow()
self.operation_count += 1

@workflow.update
async def suspend(self) -> None:
if not self.deleted and self.state.status != "SUSPENDED":
self.state.status = "SUSPENDED"
self.state.updated_at = datetime.utcnow()
self.operation_count += 1

@workflow.update
async def reactivate(self) -> None:
if not self.deleted and self.state.status == "SUSPENDED":
self.state.status = "ACTIVE"
self.state.updated_at = datetime.utcnow()
self.operation_count += 1

@workflow.signal
def delete(self) -> None:
self.deleted = True

@workflow.query
def get_state(self) -> UserState:
return self.state

The Workflow blocks on Workflow.await(() -> deleted) (Java), condition(() => deleted) (TypeScript), workflow.wait_condition(lambda: self.deleted) (Python), or selector.Select(ctx) (Go) until the delete Signal arrives. All state transitions happen through Signal and Update handlers, ensuring that every operation on the entity goes through a single Workflow with no race conditions. Continue-As-New is triggered from the main Workflow method (not from handlers) when isContinueAsNewSuggested() returns true. All SDK docs explicitly warn: do not call Continue-As-New from Update or Signal handlers. Instead, handlers set state, and the main Workflow method checks whether to Continue-As-New.

When to use

The Entity Workflow pattern is a good fit for user accounts and profiles, IoT devices and sensors, customer relationships (CRM), shopping carts and orders, financial accounts, subscription management, device provisioning and lifecycle, and multi-tenant resources.

It is not a good fit for short-lived processes (use regular Workflows), stateless operations (use Activities), high-frequency updates (more than 100 per second per entity), or entities with only CRUD operations (use a database).

Benefits and trade-offs

All operations on an entity go through a single Workflow, eliminating race conditions. The Workflow history provides a complete audit trail of all state changes. All entity logic lives in one place, and state survives process crashes and restarts. Temporal provides exactly-once execution and automatic retries. You can inspect current state through Queries without side effects.

The trade-offs to consider are that you must use Continue-As-New to prevent unbounded history growth. A single Workflow handles all operations for one entity, which limits throughput. State is kept in Workflow memory, so you should use Activities for large data. One Workflow per entity means you should consider costs at scale. The first operation after an idle period may have latency.

Comparison with alternatives

ApproachConsistencyAudit trailComplexityScalability
Entity WorkflowStrongCompleteLowHigh (per entity)
Database + LocksEventualManualHighVery High
Event SourcingStrongCompleteHighHigh
Stateless ServiceWeakManualMediumVery High

Best practices

  • Use entity ID as Workflow ID. This ensures uniqueness and idempotent starts.
  • Implement Continue-As-New. Use isContinueAsNewSuggested() to check when to continue. Always call Continue-As-New from the main Workflow method, never from handlers. Wait for all handlers to finish before continuing.
  • Validate in Updates. Use Updates for operations that require validation and a return value.
  • Use Signals for events. Use Signals for asynchronous notifications that do not need responses.
  • Keep state minimal. Store large data externally and reference it in the Workflow.
  • Add Queries. Expose state for monitoring and debugging.
  • Handle deletion. Implement an explicit deletion or decommission Signal.
  • Version carefully. Use Worker versioning for Workflow code changes.
  • Set timeouts. Use Workflow execution timeout as a safety net.
  • Monitor history size. Alert when approaching the Continue-As-New threshold.

Common pitfalls

  • Calling Continue-As-New from Signal or Update handlers. Continue-As-New must be called from the main Workflow method, never from inside a handler. Calling it from a handler causes non-determinism errors.
  • Not waiting for handlers to finish before Continue-As-New. Use allHandlersFinished (TypeScript), Workflow.isEveryHandlerFinished() (Java), or workflow.all_handlers_finished() (Python) to ensure in-flight handlers complete before transitioning.
  • Losing Update ID deduplication across Continue-As-New. Update IDs are scoped to a single Workflow Execution. After Continue-As-New, the same Update ID can be accepted again. Carry processed IDs in the Continue-As-New input if deduplication is needed.
  • Exceeding the 2 MB payload limit on Continue-As-New input. State passed to Continue-As-New is subject to the same 2 MB blob size limit as Workflow inputs. Use external storage for large state.
  • Using a hardcoded counter instead of isContinueAsNewSuggested. The SDK provides isContinueAsNewSuggested() which accounts for actual history size. Hardcoded thresholds may be too aggressive or too lenient.

Sample code

Python:

Go:

Java:

TypeScript:

References