Best practices
This guide collects practical recommendations that help you avoid common pitfalls when building workflows with Quarkus Flow.
1. Persist data with @Transactional
Workflow tasks can execute after the original HTTP request has completed.
When that happens, the request-scoped CDI context is no longer active, and any attempt to manage transactions manually with QuarkusTransaction.begin() will fail with a context not active exception.
The safe approach is to delegate persistence to a CDI bean whose method is annotated with @Transactional.
The CDI proxy activates the transaction context automatically, regardless of when the workflow task runs.
What to avoid
import static io.quarkiverse.flow.dsl.FlowDSL.function;
import static io.quarkiverse.flow.dsl.FlowDSL.listen;
import static io.quarkiverse.flow.dsl.FlowDSL.toOne;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkiverse.flow.Flow;
import io.quarkiverse.flow.dsl.FlowWorkflowBuilder;
import io.quarkus.narayana.jta.QuarkusTransaction;
import io.serverlessworkflow.api.types.Workflow;
@ApplicationScoped
public class OrderWorkflowBad extends Flow {
@Override
public Workflow descriptor() {
return FlowWorkflowBuilder.workflow("placeOrder")
.tasks(
listen("waitOrder", toOne("order.submitted")),
function("placeOrder", (OrderRequest request) -> {
// WARNING: This will fail with "context not active" if the
// workflow runs after the original HTTP request has completed.
QuarkusTransaction.begin();
try {
Order order = new Order(request.product(), request.quantity());
order.persist();
QuarkusTransaction.commit();
return order.id;
} catch (Exception e) {
QuarkusTransaction.rollback();
throw e;
}
}, OrderRequest.class))
.build();
}
}
Recommended approach
Create a bean with a @Transactional method and call it from the workflow task:
@Transactional
public Long placeOrder(Order order) {
order.persist();
return order.id;
}
import static io.quarkiverse.flow.dsl.FlowDSL.function;
import static io.quarkiverse.flow.dsl.FlowDSL.listen;
import static io.quarkiverse.flow.dsl.FlowDSL.toOne;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.quarkiverse.flow.Flow;
import io.quarkiverse.flow.dsl.FlowWorkflowBuilder;
import io.serverlessworkflow.api.types.Workflow;
@ApplicationScoped
public class OrderWorkflowGood extends Flow {
@Inject
OrderService orderService;
@Override
public Workflow descriptor() {
return FlowWorkflowBuilder.workflow("placeOrder")
.tasks(
listen("waitOrder", toOne("order.submitted")),
function("placeOrder", orderService::placeOrder, Order.class)
.outputAs((Long id) -> id))
.build();
}
}
|
This pattern applies to any side-effect that depends on a CDI context scope: JPA operations, messaging producers, audit logging, and so on.
If the operation touches the database, put it behind |
2. Never hard-code secrets
Workflow beans are discovered and wired at build time. Hard-coding credentials directly in a workflow definition means they are embedded in the compiled output and visible to anyone with access to the artifact.
Always reference secrets by handle using ${ $secret.<handle>.<key> } expressions.
At runtime, Quarkus Flow resolves the handle through the CredentialsProvider SPI or, in dev/test, from application.properties.
What to avoid
package org.acme.bestpractices;
import static io.quarkiverse.flow.dsl.FlowDSL.http;
import static io.quarkiverse.flow.dsl.FlowWorkflowBuilder.workflow;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkiverse.flow.Flow;
import io.serverlessworkflow.api.types.Workflow;
@ApplicationScoped
public class NotificationWorkflowBad extends Flow {
@Override
public Workflow descriptor() {
return workflow("notify")
.tasks(
// NEVER hard-code tokens in the workflow definition
http("callApi")
.post()
.endpoint("https://api.example.com/notify")
.header("X-Api-Key", "my-api-key"))
.build();
}
}
Recommended approach
Declare the secret handle and reference it via the authentication configurer or a $secret expression:
package org.acme.bestpractices;
import static io.quarkiverse.flow.dsl.FlowDSL.http;
import static io.quarkiverse.flow.dsl.FlowWorkflowBuilder.workflow;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkiverse.flow.Flow;
import io.serverlessworkflow.api.types.Workflow;
@ApplicationScoped
public class NotificationWorkflowGood extends Flow {
@Override
public Workflow descriptor() {
return workflow("notify")
.use(u -> u.secrets("mySecrets"))
.tasks(
http("callApi")
.post()
.endpoint("https://api.example.com/notify")
.header("X-Api-Key", "${ $secret.mySecrets.apiKey }"))
.build();
}
}
In dev/test, provide the value in application.properties:
mySecrets.apiKey=my-api-key
In production, configure a CredentialsProvider (e.g., HashiCorp Vault, Kubernetes) so that secret values are never stored in plain text.
|
For a complete walkthrough of declaring, resolving, and testing secrets, see Resolve secrets securely. |
3. Name your tasks
When a task does not have an explicit name, the Serverless Workflow SDK assigns a default task name automatically based in the type and the order of the task.
That generated task name then appears in every observability signal: logs, metrics, and traces.
Debugging a production incident becomes much harder when your dashboard shows task="http-0" instead of task="fetchCustomerProfile".
What to avoid
return workflow("order-flow")
.tasks(
// No task names — the SDK generates
function(orderService::validate, OrderRequest.class),
function(paymentService::charge, PaymentRequest.class),
function(notificationService::send, NotificationRequest.class))
.build();
Recommended approach
Always provide a descriptive, unique name for each task:
return workflow("order-flow")
.tasks(
function("validateOrder", orderService::validate, OrderRequest.class),
function("chargePayment", paymentService::charge, PaymentRequest.class),
function("sendConfirmation", notificationService::send, NotificationRequest.class))
.build();
|
The combination of workflow name and task name is used as metric label (e.g., For the full list of metrics and how task names affect them, see Observability with Prometheus and Micrometer. |
4. Separate business retries from infrastructure retries
Quarkus Flow supports retries at two distinct levels. Mixing them up leads to workflows that either mask infrastructure failures or bury business logic in configuration files.
Infrastructure retries (configuration)
Network timeouts, HTTP 503 responses, and rate-limit errors are infrastructure concerns. Handle them with SmallRye Fault Tolerance configuration properties — the engine retries transparently, without changing your workflow logic:
quarkus.flow.http.client.resilience.retry.max-retries=3
quarkus.flow.http.client.resilience.retry.delay=0
quarkus.flow.http.client.resilience.retry.jitter=200ms
For details, see Fault tolerance and resilience.
Business retries (workflow DSL)
A manager who has not yet approved a vacation request is a business concern, not a transient failure.
Model this kind of retry as part of the workflow definition itself using the tryCatch construct with a retry policy:
package org.acme.bestpractices;
import static io.quarkiverse.flow.dsl.FlowDSL.function;
import static io.quarkiverse.flow.dsl.FlowDSL.tasks;
import static io.quarkiverse.flow.dsl.FlowDSL.tryCatch;
import static io.quarkiverse.flow.dsl.FlowWorkflowBuilder.workflow;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.quarkiverse.flow.Flow;
import io.serverlessworkflow.api.types.Workflow;
@ApplicationScoped
public class VacationApproveWorkflow extends Flow {
@Inject
ApprovalService approvalService;
@Override
public Workflow descriptor() {
return workflow("vacation-approval")
.tasks(
tryCatch(
"tryApproval",
t -> t.tryCatch(tasks(
function("submitRequest", approvalService::submit),
function("checkApproval", approvalService::requireApproval)))
.catchHandler(handler -> handler
.errorsWith(err -> err.type("APPROVAL_REJECTED"))
.retry(r -> r
.limit(limit -> limit.attempt(a -> a.count(3)))
.delay("PT1H"))
.doTasks(tasks(
function("notifyRejection", approvalService::notifyRejection,
VacationRequest.class))))))
.build();
}
}
The retry policy on the catch handler tells the engine to re-execute the try block up to 3 times, with a 1-hour delay between attempts.
This makes the business rule (re-submit if rejected) visible in the workflow definition, not hidden in configuration files.
See also
-
Resolve secrets securely — declaring and resolving secrets.
-
Observability with Prometheus and Micrometer — how task names affect metrics.
-
Fault tolerance and resilience — infrastructure retries and circuit breakers.
-
Configure the HTTP client — per-client resilience tuning.
-
Java DSL cheatsheet — complete list of task providers and naming patterns.