top of page
Search

Building Domain-Driven Entities with Temporal Workflows

  • Michael Read
  • 7 days ago
  • 11 min read

Updated: 22 hours ago


Introduction

This blog post, along with its companion Github repository, demonstrates how to build, test, and run Entities using Temporal Workflows and the Java SDK.


Our demonstration use case for this blog is a streaming data pipeline that collects user purchase events and simply aggregates purchase totals and total amount spent by each user in a unique User Entity.


Prerequisites

This post assumes you have:

  • Familiarity with Java 21+

  • Basic understanding of Domain-Driven Design concepts

  • Some experience with event-driven architectures

  • Access to a Temporal Cluster (or ability to run Temporal locally)


What are Entities?

In Domain-Driven Design (DDD), an Entity is a fundamental concept for modeling business logic. Unlike regular programming objects, Entities are defined by their unique identity and carefully controlled changes. They represent core business rules while remaining independent of databases, user interfaces, and external frameworks—making them a cornerstone of modern software architecture.


Entities are great for tracking traditional business resources such as a Customer, Order, or Account. However, in the era of IoT (Internet of Things), Entities are indispensable for bridging the physical and digital worlds. They shine where physical objects require a persistent unique digital identity that evolves over time.

Key IoT Entity use cases include:


  • Digital Twins and State Synchronization - examples of digital representations of physical assets could include vehicles, cloud infrastructure, stand-by batteries, generators, phones, etc. They synchronize real-time sensor data—such as temperature, location, health, power level, and load—with the digital model, accurately reflecting the state of the physical world. This “Single Source of Truth” centralizes representation of the device state, eliminates data silos across systems, and provides a consistent view accessible to all stakeholders.Commands can also be sent from the Entity to the physical asset to facilitate changes in state of the physical device by triggering actuators to open a window, or shut off power in case of emergency.  

  • Complex Device Lifecycle Management - IoT devices often operate for years. Entities encapsulate their entire lifecycle logic through its distinct phases such as provisioned, active, maintenance, decommissioned. For example, an Entity can enforce business rules such as “a device cannot receive software updates if it's under heavy usage, been decommissioned, or it’s during certain times of the day”.

  • Encapsulating Domain Logic and Safety Invariants - entities are ideal for housing critical business logic and safety standards close to the data. For example, a battery power supply Entity could notify authorities if the temperature sensor reads above a certain safety threshold.


Why Temporal?

Temporal Workflows provide simple, powerful tools for creating resilient and scalable systems. Combined with Entities, they create an ideal foundation for building robust business applications. 


Temporal’s primitives align almost directly with DDD’s concepts for Entity implementation.


Temporal / DDD Alignment


Workflow ID ↔ Entity Identity

A workflow's stable ID provides the same uniqueness guarantees that traditional entity identities offer. Rather than maintaining separate identity management, your running workflow serves directly as the entity instance.


Continue-As-New provides this simplification, which we'll discuss in detail below.


Signals and Updates ↔ Command Methods

Both provide controlled entry points for state mutations. Temporal's Update methods can validate and reject invalid transitions before applying changes, mirroring how an aggregate protects its invariants.


Queries ↔ Query Methods

Both queries and query methods enable state inspection without mutation. This inherent separation between read and write operations means CQRS emerges organically from the workflow design rather than being artificially imposed.


For context: Command Query Responsibility Segregation (CQRS) is an architectural pattern that divides an application's operations into queries (reads) and commands (writes). With Temporal workflows, this separation comes naturally.


Durable Execution ↔ Persistence Ignorance

The Entity's state lives as simple in-memory fields while the platform handles persistence through event-sourced replay. This makes DDD's "the model doesn't know about the database" principle operationally real, not just theoretical.


Single-Threaded Execution ↔ Aggregate Consistency Boundary

Workflows process commands sequentially, providing serializable consistency without locks or optimistic concurrency patterns. This eliminates concurrency anomalies at the infrastructure level.


The Workflow/Activity Split↔Architecture by Design

Temporal workflows create a clean architectural boundary that mirrors the domain/adapter separation found in hexagonal architecture—but with a crucial difference: the runtime enforces it.


Here's how it works:


  • Workflows contain pure, deterministic business logic

  • Activities handle everything external and unpredictable—web service calls, I/O operations, database queries


This isn't just a convention developers can ignore. The system prevents you from mixing these concerns, giving you architectural discipline without relying on team agreements or code reviews.


Traits of a Temporal Entity Workflow


Entity State

Your entity's state should reflect your specific use case and requirements. However, there's one universal rule: keep all state in a single, JSON-serializable object.


Java Implementation

In Java, you have two main options:

  • Records (recommended)

  • Classes


I strongly prefer Records because they're immutable by design and naturally encourage putting business logic directly into the state object itself.


Handling Activities

When you need to call an Activity, follow this pattern:

  • Call the Activity from your workflow's signal method

  • Pass the result into your state object


This keeps your state management clean and predictable.


Event History Limits

Temporal records each workflow’s state change as events in an Event History. This history enables Durable Execution by allowing crash recovery by replaying event history to reconstruct state and then continued progress. For performance reasons, Temporal enforces limits on event history size and item count.


Entity Lifecycle

Most Temporal workflows have a beginning and an end, much like a project does. Unlike a project, most entities have a beginning and don’t ever end. Of course it depends on the domain, and of course there are some use cases where entities have short lifecycles. You need to be able to support both.


Given Temporal's history limits and the fact that entities often run indefinitely, you'll need a strategy for when you approach these limits. That path forward is Temporal’s Continue-As-New feature. Continue-As-New lets a workflow close successfully and creates a new copy while giving developers the ability to maintain the current state.


Think of Continue-As-New as a controlled restart: your workflow completes successfully, preserves its state, and immediately begins a fresh instance—preventing history limits from halting execution. Temporal even provides a method to check when it thinks you’re running close to a history limit called Workflow.getInfo().isContinueAsNewSuggested().


Being able to pass your Entity’s current state into the new Entity is a key consideration when creating your workflow through the Workflow Method. More on this below.


The Primary Loop

The Entity workflow begins when the Workflow Method is called, and the workflow ends when the Workflow Method ends. To keep your workflow running for the long haul you’ll need a primary loop that has the ability to execute the Continue-As-New process when needed with your current state as well as to optionally end if needed. You don’t want your primary loop to run uncontrollably, so you’ll want to pause for a time on each iteration. The Workflow.await() is your friend in this case. For example:

        do {
            Workflow.await(maxAwaitTime, () -> exitRequested);
        } while (!exitRequested && !shouldContinueAsNew());

Communicating with your Entity

The Entity workflow accepts commands to update state through Temporal’s Signal methods. These methods don’t return anything and run asynchronously. You can have as many Signal methods as your use case calls for.



For the read-side, Temporal’s Query methods are used. Because these methods block, it’s usually best to just return information that can be derived from the Entity’s state. 


Error Handling

One of Temporal's greatest strengths is how it handles failures and recovery—almost entirely automatically. This section explains how error handling works and what you need to do as a developer.


Workflow Error Handling

Temporal's error handling relies on two key principles:


  1. Deterministic execution — Workflow code must produce the exact same output given the same inputs and initial state, regardless of how many times it executes. This determinism enables reliable recovery.

  2. Event replay — When a failure occurs, Temporal reconstructs the workflow's state by replaying its event history, then resumes execution from the point of failure.


This means you rarely need to write explicit error handling code for your workflow logic. Temporal handles the heavy lifting automatically:

  • The workflow recovers seamlessly from failures

  • State is restored through event replay

  • Execution resumes without manual intervention


Developer Responsibilities

While Temporal manages most recovery, your job is to design workflows that support it:

  • Maintain determinism — Keep all randomness, external I/O, and non-repeatable operations out of workflow code

  • Design idempotent signals — Since signals may be processed multiple times during recovery, your entity state transitions should be safe to repeat

  • Choose appropriate retry policies — Configure retry behavior at the workflow and activity level based on your failure modes


The goal isn't to prevent all errors—it's to design workflows that recover gracefully when errors occur.


Activity Error Handling

Activities require slightly different error handling considerations, since they execute outside the workflow's deterministic context.


Successful activities have their results journaled by Temporal and reused during recovery—no re-execution needed.


Failed activities must be retried. Temporal provides retry policies that you can configure, which gives you control over how your activities respond to transient failures while keeping the workflow logic clean and focused on business rules.


Challenges of using Temporal to build Entities

Building entities with Temporal requires careful design choices. The platform is commonly used for orchestrating processes and sagas, so using it for entities means working with some specific considerations:


  • History Management - Long-running entities accumulate event history. Implement Continue-As-New proactively to manage history growth and stay within Temporal's limits.

  • Deterministic Logic - Entity domain logic must be deterministic. External I/O operations belong in activities, keeping the workflow code pure and replayable.

  • Cross-Entity Queries - Temporal excels at representing a single Entity's state and behavior. For queries across multiple entities—like "find all customers in state X"—you'll need to project data into a separate read model.

  • Scale and Cost - Managing large populations of long-lived entities introduces real scale and cost considerations. Design your Entity boundaries and lifecycle carefully to address these upfront.


These challenges aren't obstacles—they're design decisions that shape how you structure your system.


Building your Entity

Start with the Interface

When working with Temporal’s Java SDK every workflow starts with an annotated interface. For example, our UserEntityWorkflow interface looks like this:


@WorkflowInterface
public interface UserEntityWorkflow {
    @WorkflowMethod
    String create(UserInput input);

    @SignalMethod
    void purchaseEvent(UserPurchaseEvent name);

    @SignalMethod
    void exit();

    @QueryMethod
    UserState getEntity();

}

Annotate our interface with @WorkflowInterface.

This simply tags our interface as a Temporal workflow interface.


@WorkflowMethod String create(UserInput input);

This is our workflow method that is called by Temporal when the Entity workflow starts. It returns a simple string when the workflow finishes. The UserInput provides the following structure:


public record UserInput(
        String userId,
        Optional<UserState> userState,
        boolean testContinueAsNew
) {}

As you can see we’re passing in the UserId, an optional UserState, which is passed in during invocation when checkpointing our workflow with Temporal’s Continue-As-New method, and finally a boolean to declare if we’re testing the Continue-As-New functionality. 


@SignalMethod void purchaseEvent(UserPurchaseEvent name);

Here we’re declaring our method to receive an incoming UserPurchaseEvent. 


Signal methods are asynchronous—they return immediately without waiting for the Signal to be processed. This is ideal for fire-and-forget scenarios such as is needed for our streaming data pipeline use case.


If you need to update a workflow's state and verify the result, use a synchronous @UpdateMethod instead. Update methods block until the operation completes and return a result to the caller.


@SignalMethod  void exit();

This Signal causes the workflow to end. While we don’t need it for our use case we’re including it here because we need it for testing. More on that below.


@QueryMethod  UserState getEntity();

This Query simply returns the current state of our Entity. 


Constructor for configuration

Constructors aren’t required by Temporal, however you can provide one for pulling in configuration settings if needed. For example:

Here we’re pulling in our polling interval from the properties file as a default and then overriding the setting from an environment variable if available.


Working with state

Our state is represented as a Java object and contains an empty() method, and business logic. We’re careful with mutation by always returning a completely new version of state.  For example:



Additionally, because our Entity is working as part of a streaming data pipeline we’ve implemented idempotence by ignoring messages seen recently. This is done by capturing and maintaining a simple lookback window of message IDs that have been recently processed. The size of the window is really based upon some tradeoffs between the total size of the state, and how often a given user makes a purchase. In this example fourteen seems about right.


The Query method

Here we simply return the current state. In this case, the state must be serializable as JSON.


The exit method

The exit method simply flips that flag causing the workflow to end by stopping the primary workflow loop. While we don't need it for our use case, we need it for unit tests.


Testing the workflow

We’re using JUnit 5 (Jupiter) with Temporal’s TestWorkflowExtension and TestWorkflowEnvironment that allows us to test all aspects of our Entity's workflow.


Here we register the Temporal TestWorkflowExtension.


    @RegisterExtension
    public static final 
		TestWorkflowExtension testWorkflowExtension =
            TestWorkflowExtension.newBuilder()
                    .setWorkflowTypes(UserEntityWorkflowImpl.class)
                    .setDoNotStart(true)
                    .build();

By using this extension, each unit test method is automatically injected with the TestWorkflowEnvironment, a Worker, and the UserEntityWorkflow that are needed for testing.


Then with each unit test we start the TestWorkflowEnvironment and execute the following tests:

  • startUserEntityWorkflowAndExit - starts the workflow, and simply shuts it down.

  • testUserEntityWorkflowSignals - starts the workflow, sends some purchase events, and then shuts it down, and verifies the state.

  • testUserEntityWorkflowIgnoreDups - starts the workflow, sends some purchase events, and sends some duplicates, and shuts it down, and verifies that the state doesn’t include the duplicates.

  • testUserEntityWorkflowStartAsNew - starts the workflow, sends ten purchase events, waits for a new workflow to start, and verifies the state. It should be noted when starting the workflow we’re sending the test flag in the UserInput that sets the max events to seven, thus causing the workflow to checkpoint and start a new instance.


You can see all the tests directly in Github here


A useful testing pattern is to call workflow.exit() before checking test results. This guarantees all pending Signals have been processed before you verify the workflow's behavior. Since the state remains available after stopping, you can confidently confirm the expected outcome.


Working with the Workflow

Since our use case is a streaming data pipeline we’re sending purchase events that are consumed from a message broker, and then routed to the appropriate workflow. 


Given the streaming nature of our use case the consumer doesn’t know if the Entity workflow is already running for any given user. To solve this problem our consumer is leveraging Temporal’s Signal-With-Start method that is provided through an Untyped Workflow Stub. 


Signal-With-Start starts a new workflow if it’s not already running but sends the Signal immediately, and then calls the designated WorkflowMethod after which is create() in our case. Since the Signal method can be called prior to the create() method the Entity workflow needs to be properly initialized in order to process the Signal before the workflow is officially started with the create() call. For example, 


UserState userState = UserState.empty();

If the state is not properly initialized you’ll see Null Pointer Exceptions which would cause the workflow to fail.


In our consumer’s constructor we wire our Temporal client to provide observability metrics for Prometheus scraping, collect settings for our Entity workflow prefix for the ID of the workflow, and finally the Temporal Task Queue to send Signals:


UserEventConsumer.java (constructor snippet)
UserEventConsumer.java (constructor snippet)

As the consumer stream is in operation then as each purchase event comes through as a Protobuf payload we parse it, create a UserPurchaseEvent, create a UserInput record, create Untyped Workflow Stub, and finally call the Signal-with-start method with both the UserPurchaseEvent, and UserInput records.


UserEventConsumer.java (stream flow snippet)
UserEventConsumer.java (stream flow snippet)

We’ve also provided a simplified example of this process, without observability, in Github here.


Building the worker

The Temporal architecture requires that you must provide workers to execute your workflows. Workers are typically straightforward to implement. Our approach is to pick up configuration from a properties file for things like the Temporal Server target, HTTPS enablement, and Prometheus scrape port. Environment variables can also override these default settings.


Next we provide observability metrics for Prometheus scraping, create the Temporal Server and client, start the worker, and finally register the Entity's workflow implementation.


Conclusion

Building Entities with Temporal Workflows provides a powerful pattern for creating resilient, long-running business logic that can survive failures and scale horizontally. The combination of durable execution, event history, and the Continue-As-New pattern enables you to build entities that maintain state reliably over extended periods.


Key takeaways from this approach:


  • Single state object: Keep your Entity state in one serializable object for simplicity and consistency

  • Immutable updates: Always return new state objects rather than mutating existing ones

  • Signal-with-start: Use this pattern to handle the uncertainty of whether a workflow already exists

  • Checkpointing: Implement Continue-As-New proactively to manage event history limits

  • Comprehensive testing: Leverage Temporal's test framework to validate all aspects of your Entity's behavior


The companion GitHub repository provides a complete, runnable example that you can adapt for your own use cases. Whether you're building event-driven microservices, streaming data pipelines, or long-running business processes, this pattern offers a solid foundation for resilient, maintainable code.


Ready to get started? Clone the repository, run the tests, and begin building your own Temporal Entity workflows.


About Tensor7

Tensor7 is a boutique consultancy of distributed systems experts, principal engineers, data engineers, applied AI specialists, and business strategists. We help organizations journey from AI curiosity to highly profitable, enterprise-grade production systems — covering strategy, design, prototyping, implementation, evaluation, and operation. Our consultants have delivered 55+ systems across e-commerce, insurance, fintech, manufacturing, travel, logistics, and media, with a 98% client satisfaction rate.

We bring the rigor of distributed systems to agentic AI — because safe, reliable, and scalable are not optional.


Ready to explore what Temporal-powered workflows could mean for your business?

Reach out at info@Tensor7.ai and visit tensor7.ai.

 
 
bottom of page