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:
- Correlation — every incoming message must be matched to the correct process instance (by order ID, booking reference, etc.).
- State tracking — the manager remembers where the process is and what happened so far.
- 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 Manager | Temporal concept |
|---|---|
| Process instance | WorkflowExecution (identified by workflowId) |
| Correlation ID | workflowId — used to route signals and queries |
| Process state | Reconstructed deterministically from event history |
| External message in | Signal (async) or Update (sync, returns a value) |
| Step / command dispatch | Activity — a unit of potentially non-deterministic work |
| Waiting for a callback | workflow.sleep() + signal, or async activity completion |
| Timeout | workflow.sleep(Duration) before a signal arrives |
| Compensation | Regular 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.
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.
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
| Advantage | Drawback | |
|---|---|---|
| Temporal as Process Manager | Durable state, retries, timers, signals out of the box; no bespoke state machine DB | Temporal server is a required infrastructure dependency; local dev needs Docker |
| Hexagonal isolation | Domain stays free of Temporal annotations; workflow logic is swappable | More boilerplate — ports + adapters per activity; can feel over-engineered for simple flows |
| Workflow code = process state | No schema migrations when state fields change for new instances | Changing determinism of running workflows requires versioning (Workflow.getVersion) |
| Orchestration over choreography | Explicit, debuggable process flow; compensations are co-located with steps | Central 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.