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