Orchestration styles: choreography vs. two kinds of orchestration

Three coordination engines over the same shipping/order domain (Kafka choreography, a Camel route, and a declarative Quarkus Flow workflow) and when decentralized reaction beats a named coordinator.

50 minutesChapter 14The Quarkus deep-dive

Every event-driven system eventually has to answer one question: when multiple steps need to happen in sequence, who decides the sequence? This project runs three answers side by side over the same shipping/order domain. Two of them are orchestration, but they use different engines: “orchestration” describes a shape (a coordinator exists), not a specific technology.

The code is in order-service, payment-service, shipping-service, notification-service (the choreography leg), and ai-rules-service (both orchestration legs); demo-orchestration-styles.sh runs all three legs back to back (see “Build, run, observe” below for what each act checks).

Choreography: no central coordinator

Choreography means every participant reacts to events on its own terms, with no central process telling it when to run. order-service publishes order.placed to Kafka and has never heard of payment-service or shipping-service — it doesn’t know they exist, doesn’t call them, and doesn’t wait for anything from them. payment-service independently subscribes to order.placed; when one arrives, it captures payment and publishes payment.captured. shipping-service independently subscribes to payment.captured; when one arrives, it dispatches a shipment and publishes shipment.dispatched. notification-service also independently subscribes to order.placed — a fourth reaction to the same original event, running in parallel with the payment/shipping chain, not after it.

order-service --order.placed--> [Kafka] --> payment-service --payment.captured--> [Kafka] --> shipping-service --shipment.dispatched--> [Kafka]
                                         \-> notification-service (reacts to order.placed directly, in parallel)

No service holds a reference to “the whole sequence.” Each one only knows one rule: when event X arrives, do Y and emit Z (or, for notification-service, when event X arrives, do Y — it emits nothing further). demos/demo-orchestration-styles.sh’s first act proves this by placing one real order and then reading the raw bytes back off each downstream topic with kcat — a byte-level Kafka consumer, no Avro deserializer involved on the read side. It asserts the Apicurio/Confluent wire-format magic byte (0x00) is the first byte of each record, which is the demo’s check that these are Avro-encoded events on Kafka. It also queries shipping-service’s own Postgres table directly (shippingdb.shipment) and notification-service’s REST surface, confirming each service’s own persisted state independently reflects the event it reacted to.

The cost of choreography is that no single place describes what happens when an order is placed; you have to find every subscriber. The same property is its benefit: adding a fifth reaction to order.placed (say, an analytics service) requires zero changes to order-service, payment-service, or shipping-service. That loose coupling is why event-driven architectures default to choreography for cross-service reactions. The next two sections show what a coordinator adds and what it costs.

Orchestration: a Camel route

Orchestration means a single process explicitly sequences the steps and knows the whole flow. ai-rules-service exposes POST /api/orders/triage, backed by OrderTriageRoute.java:

from("direct:triage")
    .routeId("triage-order")
    .log("Triaging order (Camel): ${body}")
    .unmarshal().json(JsonLibrary.Jackson, OrderCreate.class)
    .bean(triageService, "classify")
    .log("Classification result: ${body}")
    .bean(triageService, "decide")
    .marshal().json(JsonLibrary.Jackson);

This route is the coordinator. It explicitly names every step in order: unmarshal the request, call classify, log the intermediate result, call decide, marshal the response. Nothing about the sequence is implicit, so reading the route top to bottom is reading the business process. classify and decide are both plain methods on TriageService.java: classify sends a single-shot prompt to an injected langchain4j ChatModel (backed by Ollama) and parses the category/priority/riskSignal JSON it returns; decide inserts those classified fields as a fact into a Drools KieSession and fires the rules in order-triage.drl to produce a FRAUD_HOLD, EXPEDITE, or ROUTE_TO_WAREHOUSE decision. The route coordinates, and Drools decides.

Orchestration: a Quarkus Flow workflow

The same two-step sequence is orchestrated a second way by OrderTriageWorkflow.java, reached via POST /api/orders/triage-flow:

@ApplicationScoped
public class OrderTriageWorkflow extends Flow {

    @Inject
    TriageService triageService;

    @Override
    public Workflow descriptor() {
        return FlowWorkflowBuilder.workflow("order-triage")
            .tasks(
                FlowDSL.function("classify", triageService::classify),
                FlowDSL.function("decide", triageService::decide))
            .build();
    }
}

This is a declarative coordinator. Instead of imperative route code calling one bean method after another, it is a workflow document, built here with quarkus-flow’s Java DSL and structurally the same as a CNCF Serverless Workflow YAML or JSON document. It declares two tasks, classify then decide, and the Quarkus Flow engine’s default behavior (each task’s input is the prior task’s output) connects them. OrderTriageFlowRunner.java bridges this back to the REST layer:

public TriageDecision run(OrderCreate order) {
    return workflow.startInstance(order)
        .onItem().transform(model -> model.as(TriageDecision.class).orElseThrow())
        .await().atMost(Duration.ofSeconds(120));
}

startInstance(order) returns a Uni (Flow instances run asynchronously); .await().atMost(Duration.ofSeconds(120)) blocks the calling thread until the workflow completes (the timeout is bounded so a stuck instance fails the request instead of hanging it), then .as(TriageDecision.class) unwraps the workflow’s final model into the response shape /triage returns. Both endpoints call the same TriageService.classify and decide methods. The workflow does not reimplement the decision logic as, say, a Flow switchCase, so Drools makes the decision in both paths.

# Kafka choreography — order-service publishes; nothing it calls, nothing calls it back.
# No single file "is" this flow — it is the union of three services' independent
# @Incoming/@Outgoing channel bindings (application.properties), shown here as one
# diagram of what actually happens on the wire:

order-service:
  publishes: order.placed          # OrderEventProducer, Avro via Apicurio

payment-service:
  consumes: order.placed           # reacts on its own; order-service never calls it
  publishes: payment.captured

shipping-service:
  consumes: payment.captured       # reacts on its own; payment-service never calls it
  publishes: shipment.dispatched

notification-service:
  consumes: order.placed           # a FOURTH, independent reaction to the same event
// Camel orchestration — OrderTriageRoute.java
// One route explicitly sequences every step; this IS the coordinator.
from("direct:triage")
    .routeId("triage-order")
    .unmarshal().json(JsonLibrary.Jackson, OrderCreate.class)
    .bean(triageService, "classify")
    .bean(triageService, "decide")
    .marshal().json(JsonLibrary.Jackson);
// Quarkus Flow orchestration — OrderTriageWorkflow.java
// Same two steps, declared instead of sequenced imperatively.
return FlowWorkflowBuilder.workflow("order-triage")
    .tasks(
        FlowDSL.function("classify", triageService::classify),
        FlowDSL.function("decide", triageService::decide))
    .build();
Three coordination shapes over the same shipping/order domain: decentralized Kafka choreography across order-service, payment-service, shipping-service and notification-service with no central caller; a Camel route in ai-rules-service explicitly sequencing classify then decide; and a declarative Quarkus Flow workflow document expressing the same two tasks
Figure 13.1 — Choreography and two orchestration shapes, side by side

The choreography box has no entry point: every service subscribes to a topic and publishes to another, and no node is labeled “coordinator” because none exists. The two orchestration boxes each have one entry point and one owner of the sequence, drawn differently. The Camel box is a straight line of named steps, because a route is a sequence of method calls. The Quarkus Flow box is a small graph of tasks with declared dependencies, because a workflow document states what must happen before what and leaves the calling to the engine. The difference between code that calls things in order and data that declares an order is separate from the difference between orchestration and choreography. A coordinator can exist without being a hand-written imperative function, which is why the Quarkus Flow leg is included.

When to reach for which

  Kafka choreography Camel orchestration Quarkus Flow orchestration
Who knows the whole sequence No one; each service knows only its own reaction The route, read top to bottom The workflow document, read declaratively
Adding a new participant Zero changes to existing services (just subscribe) Edit the route to add a step Edit the workflow document to add a task
Where to look when something’s wrong Every subscriber’s own logs/topic One route’s log output One workflow instance’s task history
Best fit Independent reactions to a domain event, unknown/growing set of subscribers, no step needs to wait on another’s result before proceeding A fixed, code-reviewed business process where the sequence itself is the valuable artifact, and you want full imperative control (branching, error handling, EIPs) The same kind of fixed business process, but you want the sequence expressed as data (a workflow document) rather than code — useful when the process itself needs to be inspected, versioned, or edited independently of a Java release
Coupling Loosest: publishers and subscribers never reference each other Tighter: the route references every participant bean directly Tighter, as with Camel, but the reference is a task graph, not imperative calls

The rule of thumb: reach for choreography when you have a domain event other parts of the system might react to today or in the future, and you don’t want the publisher to know or care who’s listening. Reach for orchestration when a specific business process has to produce one determinate outcome and you need the sequence itself to be an explicit, inspectable artifact — and then choose imperative (Camel route) vs. declarative (Quarkus Flow) based on whether that artifact is better reviewed as code or as a document.

Limit: the three legs do not share one order

A single order does not flow through all three engines (placed via Kafka, then triaged via Camel, then triaged again via Flow). The script’s header gives the reason: the triage endpoints (/triage, /triage-flow) only accept an order’s line-item fields — they don’t persist anything and don’t publish an event. The Kafka leg’s order payload was never run through a classifier-stability trial, and the order used for the choreography leg was not chosen to produce a deterministic triage decision. The legs share the domain (customer, SKU, quantity, amount) and the comparison: one decentralized mechanism against two differently shaped centralized ones, coordinating the same kind of step.

The demo uses a pre-validated input for Act 2 and Act 3 instead of reusing Act 1’s order, because a deterministic coordinator wraps a non-deterministic step. Chapter 14 shows that /triage and /triage-flow both delegate their actual classification to a small local LLM (qwen2.5:3b via Ollama) before Drools sees a fact. An LLM classification is not guaranteed to repeat on every input, so asserting a specific decision (ROUTE_TO_WAREHOUSE, not one of three valid decisions) requires an input whose classification has been stable across repeated trials. Act 1’s order was never put through that trial because Act 1 does not test classification. It tests whether four independently deployed services react to Kafka events, which does not depend on the order’s fields. Keeping the acts’ inputs uncoupled lets each act make a strict assertion.

Build, run, observe

cd demos && ./demo-orchestration-styles.sh

This single script stands up order-service, inventory-service, payment-service, shipping-service, and notification-service for the choreography act, and ai-rules-service (with Ollama via the compose ollama profile) for both orchestration acts, against the compose baseline. Act 1 places one order and polls each downstream Kafka topic with kcat, asserting the Avro wire-format magic byte on each hop and corroborating with a direct Postgres row check and a REST lookup. Act 2 posts to /triage and asserts a strict decision (ROUTE_TO_WAREHOUSE, for the pre-validated stable low-risk input used here). Act 3 posts the identical input to /triage-flow and asserts the identical decision. A closing recap narrates the three mechanisms side by side.

What you learned

  • Choreography (Kafka): decentralized, no coordinator, participants only know “react to event X, emit event Z”; adding a subscriber costs nothing to existing services.
  • Orchestration (Camel, Quarkus Flow): a named coordinator sequences explicit steps; the two orchestration engines here differ in how the sequence is expressed — imperative route code vs. a declarative workflow document — not in whether a coordinator exists.
  • The choice between choreography and orchestration depends on who needs to see the whole sequence and how loosely coupled the participants should be.

  • The three legs of the demo share a domain and a comparison, not one order flowing through all three.

Chapter 14 covers the AI-assisted triage behind both orchestration legs, including where in-process LLM tool-calling works and where it does not.


Verification status: verified. The Flow, Drools, and route unit tests pass under mvn verify, and demo-orchestration-styles.sh ran end to end against the compose stack with the Ollama profile and a live qwen2.5:3b. All three coordination styles (Kafka choreography, the Camel route, and the Quarkus Flow workflow) ran over the same order-to-shipment domain and passed. Re-run on 2026-10-06 (walkthrough act 2): passed after the demo was changed to pull qwen2.5:3b into the compose volume when it is missing, as the act 3 demos already did.