Build a Refund Workflow with Quarkus Flow 0.11
A hands-on guide to separating Java decision logic from workflow waiting, manual-review callbacks, CloudEvents, and correlation IDs.
I wrote about Quarkus Flow before and if you want to start fresh, you might wanna catch up on that before you dive deeper with this piece.
A refund request can look simple on first read:
if amount is small and the receipt exists, approve it
if the receipt is missing, deny it
otherwise ask a humanThat is a policy decision. It belongs in boring, testable code. If your team already uses a rules engine, that policy can live there too.
The awkward part starts after the policy says “ask a human.” Now the system has to emit a review request, remember which workflow instance is waiting, accept a callback later, resume the exact request, record the final decision, and leave enough evidence for the next person who has to debug it. A decision table does not want that job. A workflow engine does.
This is where Quarkus Flow fits. It is a lightweight workflow engine for Quarkus based on the CNCF Serverless Workflow specification. Version 0.11.0, adds enough new pieces that it deserves a different article than another “call three services in order” walkthrough: runner images, better idempotency and correlation guidance, runtime-configurable messaging and observability pieces, Quartz scheduling, @ScheduleOn for agentic workflows, and gRPC channel routing.
We will build RefundDesk, a small Quarkus service that shows the boundary:
A plain Java policy decides whether a refund is approved, denied, or routed to manual review
A Quarkus Flow workflow coordinates the process around that decision
CloudEvents carry the manual-review request and callback
The workflow pauses and resumes by correlation data
Tests prove the policy and the HTTP path
No AI is needed for the main path. That is intentional. A workflow engine is already useful before the model shows up and starts making confident little messes. But, yes, Quarkus Flow also works with LangChain4j and the agentic modules. But this will be another article soon hopefully.
What You Need
This article uses Quarkus 3.37.0, Quarkus Flow 0.11.0, Java 25, and Podman.
Java 25 installed
Quarkus CLI installed
Podman available for Kafka Dev Services and the Runner image
Basic Quarkus REST and CDI knowledge
About two ☕️ (and yes, you can also take another beverage depending on time of the day and mood.)
Three definitions before we start to code:
Policy - the deterministic decision logic. In this article, it is a Java CDI bean. It could also be Drools, Easy Rules, or a remote decision service.
Workflow - the process that decides which step runs next and what state must survive across waiting, retries, and callbacks.
Correlation - the data that lets an incoming callback wake the right waiting workflow instance. In Quarkus Flow event workflows, the flowinstanceid CloudEvent extension is the important one.
Create the Project
Create the application and follow along or grab the source code from my repository. :
quarkus create app dev.mainthread:refunddesk-flow \
--extension='io.quarkiverse.flow:quarkus-flow:0.11.0,io.quarkiverse.flow:quarkus-flow-mvstore:0.11.0,rest-jackson,messaging-kafka,smallrye-openapi,quarkus-micrometer-registry-prometheus' \
--java=25 \
--no-code
cd refunddesk-flowUse these extensions:
quarkus-flow: the Java DSL and workflow runtimequarkus-flow-mvstore: local file-backed workflow persistencerest-jackson: HTTP endpoints and JSON serializationquarkus-micrometer-registry-prometheus- has to be included when Quarkus Flow metrics are enabled.messaging-kafka: the Reactive Messaging bridge that connects Flow to Kafka topicssmallrye-openapi: a quick way to inspect the REST API while testing
The important detail is that Flow is scaffolded by the Quarkus CLI. Do not paste a second Flow dependency block into the POM. The command above asks the CLI to add both Flow artifacts while the project is created.
I am using the full Quarkiverse coordinates because, when I tested this, the catalog short name did not resolve to the 0.11.0 artifact. Once the catalog catches up, --extension='quarkus-flow,...' is the nicer shape. Until then, pinning io.quarkiverse.flow:quarkus-flow:0.11.0 keeps the tutorial reproducible.
quarkus-flow-mvstore gives this local tutorial a file-backed persistence store so waiting workflow state can survive a JVM restart. MVStore is a good local fit. For production, you will usually move to Redis or JPA, depending on how your platform already stores operational state.
Define the Refund Model
The policy and workflow need a small domain model. Keep it explicit. If the records are too loose, the workflow becomes a bag of maps and the policy test stops telling you much.
Create src/main/java/dev/mainthread/refunddesk/DecisionOutcome.java:
package dev.mainthread.refunddesk;
public enum DecisionOutcome {
APPROVED,
DENIED,
MANUAL_REVIEW
}Create src/main/java/dev/mainthread/refunddesk/RefundRequest.java:
package dev.mainthread.refunddesk;
import java.math.BigDecimal;
public record RefundRequest(
String refundId,
String customerId,
BigDecimal amount,
boolean receiptPresent,
int accountAgeDays,
int chargebackCount) {
}Create src/main/java/dev/mainthread/refunddesk/RefundDecision.java:
package dev.mainthread.refunddesk;
public record RefundDecision(DecisionOutcome outcome, String reason) {
}Create src/main/java/dev/mainthread/refunddesk/RefundCase.java:
package dev.mainthread.refunddesk;
public record RefundCase(RefundRequest request, RefundDecision decision) {
}Create src/main/java/dev/mainthread/refunddesk/ReviewDecision.java:
package dev.mainthread.refunddesk;
public record ReviewDecision(
String refundId,
DecisionOutcome outcome,
String reviewer,
String note) {
}Create src/main/java/dev/mainthread/refunddesk/RefundResult.java:
package dev.mainthread.refunddesk;
public record RefundResult(
String refundId,
DecisionOutcome outcome,
String reason,
String reviewer) {
}Create src/main/java/dev/mainthread/refunddesk/SubmitRefundResponse.java:
package dev.mainthread.refunddesk;
public record SubmitRefundResponse(String refundId, String workflowInstanceId) {
}These records separate facts, policy output, review output, and final state. That keeps the policy easy to test and the workflow easier to read. A rules engine would use the same kind of boundary: facts in, decision out.
Write the Policy as Plain Java
The policy is deliberately small. The goal is to show where the rule belongs, not to pretend three predicates are a credit-risk platform.
Create src/main/java/dev/mainthread/refunddesk/RefundPolicy.java:
package dev.mainthread.refunddesk;
import java.math.BigDecimal;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class RefundPolicy {
private static final BigDecimal AUTO_APPROVE_LIMIT = new BigDecimal("50.00");
private static final BigDecimal MANUAL_REVIEW_LIMIT = new BigDecimal("300.00");
public RefundCase evaluate(RefundRequest request) {
if (!request.receiptPresent()) {
return new RefundCase(request,
new RefundDecision(DecisionOutcome.DENIED, "receipt is missing"));
}
if (request.amount().compareTo(AUTO_APPROVE_LIMIT) <= 0 && request.chargebackCount() == 0) {
return new RefundCase(request,
new RefundDecision(DecisionOutcome.APPROVED, "small refund with clean history"));
}
if (request.amount().compareTo(MANUAL_REVIEW_LIMIT) >= 0
|| request.accountAgeDays() < 30
|| request.chargebackCount() > 1) {
return new RefundCase(request,
new RefundDecision(DecisionOutcome.MANUAL_REVIEW, "manual review required"));
}
return new RefundCase(request,
new RefundDecision(DecisionOutcome.APPROVED, "standard refund approved"));
}
}This class has no workflow dependency. That is the point. You can unit test it without Kafka, Flow, REST, or a running Quarkus application.
If your organization already has a rules engine, this class is the seam where it would go. The workflow only cares that evaluate() returns a RefundCase, whether the decision came from Drools, a DMN service, or a switch statement.
Store Decisions
We need a small store so the HTTP API can show the final result after the workflow finishes. This uses memory because the tutorial is about workflow state, not database schema design. In a real refund system, this would be a database table with a uniqueness constraint on refundId.
Create src/main/java/dev/mainthread/refunddesk/DecisionStore.java:
package dev.mainthread.refunddesk;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class DecisionStore {
private final ConcurrentMap<String, RefundResult> results = new ConcurrentHashMap<>();
public void recordAutomatic(RefundCase refundCase) {
RefundRequest request = refundCase.request();
RefundDecision decision = refundCase.decision();
results.put(request.refundId(),
new RefundResult(request.refundId(), decision.outcome(), decision.reason(), "policy"));
}
public void recordManualReview(ReviewDecision review) {
if (DecisionOutcome.MANUAL_REVIEW.equals(review.outcome())) {
throw new IllegalArgumentException("A reviewer must approve or deny the refund");
}
results.put(review.refundId(),
new RefundResult(review.refundId(), review.outcome(), review.note(), review.reviewer()));
}
public Optional<RefundResult> find(String refundId) {
return Optional.ofNullable(results.get(refundId));
}
}There is one production warning worth saying here, and only here: this store is not an idempotency boundary. A real refund system should enforce one final decision per refundId in the database. Quarkus Flow can propagate correlation data, but the receiver of a side effect still owns deduplication.
The Quarkus Flow idempotency and correlation guide is clear about the limit: Flow gives every workflow instance a unique identity and propagates correlation metadata for HTTP and CloudEvents. Exactly-once behavior still belongs at the receiver, with stable business keys and storage constraints. Anything claiming otherwise usually hides the invoice in another room.
Build the Workflow
Now we can write the orchestration. It has five moves:
Run the policy
Branch on the policy result
Record automatic decisions
Emit a review request for manual cases
Wait for a review callback and record it
Create src/main/java/dev/mainthread/refunddesk/RefundWorkflow.java:
package dev.mainthread.refunddesk;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.consume;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.consumed;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.emitJson;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.function;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.listen;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.switchWhenOrElse;
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.toOne;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkiverse.flow.Flow;
import io.serverlessworkflow.api.types.FlowDirectiveEnum;
import io.serverlessworkflow.api.types.Workflow;
import io.serverlessworkflow.fluent.func.FuncWorkflowBuilder;
@ApplicationScoped
public class RefundWorkflow extends Flow {
private final RefundPolicy policy;
private final DecisionStore decisions;
public RefundWorkflow(RefundPolicy policy, DecisionStore decisions) {
this.policy = policy;
this.decisions = decisions;
}
@Override
public Workflow descriptor() {
return FuncWorkflowBuilder.workflow("refund-review", "refunddesk", "1.0.0")
.tasks(
function("evaluatePolicy", policy::evaluate, RefundRequest.class),
switchWhenOrElse(this::needsManualReview, "requestManualReview", "finishAutomatic",
RefundCase.class),
emitJson("requestManualReview", "refund.review.required", RefundCase.class),
listen("waitForReview",
toOne(consumed("refund.review.completed").extensionByInstanceId("flowinstanceid"))),
consume("finishManualReview", decisions::recordManualReview, ReviewDecision.class)
.then(FlowDirectiveEnum.END),
consume("finishAutomatic", decisions::recordAutomatic, RefundCase.class)
.then(FlowDirectiveEnum.END))
.build();
}
private boolean needsManualReview(RefundCase refundCase) {
return DecisionOutcome.MANUAL_REVIEW.equals(refundCase.decision().outcome());
}
}This is where the workflow actually happens.
function("evaluatePolicy", ...) calls normal Java code. The result becomes the workflow data for the next step. switchWhenOrElse(...) decides which task name runs next. Automatic decisions jump to finishAutomatic. Manual-review decisions jump to requestManualReview.
emitJson(...) publishes a structured CloudEvent to the Flow outbound channel. In a real system, a reviewer UI or back-office queue would consume that event. Then listen(...) pauses the workflow until a refund.review.completed CloudEvent reaches the engine.
The correlation part is in this line:
toOne(consumed("refund.review.completed").extensionByInstanceId("flowinstanceid"))Flow will only resume this waiting workflow when the callback event has the matching flowinstanceid CloudEvent extension. That is the difference between “some review happened” and “the review for this workflow instance happened.”
The two then(FlowDirectiveEnum.END) calls are boring and easy to miss. After a manual review is recorded, the workflow must end. After an automatic decision is recorded, it must end. If you remove those endings, you have built a confusing process with excellent syntax highlighting.
Expose the Workflow Over HTTP
The REST resource starts workflow instances, reads final decisions, and turns reviewer callbacks into CloudEvents.
Create src/main/java/dev/mainthread/refunddesk/RefundResource.java:
package dev.mainthread.refunddesk;
import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
import java.net.URI;
import java.util.Map;
import java.util.UUID;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import io.serverlessworkflow.impl.WorkflowInstance;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
@Path("/refunds")
@ApplicationScoped
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
public class RefundResource {
private static final JsonFormat CE_JSON = (JsonFormat) EventFormatProvider.getInstance()
.resolveFormat(JsonFormat.CONTENT_TYPE);
@Inject
RefundWorkflow workflow;
@Inject
DecisionStore decisions;
@Inject
ObjectMapper objectMapper;
@Inject
@Channel("flow-in-outgoing")
Emitter<byte[]> flowIn;
@POST
public Response submit(RefundRequest request) {
WorkflowInstance instance = workflow.instance(request);
instance.start();
return Response.accepted(new SubmitRefundResponse(request.refundId(), instance.id())).build();
}
@GET
@Path("/{refundId}")
public Response result(@PathParam("refundId") String refundId) {
return decisions.find(refundId)
.map(result -> Response.ok(result).build())
.orElseGet(() -> Response.status(Status.NOT_FOUND).build());
}
@POST
@Path("/{refundId}/review/{instanceId}")
public Response review(@PathParam("refundId") String refundId,
@PathParam("instanceId") String instanceId,
ReviewDecision review) throws JsonProcessingException {
if (!refundId.equals(review.refundId())) {
return Response.status(Status.BAD_REQUEST)
.entity(Map.of("error", "path refundId and review refundId must match"))
.build();
}
byte[] body = objectMapper.writeValueAsBytes(review);
byte[] event = CE_JSON.serialize(CloudEventBuilder.v1()
.withId(UUID.randomUUID().toString())
.withSource(URI.create("urn:refunddesk:review-api"))
.withType("refund.review.completed")
.withDataContentType(APPLICATION_JSON)
.withExtension("flowinstanceid", instanceId)
.withData(body)
.build());
flowIn.send(event);
return Response.accepted().build();
}
}The callback endpoint writes a structured CloudEvent as JSON bytes. Notice the flowinstanceid extension. That value is the routing key for the waiting workflow instance.
The business key still matters. We keep refundId in the URL and in the event data because your domain system should know which refund was reviewed. The workflow instance ID wakes the process. The refund ID protects the business record.
Also notice that the callback writes to a channel named flow-in-outgoing, not directly to flow-in. In the configuration below, flow-in-outgoing is an outgoing producer that writes to the same Kafka topic that Flow consumes as flow-in. Keeping the app producer and Flow consumer as separate Reactive Messaging channels makes the wiring easier to reason about.
Configure Messaging, Persistence, and Tracing
Configure the application in src/main/resources/application.properties:
quarkus.application.name=refunddesk-flow
quarkus.flow.messaging.defaults-enabled=true
mp.messaging.incoming.flow-in.connector=smallrye-kafka
mp.messaging.incoming.flow-in.topic=refunddesk-flow-in
mp.messaging.incoming.flow-in.value.deserializer=org.apache.kafka.common.serialization.ByteArrayDeserializer
mp.messaging.incoming.flow-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.flow-in.auto.offset.reset=earliest
mp.messaging.outgoing.flow-out.connector=smallrye-kafka
mp.messaging.outgoing.flow-out.topic=refunddesk-flow-out
mp.messaging.outgoing.flow-out.value.serializer=org.apache.kafka.common.serialization.ByteArraySerializer
mp.messaging.outgoing.flow-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.flow-in-outgoing.connector=smallrye-kafka
mp.messaging.outgoing.flow-in-outgoing.topic=refunddesk-flow-in
mp.messaging.outgoing.flow-in-outgoing.value.serializer=org.apache.kafka.common.serialization.ByteArraySerializer
mp.messaging.outgoing.flow-in-outgoing.key.serializer=org.apache.kafka.common.serialization.StringSerializer
quarkus.flow.persistence.mvstore.db-path=target/refunddesk-flow.mv.db
%dev.quarkus.flow.tracing.enabled=true
%test.quarkus.flow.tracing.enabled=truequarkus.flow.messaging.defaults-enabled=true turns on the Flow messaging bridge. Flow reads structured CloudEvents from flow-in and publishes workflow events to flow-out. Because messaging-kafka is on the classpath, Quarkus Dev Services starts Kafka for local development when no broker is configured.
The flow-in-outgoing channel writes to the same refunddesk-flow-in topic. That lets the REST callback endpoint wake the waiting workflow through the same external event path a real reviewer UI would use.
The MVStore path keeps workflow checkpoints in target/. This is fine for a tutorial and local testing. For production, put workflow state in a durable store outside the application filesystem.
Tracing is enabled in dev and test so you can see which workflow and task ran. Quarkus Flow’s tracing docs also show how to emit MDC fields for log correlation. The short version: use the workflow instance ID when you search logs. It is the thread that ties the process together after the process stops being one HTTP request.
Run the App
Start dev mode:
./mvnw quarkus:devSubmit a small refund:
curl -i -X POST http://localhost:8080/refunds \
-H 'Content-Type: application/json' \
-d '{
"refundId": "refund-1001",
"customerId": "customer-7",
"amount": 42.00,
"receiptPresent": true,
"accountAgeDays": 120,
"chargebackCount": 0
}'Expected response:
{
"refundId": "refund-1001",
"workflowInstanceId": "01J..."
}The endpoint always returns 202 Accepted because the workflow may finish immediately or may wait for a later event. That stable contract is easier for clients. The result lives behind GET /refunds/{refundId}.
Check the result:
curl http://localhost:8080/refunds/refund-1001Expected response:
{
"refundId": "refund-1001",
"outcome": "APPROVED",
"reason": "small refund with clean history",
"reviewer": "policy"
}Try a denied refund:
curl -i -X POST http://localhost:8080/refunds \
-H 'Content-Type: application/json' \
-d '{
"refundId": "refund-1002",
"customerId": "customer-8",
"amount": 19.99,
"receiptPresent": false,
"accountAgeDays": 400,
"chargebackCount": 0
}'
curl http://localhost:8080/refunds/refund-1002Expected result:
{
"refundId": "refund-1002",
"outcome": "DENIED",
"reason": "receipt is missing",
"reviewer": "policy"
}Now submit a refund that needs a person:
curl -i -X POST http://localhost:8080/refunds \
-H 'Content-Type: application/json' \
-d '{
"refundId": "refund-1003",
"customerId": "customer-9",
"amount": 450.00,
"receiptPresent": true,
"accountAgeDays": 15,
"chargebackCount": 2
}'Keep the workflowInstanceId from the response. Before the review arrives, the result is not ready:
curl -i http://localhost:8080/refunds/refund-1003Expected status:
HTTP/1.1 404 Not FoundFor the manual curl path, wait until the workflow has reached the review step before sending the callback. In dev mode you will see requestManualReview complete and waitForReview start in the log. A real reviewer UI would not guess this timing; it would consume the refund.review.required event from flow-out, store its flowinstanceid, and use that value for the callback.
Send the review callback. Replace 01J... with the workflow instance ID returned by the submit call:
curl -i -X POST http://localhost:8080/refunds/refund-1003/review/01J... \
-H 'Content-Type: application/json' \
-d '{
"refundId": "refund-1003",
"outcome": "APPROVED",
"reviewer": "alex",
"note": "receipt and order history checked"
}'Check the result again:
curl http://localhost:8080/refunds/refund-1003Expected response:
{
"refundId": "refund-1003",
"outcome": "APPROVED",
"reason": "receipt and order history checked",
"reviewer": "alex"
}That is the workflow shape a rules engine does not cover by itself. The policy decision was one task. The process around the decision crossed an event boundary, waited, resumed, and recorded the final state.
Test the Policy First
Test the policy outside Quarkus. This is a normal unit test, and that is a feature.
Create src/test/java/dev/mainthread/refunddesk/RefundPolicyTest.java:
package dev.mainthread.refunddesk;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class RefundPolicyTest {
private final RefundPolicy policy = new RefundPolicy();
@Test
void approvesSmallCleanRefund() {
RefundCase refundCase = policy.evaluate(new RefundRequest(
"refund-2001",
"customer-1",
new BigDecimal("25.00"),
true,
120,
0));
assertEquals(DecisionOutcome.APPROVED, refundCase.decision().outcome());
assertEquals("small refund with clean history", refundCase.decision().reason());
}
@Test
void deniesMissingReceipt() {
RefundCase refundCase = policy.evaluate(new RefundRequest(
"refund-2002",
"customer-2",
new BigDecimal("25.00"),
false,
120,
0));
assertEquals(DecisionOutcome.DENIED, refundCase.decision().outcome());
}
@Test
void routesSuspiciousRefundToManualReview() {
RefundCase refundCase = policy.evaluate(new RefundRequest(
"refund-2003",
"customer-3",
new BigDecimal("450.00"),
true,
10,
2));
assertEquals(DecisionOutcome.MANUAL_REVIEW, refundCase.decision().outcome());
}
}Run it:
./mvnw test -Dtest=RefundPolicyTestThis test does not prove the workflow. It proves the decision logic. That separation keeps the article honest: a rules engine would sit at the same boundary.
Test the Workflow Through HTTP
Now test the process. This test starts the app, submits refunds, reads the emitted review request from flow-out, sends a review callback, and polls for the final result.
Add two test dependencies to pom.xml:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-kafka-companion</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>Create src/test/resources/application.properties:
quarkus.flow.messaging.defaults-enabled=true
mp.messaging.incoming.flow-in.auto.offset.reset=earliest
quarkus.flow.persistence.mvstore.db-path=target/refunddesk-flow-test.mv.db
quarkus.flow.persistence.auto-restore=false
quarkus.flow.tracing.enabled=trueCreate src/test/java/dev/mainthread/refunddesk/RefundResourceTest.java:
package dev.mainthread.refunddesk;
import static io.restassured.RestAssured.given;
import static java.time.Duration.ofSeconds;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;
import java.math.BigDecimal;
import java.util.concurrent.atomic.AtomicReference;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.apache.kafka.common.serialization.ByteArrayDeserializer;
import org.apache.kafka.common.serialization.StringDeserializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.kafka.InjectKafkaCompanion;
import io.quarkus.test.kafka.KafkaCompanionResource;
import io.restassured.http.ContentType;
import io.restassured.response.Response;
import io.smallrye.reactive.messaging.kafka.companion.ConsumerTask;
import io.smallrye.reactive.messaging.kafka.companion.KafkaCompanion;
@QuarkusTest
@QuarkusTestResource(KafkaCompanionResource.class)
class RefundResourceTest {
private static final JsonFormat CE_JSON = (JsonFormat) EventFormatProvider.getInstance()
.resolveFormat(JsonFormat.CONTENT_TYPE);
@InjectKafkaCompanion
KafkaCompanion companion;
@Inject
ObjectMapper objectMapper;
@Test
void autoApprovesSmallRefund() {
given()
.contentType(ContentType.JSON)
.body(new RefundRequest("refund-test-1", "customer-1", new BigDecimal("42.00"), true, 120, 0))
.when()
.post("/refunds")
.then()
.statusCode(202);
Response result = waitForResult("refund-test-1");
result.then()
.statusCode(200)
.body("outcome", equalTo("APPROVED"))
.body("reviewer", equalTo("policy"));
}
@Test
void resumesManualReviewFromCallbackEvent() throws Exception {
ConsumerTask<Object, Object> flowOut = companion
.consumeWithDeserializers(StringDeserializer.class, ByteArrayDeserializer.class)
.fromTopics("refunddesk-flow-out");
given()
.contentType(ContentType.JSON)
.body(new RefundRequest("refund-test-2", "customer-2", new BigDecimal("450.00"), true, 10, 2))
.when()
.post("/refunds")
.then()
.statusCode(202);
CloudEvent reviewRequired = waitForReviewRequiredEvent(flowOut);
assertEquals("refund.review.required", reviewRequired.getType());
assertNotNull(reviewRequired.getExtension("flowinstanceid"));
RefundCase refundCase = objectMapper.readValue(reviewRequired.getData().toBytes(), RefundCase.class);
assertEquals("refund-test-2", refundCase.request().refundId());
given()
.contentType(ContentType.JSON)
.body(new ReviewDecision("refund-test-2", DecisionOutcome.APPROVED, "test-reviewer", "checked"))
.when()
.post("/refunds/{refundId}/review/{instanceId}",
"refund-test-2",
reviewRequired.getExtension("flowinstanceid").toString())
.then()
.statusCode(202);
Response result = waitForResult("refund-test-2");
result.then()
.statusCode(200)
.body("outcome", equalTo("APPROVED"))
.body("reviewer", equalTo("test-reviewer"));
flowOut.close();
}
@Test
void rejectsMismatchedReviewCallback() {
given()
.contentType(ContentType.JSON)
.body(new ReviewDecision("other-refund", DecisionOutcome.APPROVED, "test-reviewer", "checked"))
.when()
.post("/refunds/{refundId}/review/{instanceId}",
"refund-test-3",
"01JFAKEINSTANCE")
.then()
.statusCode(400)
.body("error", equalTo("path refundId and review refundId must match"));
}
private Response waitForResult(String refundId) {
for (int attempt = 0; attempt < 50; attempt++) {
Response response = given()
.accept(ContentType.JSON)
.when()
.get("/refunds/{refundId}", refundId);
if (response.statusCode() == 200) {
return response;
}
sleep();
}
fail("Timed out waiting for refund result " + refundId);
return null;
}
private void sleep() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
private CloudEvent waitForReviewRequiredEvent(ConsumerTask<Object, Object> flowOut) {
AtomicReference<CloudEvent> reviewRequired = new AtomicReference<>();
await().atMost(ofSeconds(10)).untilAsserted(() -> {
flowOut.stream().forEach(record -> {
CloudEvent event = CE_JSON.deserialize((byte[]) record.value());
if ("refund.review.required".equals(event.getType())) {
reviewRequired.set(event);
}
});
assertNotNull(reviewRequired.get());
});
return reviewRequired.get();
}
}Run all tests:
./mvnw testThe manual-review test deliberately consumes refund.review.required before it sends the callback. That order matters. The event proves the workflow reached the wait point and gives the test the same flowinstanceid a reviewer UI would store with its work item.
What Flow Guarantees, and What It Does Not
The workflow now waits and resumes, but the boundary is still important.
Quarkus Flow assigns each workflow instance a unique ID. It can propagate that ID through HTTP headers and CloudEvent extension attributes. In our callback event, flowinstanceid tells Flow which waiting instance to resume.
Flow treats independent workflow instances as independent work. If the same refund.requested event arrives twice and you start two workflows, you have two workflow instances. Your application still needs a business idempotency key such as refundId and a database constraint that makes duplicate final decisions impossible.
Flow also gives you at-least-once task execution, not exactly-once execution. If a task calls an external service and the network fails at the wrong moment, the receiver must be safe to call again. Use stable business keys for operations such as payments, refunds, ledger writes, and emails.
That may sound disappointing until you compare it with the alternative: hiding the same distributed-systems problem behind a rule called approveRefund. At least here the boundary is visible.
Make the Workflow Survive a Restart
With quarkus-flow-mvstore on the classpath and the MVStore path configured, Flow checkpoints state after completed tasks and paused steps. You can test the practical effect:
Start the app with
./mvnw quarkus:devSubmit
refund-1003, the manual-review caseStop dev mode with
Ctrl+CStart dev mode again
Send the review callback using the same
workflowInstanceIdfrom the curl response, or theflowinstanceidfromflow-outif you are testing through a reviewer event consumerRead
GET /refunds/refund-1003
The waiting workflow should resume because the workflow state was persisted. The DecisionStore in this article is still in memory, so it only records the final result after the callback arrives. If you want the submitted refund record itself to survive restarts, put the domain state in a database too.
For production, the Quarkus Flow persistence guide gives three provider paths:
Redis for distributed workloads
JPA for relational databases
MVStore for local development, tests, and single-node deployments
Pick one persistence provider. Running several persistence implementations in the same app is a good way to make future-you read source code with bad coffee.
Use the Runner When the Workflow Is Configuration
The Java DSL is the better fit for RefundDesk because the workflow calls CDI beans and domain code. Quarkus Flow 0.11.0 also ships pre-built Runner images for a different case: you have workflow definitions as YAML or JSON and want to run them as configuration.
Use the Runner when a platform team wants to mount workflows from GitOps, ConfigMaps, or another external workflow authoring path. Use the Java DSL when the workflow is tightly coupled to Quarkus beans and you want compile-time help.
Create a small policy-only workflow file under a new local directory:
mkdir -p workflowsCreate workflows/refund-policy.yaml:
document:
dsl: "1.0.0"
namespace: refunddesk
name: refund-policy
version: "1.0.0"
input:
schema:
format: json
document:
type: object
required:
- amount
- receiptPresent
- chargebackCount
properties:
amount:
type: number
receiptPresent:
type: boolean
chargebackCount:
type: integer
do:
- route:
switch:
- missingReceipt:
when: .receiptPresent == false
then: deny
- smallClean:
when: .amount <= 50 and .chargebackCount == 0
then: approve
- default:
then: manualReview
- approve:
set:
outcome: APPROVED
reason: small refund with clean history
then: exit
- deny:
set:
outcome: DENIED
reason: receipt is missing
then: exit
- manualReview:
set:
outcome: MANUAL_REVIEW
reason: manual review required
then: exitRun the minimal Runner image:
podman run --rm \
--name refunddesk-runner \
-p 8081:8080 \
-v "$PWD/workflows:/deployments/workflows:ro" \
quay.io/quarkiverse/quarkus-flow-runner:0.11.0-minimalIn another terminal, execute the workflow:
curl -X POST 'http://localhost:8081/q/flow/exec/refunddesk/refund-policy/1.0.0?wait=true' \
-H 'Content-Type: application/json' \
-d '{
"amount": 42.00,
"receiptPresent": true,
"chargebackCount": 0
}'Expected output:
{
"instanceId": "01KW65RE8KKQPW7FT6CQY7QS5Y",
"status": "COMPLETED",
"startedAt": "2026-06-28T03:53:36.274777328Z",
"completedAt": "2026-06-28T03:53:36.306582193Z",
"workflowOutput": {
"reason": "small refund with clean history",
"outcome": "APPROVED"
}
}This Runner version is intentionally smaller than the Java app. It shows the release feature without pretending YAML is always the right abstraction. The Runner can execute workflow definitions mounted at runtime. The Java app can call your CDI beans, keep policy code in normal Java, use tests, and participate in the rest of your Quarkus application.
That split is useful. Some workflows are platform configuration. Some workflows are application code. Treating both as the same thing usually makes one team unhappy.
Where @ScheduleOn Fits
Quarkus Flow 0.11.0 also adds @ScheduleOn for agentic LangChain4j workflows. That is not part of RefundDesk because this example does not need agents.
The feature matters when an agentic method should wake from an event, a cron schedule, or a fixed interval. For example, a nightly refund-anomaly reviewer could run every hour, fetch suspicious cases, and produce a report. The Quarkus Flow @ScheduleOn guide documents three trigger styles:
eventfor a CloudEvent typecronfor a Unix cron expressioneveryfor an ISO 8601 duration
The same design rule applies: use the agent for the task that needs language or fuzzy reasoning. Use the workflow for the process that needs state, waiting, and evidence.
Production Notes That Matter
RefundDesk is small, but it creates real production questions.
Use a database for final decisions. The ConcurrentHashMap keeps the tutorial readable, but the business boundary belongs in storage with a unique key on refundId.
Use idempotency keys at receivers. If recordManualReview() became a real refund transaction, the downstream payment or ledger API should reject duplicate refundId operations or return the already-recorded result.
Keep workflow state outside the pod. MVStore is fine for local testing. Use Redis, JPA, or the production Runner variants when workflow state must survive node drains, pod replacement, and rolling updates.
Treat flow-out as part of the contract. The review UI should consume refund.review.required, store the flowinstanceid, and send it back on refund.review.completed. Guessing instance IDs from an HTTP response is a tutorial convenience.
Use structured logs or tracing before the first incident. Quarkus Flow can emit workflow and task lifecycle data. Searching by workflow instance ID is much better than stitching together five log lines by timestamp and hope.
Use Quartz for clustered schedules. The normal scheduler is enough for simple standalone deployments. The Quarkus Flow Quartz scheduler docs call out persistent Quartz for clustered scheduled workflows.
Conclusion
RefundDesk keeps the policy small and lets the workflow own the process around it: branching, event emission, waiting, correlation, and final recording. That is the mental model I would keep: rules decide from facts, workflows coordinate what happens over time.



