Skip to content

Workflow services

View Markdown

Status: Stable Package: dev.vertique.workflow.services Artifact: vertique-workflow-services Depends on: workflow-core, services, inbox-outbox-core, vertx-sql-client API reference: javadoc.io

vertique-workflow-services connects workflow service-dispatch steps to Vertique’s transactional outbox and exposes a service contract for posting workflow signals.

Install this module when workflows dispatch service work or when service consumers need to signal workflow instances.


Component Purpose
Service side-effect recorder Persists SERVICE workflow intents to the outbox inside the workflow transaction
Signal service contributor Registers the workflow.signals.post service contract
Composition validator Fails startup when workflow service targets or outbox handlers are miswired

This module does not compile-depend on workflow-postgresql or inbox-outbox-services. The application composes those modules at the Dagger boundary.


When a workflow reaches a service-dispatch step, the engine emits a service side-effect intent. The services module records that intent as an outbox entry. The downstream service call happens later through the outbox relay, after the transaction commits.

Recorder behavior:

  • Resolve the workflow target id through ServiceTargetResolver.
  • Require the target to be a one-payload, Future<Void> service operation.
  • Write an outbox entry with workflow correlation headers: x-workflow-id, x-workflow-step-sequence, x-workflow-definition-id, x-workflow-definition-scope (the scope of the definition the instance is pinned to), x-workflow-scope (the scope the instance was started in), x-workflow-step-id, and the branch identity headers for a branch dispatch. A correlation header replaces an intent header of the same name.
  • Fail the workflow transaction on an unknown or invalid target.
  • Treat a null intent header map as no headers, and fail the returned future with an IllegalArgumentException naming the key when an intent header has a null key or value.

The recorder must never make a live downstream call. The outbox row is the durable handoff.


WorkflowSignalContributor registers the workflow.signals.post service contract. Service delivery uses inbox deduplication before calling TransactionalWorkflowOperations.signal(...) in the same SQL transaction.

The endpoint’s reply means the signal is recorded, not applied: signal records a SIGNAL command of the instance and returns, and an executor applies it afterwards in queue order. A signal the instance does not await yet is parked and applied when the instance reaches a wait that accepts it. The call is refused only for what recording refuses: a missing or ended instance, a name the instance’s pinned definition does not declare for the addressed wait, a payload or carrier over the record-time bounds, or an instance that already holds workflow.recording.maxOpenSignalsPerInstance open signals (WorkflowSignalLimitException). A refusal rolls the inbox record back with the transaction, so the relay retries it.

Who is logged. The endpoint records the signal inside the dispatch context the relay rebuilt for the delivery, and the engine logs the signal’s actor from the identity of that context (see “Who asked for a command” in the engine module reference). The actor is never read from WorkflowSignalRequest, its payload or its carrier. By default the relay’s identity is SYSTEM with the outbox row’s event type as the reason; a bound ServiceIdentityResolver names a service instead. Either acts for the user whose transaction wrote the outbox row (on_behalf_of). A snapshot that cannot be verified refuses the delivery under the default degradation policy, so no command, dedup or inbox row is written; under CONTINUE_WITHOUT_IDENTITY the signal is logged DEGRADED and acts for no one. A graph without identity carriage logs the signal as NONE.

Signal dedup happens at two layers:

Layer Scope Purpose
Inbox dedup Transport delivery Handles at-least-once outbox relay retries
Workflow dedup Workflow instance Handles duplicate logical signals

The inbox message id must include workflow identity. A caller-provided signal dedup key alone is not globally unique.

WorkflowSignalRequest carries an additional @Nullable JsonObject metadata field — the raw {"context": …} carrier document produced by DurableMetadata.toCarrier(). All existing factory overloads (instance(...), branch(...)) are preserved; new overloads accept the carrier alongside the original arguments. A null carrier keeps every existing call site’s behavior unchanged.

WorkflowSignalContributor.handleSignal decodes a non-null carrier via DurableMetadata.fromCarrier(...) inside the InboxService.processOnce work supplier — not before pool.withTransaction is entered — and then calls the metadata-aware 8-arg TransactionalWorkflowOperations.signal overload instead of the legacy 7-arg one. This placement has two consequences:

  • Decode failure is a failed future, not a thrown exception. A malformed context section or namespace body raises MalformedDurableMetadataException (a ValidationException subtype), which propagates through the normal async error-handling path.
  • First-delivery decode failure rolls back the inbox insert. Because the decode runs inside the same transaction as the inbox insert, a poison message on its first delivery never commits a dedup record — every redelivery re-decodes and re-fails identically. An already-committed duplicate (whose first delivery succeeded) is skipped by the dedup check before decode is attempted, so a legitimate duplicate delivery of a previously-successful carrier is never re-decoded.

Security. WorkflowSignalEndpoint.post is an internal-relay-only endpoint. The metadata carrier is stored with the recorded signal, without its identity-snapshot namespace, and the executor binds it as the authoritative durable-context base for the signal’s drive — it is never populated from untrusted or end-user input, only from the relay that produced it via DurableMetadata.toCarrier(). The carrier’s provenance is not independently authenticated against the caller’s identity — a future revision may derive the tenant namespace from the authenticated SecurityContext instead of trusting it verbatim off the carrier.


An app that uses service-dispatch workflows needs the workflow services module plus transactional messaging modules:

@Singleton
@Component(modules = {
WorkflowCoreModule.class,
WorkflowPostgresqlModule.class,
WorkflowServicesModule.class,
TransactionalMessagingPostgresqlModule.class,
TransactionalMessagingServiceModule.class,
DbPostgresqlModule.class,
DbFlywayModule.class,
AppModule.class
})
public interface AppComponent {
WorkflowOperations workflowOperations();
}

TransactionalMessagingServiceModule is required for the SERVICE outbox destination handler. Without it, service-dispatch intents cannot be delivered after commit.

WorkflowOutboxComposeValidator is a per-plan WorkflowDefinitionValidator that WorkflowServicesModule contributes into the registry’s validator set, so the registry runs it once for every plan it builds, at startup and for a definition loaded or published later. A plan that dispatches to a service (a service-dispatch or compensation step) is refused when no SERVICE outbox handler is bound, or when a target id does not resolve or has the wrong shape (@OneWay, a return type other than Future<Void>, other than one payload parameter). A graph whose plans dispatch to no service starts without a SERVICE handler. A code plan that fails a check aborts startup with an IllegalStateException naming the definition, step and target; a document that fails one is refused at publication with the same text. The validator is no longer a ComposeValidator, no accessor is needed on the component, and OutboxSideEffectRecorder no longer takes it as a constructor dependency. Its public constructor takes the outbox destination handlers and the ServiceTargetResolver.


Extension point Purpose
@WorkflowRecorders Receives the service side-effect recorder
ServiceContractContributor Receives the workflow signal service contract
ServiceTargetResolver Resolves stable workflow service target ids
OutboxDestinationHandler Delivers outbox entries after commit

Applications normally provide service contracts and destination handlers; this module provides the workflow bridge.


  • Service dispatch is transactional. If the workflow transaction rolls back, no downstream service call is published.
  • Unknown or shape-incompatible service targets fail startup or the workflow transition; there is no best-effort relay.
  • The module boundary is intentionally narrow. workflow-services consumes core SPIs and messaging-core APIs, but the app decides which persistence and relay modules are installed.

  • workflow-core - workflow side-effect recorder SPI and transactional signal operations.
  • services - service contract contribution and target resolution.
  • inbox-outbox-core - outbox and inbox APIs.
  • vertx-sql-client - SqlClient appears in public integration signatures.