Skip to content

Durable workflows

View Markdown

A durable workflow coordinates several steps over time (service calls, external signals, timers, human decisions) as one resumable instance that survives restarts. Vertique’s workflow engine is light, Temporal-style durable execution as a framework module. It runs inside your application and persists to PostgreSQL, with no separate orchestration cluster to operate.

  • Workflows are plain Java. A builder DSL describes steps, signals, timers, human tasks, and compensation in one readable definition class. The same plan can also be authored as a YAML/JSON document, which defines the steps over the Java state and payload classes your application already has.
  • Saga compensation is built in. A dispatch step can declare its compensating action. On failure the engine runs the recorded compensations in LIFO order.
  • Instances wait without holding resources. A step can wait on an external signal, a durable timer, or a human decision with role assignment and typed decision payloads. The wait can last minutes or months.
  • Parallel branches. Fork into named branches and join with a completion policy: all required, first success, or first failure.
  • Typed contracts. Annotate an interface, create a client from the definition, and call start, signal, and query through a compile-time-typed proxy instead of stringly-typed operations. One contract can back many definitions and versions.
  • Idempotent by design. Starts require an idempotency key and signals carry dedup keys, so a duplicate call or a redelivered event is ignored and is not executed again.
  • Version-pinned instances. An instance resumes under the definition scope, id, version, and plan hash it started with. Registering a newer definition never changes an in-flight instance, and upgrades are an explicit migrate operation.
  • Explicit current version, per scope. Which version a new start runs is an activation you control, and a new code version becomes current on deploy unless it opts out. A scope, such as a tenant, can run its own variant of a workflow or follow the default one.

Reach for a workflow when the work has more than one step, must survive a restart, and may wait on a signal, a timer, or a human decision. A single handler invocation on a schedule or a delay is a scheduled or deferred job. A single side effect that must commit atomically with a business write is an inbox/outbox message.

The order-fulfillment saga below reserves inventory, authorizes payment, and creates a shipment, waiting for a confirmation signal after each dispatch. The compensable steps name their undo action. If the saga fails midway, the engine releases the reservation and voids the authorization in LIFO order. Abridged from OrderFulfillmentDefinition in the public vertique-example-workflow-order-fulfillment example:

@Override
public void define(WorkflowBuilder<OrderState> wf) {
wf
.init(PlaceOrder.class, OrderState::fromCommand)
.initialStep("reserve-inventory")
.dispatchWithCompensation(
"reserve-inventory",
"inventory.reserve",
state -> new ReserveInventory(state.orderId(), state.items()),
"release-inventory",
"wait-inventory")
.waitFor(
"wait-inventory",
"inventory.reserved",
InventoryReserved.class,
(s, sig) -> s.withReservation(sig.reservationId()),
"authorize-payment")
.dispatchWithCompensation(
"authorize-payment",
"payment.authorize",
state -> new AuthorizePayment(state.orderId(), state.customerId(), state.totalCents()),
"void-authorization",
"wait-payment")
// … wait-payment, create-shipment, and wait-shipment elided …
.complete("completed")
.compensate(
"release-inventory",
"reserve-inventory",
"inventory.release",
state -> new ReleaseInventory(state.orderId(), state.reservationId()))
.compensate(
"void-authorization",
"authorize-payment",
"payment.void-authorization",
state -> new VoidAuthorization(state.orderId(), state.authorizationId()));
}

Application code drives the saga through a typed contract: an annotated interface whose proxy routes each method to the workflow operations facade, so starting, signaling, and querying stay compile-time typed. The contract names only itself. The definition class carries the workflow id and version. From the same example, abridged:

@WorkflowContract("order-fulfillment")
public interface OrderFulfillmentWorkflow {
@WorkflowStart
Future<WorkflowInstanceId> start(PlaceOrder cmd);
@WorkflowSignal("inventory.reserved")
Future<Void> inventoryReserved(WorkflowInstanceId id, InventoryReserved signal);
@WorkflowQuery("status")
Future<WorkflowView> status(WorkflowInstanceId id);
}

You obtain the typed proxy from the injected WorkflowClientFactory, by definition class:

OrderFulfillmentWorkflow orders = factory.create(OrderFulfillmentDefinition.class);

A contract, a definition, and a scope are three separate things. The contract is the interface callers use. A definition, written in Java or as a YAML/JSON document, owns the workflow id, version, contract, state type, and steps. A scope is the place a definition and its instances live: default unless you choose another, such as one scope for each tenant.

  • Clients select a definition, not a version. factory.create(DefinitionClass.class) fits a code definition, and factory.client(Contract.class, definitionId) fits any definition, documents included. A start runs the current version. The builder adds scope(...) and an explicit version(...), for example to try a version before it is current.
  • The current version is an activation. Each scope has at most one activation record per workflow id. A scope with no record follows the default scope. A code version or packaged document published at deploy time becomes the default scope’s current version, unless it returns false from activateOnDeploy(). A version published at run time never becomes current by itself.
  • Roll out in two steps. Deploy a new version with activateOnDeploy set to false, and activate it once every node runs the new code. Deployments are roll-forward only: a node whose code lacks a version that is not retired fails at startup.
  • Documents define steps, not types. A YAML/JSON document names its contract, state type, and payload types by logical name. Types stay Java classes, annotated with @WorkflowType, and a document can reference only names in the generated type catalog or used by a registered definition. A document never carries its scope. The scope is the argument of the call that loads it.
  • Instances stay in their scope. Business keys and start idempotency keys are unique for each start scope and workflow id. The scope is a label and a filter, not an authorization boundary.

From the example’s scenario tests, abridged (the receiver is shortened to factory), a client for a document-defined workflow in one scope and the document that defines it:

PurchaseApprovalWorkflow purchases = factory
.client(PurchaseApprovalWorkflow.class, "purchase-approval")
.scope("acme")
.create();
definitionId: purchase-approval
definitionVersion: 1
stateType: purchase
contract: purchase-approval
# … payload types and callback names elided …
initialStep: finance-approval
steps:
- id: finance-approval
type: human-task
# … decisions elided …
- id: await-audit
type: wait-signal
signal: po.audited
next: done
- id: done
type: complete

The vertique-workflow-definition and vertique-workflow-core references document loading, publishing, activation, deprecation, and retirement in full.

  • Snapshot plus history. Instance state is one current snapshot and an append-only history. A query returns the snapshot, and every transition that produced it is a recorded history entry.
  • Pinned resumption. The engine records the definition scope, definition id, definition version, and content-derived plan hash when an instance starts. Resumes use that pinned plan and never a newer registered definition. Moving an instance forward is an explicit migrate call.
  • Scoped idempotency. A start key is scoped to the start scope and the definition id, and a signal dedup key is scoped to the workflow instance. The PostgreSQL module reference carries the complete key-scope table across all operations.
  • Registration-time validation. The registry validates a definition’s step graph when it is registered, so an invalid graph fails before it can serve traffic.

The workflow engine and capability modules

Section titled “The workflow engine and capability modules”

vertique-workflow-core defines the programming model: the builder DSL, the registry of compiled plans, the SQL-free operations facade for start, signal, cancel, and query, and typed contract proxies. vertique-workflow-engine holds the dialect-neutral orchestration kernel underneath it (the saga state machine, fork/join coordination, and timer and task lifecycle). vertique-workflow-postgresql is its PostgreSQL adapter: schema, Flyway migrations, and the repositories that let an instance survive a restart.

There is no vertique-starter-workflow. An application composes only the artifacts it needs directly into its own Dagger component. Install vertique-workflow-core in every application that uses workflows, and add capability modules only as needed:

Add For
vertique-workflow-postgresql A durable runtime that survives restarts and runs across more than one node
vertique-workflow-services Dispatching workflow steps as service calls, recorded through the transactional outbox
vertique-workflow-delayed Timers, signal timeouts, and task due dates
vertique-workflow-tasks The human-task service API
vertique-workflow-events Publishing workflow lifecycle events to external consumers, through the transactional outbox
vertique-workflow-definition Authoring a workflow as a YAML/JSON document instead of a Java DSL, and loading it into a scope

vertique-workflow-postgresql includes the engine module transitively, so naming it is enough to reach the operations facade. No separate component entry for vertique-workflow-engine is needed. Dispatching service steps or publishing events is different. Both add a compile dependency on vertique-inbox-outbox-core without wiring its Dagger module for you, so a workflow application that uses either one also names the matching inbox/outbox adapter module directly. See Composing with jobs and inbox/outbox.

  • Workflow service dispatch and events use inbox/outbox. vertique-workflow-services and vertique-workflow-events record their work through the transactional outbox, so a workflow application that dispatches service steps or publishes workflow events also composes the matching inbox/outbox adapter modules in its component. For this reason, vertique-workflow-core’s own required-wiring example names TransactionalMessagingPostgresqlModule and TransactionalMessagingServiceModule alongside the workflow modules. See Transactional inbox/outbox messaging for the messaging side.
  • Workflow timers use jobs. vertique-workflow-delayed depends on vertique-job-core, vertique-job-delayed, and vertique-job-cron to give a workflow instance durable timers, signal timeouts, and task due dates. Add it, and the job modules it depends on, only when a workflow definition uses those steps. See Scheduled and deferred jobs for the job side.
  • The PostgreSQL adapter wires the database connection layer directly, without a starter. vertique-workflow-postgresql depends on vertique-db-postgresql, and the application still installs DbPostgresqlModule and DbFlywayModule in its own Dagger component. The adapter does not run Flyway migration for the application, but it ships its migration scripts at classpath:db/migration/workflow and a WorkflowMigrationsModule that declares them as a migration named workflow. With the opt-in named mode (flyway.instances), that declaration gives the workflow tables their own Flyway history and lets the packaged scripts run through the public runner without any application bootstrap, and an optional db.schemas.workflow setting places them in their own schema for both the migration and every workflow store’s SQL. In the default single-history mode, flyway.locations must instead list the workflow path alongside the application’s own migration location. An application that already uses the PostgreSQL starter for its own repositories (see Persistence) keeps that starter and composes the workflow adapter alongside it. The module’s own reference documents the wiring. Jobs and inbox/outbox state the same pattern for their own PostgreSQL adapters.

The module references below document the constructs, SQL, configuration, and extension points in full. This page does not repeat them.