~/architect
#enterprise-integration-patterns #temporal #hexagonal-architecture #java #spring-boot #workflow-orchestration

The EIP Process Manager Pattern with Temporal, Spring Boot, and Hexagonal Architecture

The Process Manager pattern gives long-running business workflows an explicit, stateful home — and Temporal is the infrastructure that makes that state durable without leaking into your domain.

Distributed workflows that span multiple services, wait for external events, and require compensation on failure are deceptively hard. Most teams reach for ad-hoc solutions — a status column in a database, a chain of REST calls, maybe a Saga half-implemented in a message consumer — and then spend months debugging race conditions. The EIP Process Manager pattern gives this problem a name and a clear contract. Temporal gives it a runtime. Hexagonal architecture keeps your business logic honest.

What the EIP Process Manager Pattern Actually Says

The Process Manager (Hohpe & Woolf, EIP) is a stateful router. It receives messages, correlates them to an ongoing process instance, advances internal state, and dispatches commands to the next participant. Unlike a simple Routing Slip, the Process Manager decides the next step dynamically based on accumulated state.

Three structural responsibilities define it:

  1. Correlation — every incoming message must be matched to the correct process instance (by order ID, booking reference, etc.).
  2. State tracking — the manager remembers where the process is and what happened so far.
  3. Command dispatch — it knows what to send next and to whom.

The pattern is intentionally silent on how state is stored or how correlation works. That’s the implementation concern — and that’s exactly where Temporal steps in.

Temporal’s Concepts and the Mapping

Temporal models workflows as durable functions: ordinary Java methods that may sleep for days, survive process crashes, and receive external signals — because every state transition is persisted as an immutable event in a server-managed history.

EIP Process ManagerTemporal concept
Process instanceWorkflowExecution (identified by workflowId)
Correlation IDworkflowId — used to route signals and queries
Process stateReconstructed deterministically from event history
External message inSignal (async) or Update (sync, returns a value)
Step / command dispatchActivity — a unit of potentially non-deterministic work
Waiting for a callbackworkflow.sleep() + signal, or async activity completion
Timeoutworkflow.sleep(Duration) before a signal arrives
CompensationRegular activities executed in a catch block

The critical insight: you do not manage the state store. Temporal’s server owns the event history. Your workflow code is just logic — no if (status == AWAITING_PAYMENT) persisted to a DB column anywhere.

// Temporal Process Manager: Request Lifecycle
sequenceDiagram
participant Client
participant TemporalServer as Temporal Server
participant Worker as Spring Worker
participant ActivityImpl as Activity (Adapter)
participant ExternalSvc as External Service

Client->>TemporalServer: StartWorkflow(orderId)
TemporalServer->>Worker: Schedule WorkflowTask
Worker->>Worker: Execute WorkflowImpl (deterministic)
Worker->>TemporalServer: Schedule ActivityTask (reserveInventory)
TemporalServer->>Worker: Dispatch ActivityTask
Worker->>ActivityImpl: reserveInventory(orderId)
ActivityImpl->>ExternalSvc: POST /inventory/reserve
ExternalSvc-->>ActivityImpl: 200 OK
ActivityImpl-->>TemporalServer: Activity complete
TemporalServer->>Worker: Schedule WorkflowTask (continue)
Worker->>Worker: Advance state → chargePayment
Note over Worker,TemporalServer: Workflow sleeps waiting for payment signal
Client->>TemporalServer: Signal(paymentConfirmed, orderId)
TemporalServer->>Worker: Deliver signal to workflow
Worker->>Worker: Unblock, dispatch next activity

Hexagonal Architecture: Temporal as an Adapter

In hexagonal (ports & adapters) architecture, the domain defines what happens through port interfaces. Infrastructure adapters provide how it happens. Temporal lives entirely in the infrastructure ring — your workflow interface is the port, your Temporal WorkflowImpl is the adapter.

// Hexagonal Layers — Temporal Adapter Position
flowchart LR
subgraph Domain["Domain (core)"]
  D1[OrderFulfillmentPort]
  D2[InventoryPort]
  D3[PaymentPort]
end
subgraph Application["Application"]
  A1[OrderFulfillmentService]
end
subgraph Infrastructure["Infrastructure (adapters)"]
  T1[OrderFulfillmentWorkflowImpl
Temporal Workflow]
  T2[InventoryActivityImpl
Temporal Activity]
  T3[PaymentActivityImpl
Temporal Activity]
  T4[TemporalWorkerConfig
Spring Bean]
end

A1 --> D1
D1 -.->|implemented by| T1
D2 -.->|implemented by| T2
D3 -.->|implemented by| T3
T4 --> T1
T4 --> T2
T4 --> T3

The domain never imports io.temporal.*. The application layer triggers the workflow through a port. Only the infrastructure package knows Temporal exists.

Implementation

Domain Port

// domain/port/out/OrderFulfillmentPort.java
public interface OrderFulfillmentPort {
    void startFulfillment(String orderId, OrderDetails details);
    void confirmPayment(String orderId, PaymentConfirmation confirmation);
}

Activity ports are standard interfaces — no Temporal annotations here:

// domain/port/out/InventoryPort.java
public interface InventoryPort {
    ReservationResult reserve(String orderId, List<LineItem> items);
    void release(String orderId);
}

// domain/port/out/PaymentPort.java
public interface PaymentPort {
    ChargeResult charge(String orderId, Money amount);
    void refund(String orderId);
}

Temporal Workflow Adapter

// infrastructure/temporal/workflow/OrderFulfillmentWorkflowImpl.java
@WorkflowImpl(taskQueues = "order-fulfillment")
public class OrderFulfillmentWorkflowImpl implements OrderFulfillmentWorkflow {

    private final InventoryActivities inventory =
        Workflow.newActivityStub(InventoryActivities.class,
            ActivityOptions.newBuilder()
                .setStartToCloseTimeout(Duration.ofSeconds(30))
                .setRetryOptions(RetryOptions.newBuilder()
                    .setMaximumAttempts(3).build())
                .build());

    private final PaymentActivities payment =
        Workflow.newActivityStub(PaymentActivities.class,
            ActivityOptions.newBuilder()
                .setStartToCloseTimeout(Duration.ofSeconds(60))
                .build());

    // Process Manager state — reconstructed deterministically from history
    private PaymentConfirmation pendingPayment;
    private boolean paymentReceived = false;

    @Override
    public void fulfill(String orderId, OrderDetails details) {
        // Step 1 — reserve inventory
        ReservationResult reservation = inventory.reserve(orderId, details.items());

        if (!reservation.success()) {
            Workflow.getLogger(getClass()).warn("Inventory unavailable for {}", orderId);
            return;
        }

        // Step 2 — wait for payment signal (up to 24h)
        boolean signaled = Workflow.await(
            Duration.ofHours(24), () -> paymentReceived);

        if (!signaled) {
            // Compensation: timeout → release inventory
            inventory.release(orderId);
            return;
        }

        // Step 3 — charge
        ChargeResult charge = payment.charge(orderId, pendingPayment.amount());

        if (!charge.success()) {
            // Compensation chain
            inventory.release(orderId);
        }
    }

    @Override
    @SignalMethod
    public void confirmPayment(String orderId, PaymentConfirmation confirmation) {
        this.pendingPayment = confirmation;
        this.paymentReceived = true;
    }
}

Activity Adapters

// infrastructure/temporal/activity/InventoryActivityAdapter.java
@Component
public class InventoryActivityAdapter implements InventoryActivities {

    private final InventoryPort inventoryPort; // injected Spring bean

    public InventoryActivityAdapter(InventoryPort inventoryPort) {
        this.inventoryPort = inventoryPort;
    }

    @Override
    public ReservationResult reserve(String orderId, List<LineItem> items) {
        return inventoryPort.reserve(orderId, items);
    }

    @Override
    public void release(String orderId) {
        inventoryPort.release(orderId);
    }
}

Spring Boot Worker Registration

// infrastructure/temporal/config/TemporalWorkerConfig.java
@Configuration
public class TemporalWorkerConfig {

    @Bean
    public Worker orderFulfillmentWorker(
            WorkerFactory factory,
            OrderFulfillmentWorkflowImpl workflow,
            InventoryActivityAdapter inventoryAdapter,
            PaymentActivityAdapter paymentAdapter) {

        Worker worker = factory.newWorker("order-fulfillment");
        worker.registerWorkflowImplementationTypes(
            OrderFulfillmentWorkflowImpl.class);
        worker.registerActivitiesImplementations(
            inventoryAdapter, paymentAdapter);
        return worker;
    }
}

Driving the Process Manager

// application/OrderFulfillmentService.java
@Service
public class OrderFulfillmentService {

    private final WorkflowClient temporal;

    public void startFulfillment(String orderId, OrderDetails details) {
        OrderFulfillmentWorkflow wf = temporal.newWorkflowStub(
            OrderFulfillmentWorkflow.class,
            WorkflowOptions.newBuilder()
                .setWorkflowId(orderId)          // correlation ID = workflowId
                .setTaskQueue("order-fulfillment")
                .build());
        WorkflowClient.start(wf::fulfill, orderId, details);
    }

    public void confirmPayment(String orderId, PaymentConfirmation confirmation) {
        OrderFulfillmentWorkflow wf = temporal.newWorkflowStub(
            OrderFulfillmentWorkflow.class, orderId); // correlate by id
        wf.confirmPayment(orderId, confirmation);     // delivers signal
    }
}

The workflowId doubles as the correlation ID. Temporal routes the signal to the correct running instance — no correlation table needed in your code.

Trade-offs

AdvantageDrawback
Temporal as Process ManagerDurable state, retries, timers, signals out of the box; no bespoke state machine DBTemporal server is a required infrastructure dependency; local dev needs Docker
Hexagonal isolationDomain stays free of Temporal annotations; workflow logic is swappableMore boilerplate — ports + adapters per activity; can feel over-engineered for simple flows
Workflow code = process stateNo schema migrations when state fields change for new instancesChanging determinism of running workflows requires versioning (Workflow.getVersion)
Orchestration over choreographyExplicit, debuggable process flow; compensations are co-located with stepsCentral coordinator is a coupling point; all steps must be reachable from the worker

When to Reach for This Pattern

Temporal’s model matches the Process Manager precisely when: the process spans multiple services, has variable step sequences, requires timer-driven timeouts, or must guarantee compensation on partial failure. For simple linear flows with no branching, a transactional outbox + event chain is cheaper. For pure choreography, the overhead of a central orchestrator is unjustified.

The EIP vocabulary gives teams a shared mental model before any code is written. Temporal gives that model a production-grade runtime. Hexagonal architecture ensures neither the pattern nor the runtime calcifies into your domain.

← back to posts