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();
    }
}

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 @Transactional.

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();
    }
}

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();

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., quarkus_flow_task_duration_seconds{workflow="order-flow",task="chargePayment"}). Unique, descriptive names improve filtering, aggregation, and dashboard visualization.

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.

Rule of thumb

Concern Where it lives Example

Transient network failures

application.properties (Fault Tolerance)

HTTP 503, connection timeout, rate limit

Business process conditions

Workflow DSL (tryCatch with retry)

Manager rejection, pending approval, missing document

See also