Durable workflows
View MarkdownA 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.
Define a workflow as code
Section titled “Define a workflow as code”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:
@Overridepublic 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);Versions, scopes, and documents
Section titled “Versions, scopes, and documents”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, andfactory.client(Contract.class, definitionId)fits any definition, documents included. A start runs the current version. The builder addsscope(...)and an explicitversion(...), 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
activateOnDeployset 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-approvaldefinitionVersion: 1stateType: purchasecontract: purchase-approval# … payload types and callback names elided …initialStep: finance-approvalsteps: - id: finance-approval type: human-task # … decisions elided … - id: await-audit type: wait-signal signal: po.audited next: done - id: done type: completeThe vertique-workflow-definition and vertique-workflow-core references document loading, publishing,
activation, deprecation, and retirement in full.
Durability guarantees
Section titled “Durability guarantees”- 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.
Composing with jobs and inbox/outbox
Section titled “Composing with jobs and inbox/outbox”- Workflow service dispatch and events use inbox/outbox.
vertique-workflow-servicesandvertique-workflow-eventsrecord 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 namesTransactionalMessagingPostgresqlModuleandTransactionalMessagingServiceModulealongside the workflow modules. See Transactional inbox/outbox messaging for the messaging side. - Workflow timers use jobs.
vertique-workflow-delayeddepends onvertique-job-core,vertique-job-delayed, andvertique-job-cronto 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-postgresqldepends onvertique-db-postgresql, and the application still installsDbPostgresqlModuleandDbFlywayModulein its own Dagger component. The adapter does not run Flyway migration for the application, but it ships its migration scripts atclasspath:db/migration/workflowand aWorkflowMigrationsModulethat declares them as a migration namedworkflow. 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 optionaldb.schemas.workflowsetting places them in their own schema for both the migration and every workflow store’s SQL. In the default single-history mode,flyway.locationsmust 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.
Learn more
Section titled “Learn more”The module references below document the constructs, SQL, configuration, and extension points in full. This page does not repeat them.
vertique-workflow-coremodule reference: the DSL, registry, and operations facade.vertique-workflow-enginemodule reference: the dialect-neutral orchestration kernel.vertique-workflow-postgresqlmodule reference: the durable PostgreSQL adapter.vertique-workflow-servicesmodule reference: service-dispatch steps through the transactional outbox.vertique-workflow-delayedmodule reference: durable timers, signal timeouts, and task due dates.vertique-workflow-tasksmodule reference: the human-task service API.vertique-workflow-eventsmodule reference: durable external workflow events.vertique-workflow-definitionmodule reference: YAML/JSON workflow authoring.OrderFulfillmentDefinition: the full order-fulfillment saga the excerpts above abridge.- Purchase approval document: the full YAML definition the excerpt above abridges, with the scenario tests that load it into a scope.
- Scheduled and deferred jobs
- Transactional inbox/outbox messaging
- Persistence
- Application model
- What is Vertique?
Continue reading
Section titled “Continue reading”- Previous: Jobs and scheduling
- Next: Security