Modern Java systems rarely fail because of missing features.
They fail because synchronous designs don’t scale under real pressure.
As soon as a workflow spans multiple steps, services, or time boundaries, classic request/response starts working against you. Callers wait. Latency compounds. Failures propagate.
This article shows how to move from REST-style orchestration to an event-driven pipeline using Quarkus and MicroProfile Reactive Messaging. The focus is not on frameworks, but on architectural intent: decoupling, bounded streams, and clean completion.
The example is deliberately simple, but the pattern is production-grade.
The Problem: REST Works Until It Doesn’t
REST is a good default when:
- The caller expects an immediate result
- The operation is short-lived
- Tight coupling is acceptable
It breaks down when:
- Work continues after the response is sent
- Multiple services react to the same event
- Throughput matters more than latency
- Failure isolation becomes critical
In these cases, the system is no longer about requests.
It is about flows.
Reactive Messaging as a Structural Tool
MicroProfile Reactive Messaging gives Java developers a minimal model:
- Channels represent streams of events
- Producers emit messages
- Consumers react independently
- Completion is explicit and meaningful
There is no custom threading model to manage and no low-level messaging API to fight with. You describe the flow. The runtime wires it.
The Example: A Bounded Order Processing Flow
We’ll build a small pipeline with four stages:
- Orders are accepted via REST
- Orders flow through a kitchen processor
- All orders are aggregated
- A final notification is emitted when the stream completes
The important part is not the domain.
It’s that the stream has a clear start and a clear end.
Domain Model
public record Order(String id, String item, double price) {}
public record KitchenTicket(String orderId, String item, String status, double price) {}
Opening and Closing the Stream Explicitly
Orders should only flow while the system is “open”.
When the tap closes, downstream stages must know that no more data is coming.
@ApplicationScoped
public class OrderGateway {
@Channel("orders")
Emitter<Order> emitter;
private volatile boolean open;
public void open() {
open = true;
}
public void close() {
open = false;
emitter.complete();
}
public void submit(Order order) {
if (open) {
emitter.send(order);
}
}
}
This is a key architectural decision:
completion is a signal, not an error case.
REST as an Ingress, Not the Orchestrator
REST is still useful, but only at the edge.
@Path("/orders")
@ApplicationScoped
public class OrderResource {
@Inject
OrderGateway gateway;
@POST
public void create(Order order) {
gateway.submit(order);
}
@POST
@Path("/open")
public void open() {
gateway.open();
}
@POST
@Path("/close")
public void close() {
gateway.close();
}
}
REST triggers events.
It does not control the workflow.
Processing Orders Without Blocking Callers
The kitchen reacts to incoming orders and emits enriched events.
@ApplicationScoped
public class KitchenService {
@Incoming("orders")
@Outgoing("kitchen")
public KitchenTicket prepare(Order order) {
return new KitchenTicket(
order.id(),
order.item(),
"PREPARED",
order.price()
);
}
}
No caller waits for this.
No service depends on the kitchen being fast.
Aggregation Triggered by Completion
This is where reactive messaging differs from ad-hoc async code.
The aggregator keeps state while the stream is active and emits one final result when the stream completes.
@ApplicationScoped
public class TotalAggregator {
private final DoubleAdder total = new DoubleAdder();
@Incoming("kitchen")
@Outgoing("totals")
public Multi<Double> aggregate(Multi<KitchenTicket> tickets) {
return tickets
.onItem().invoke(t -> total.add(t.price()))
.onCompletion().continueWith(total.doubleValue());
}
}
This pattern is extremely useful for:
Batch-style processing
Session-based workflows
Controlled ingestion windows
Finalization logic that must run exactly once
Observing the Outcome
The final consumer receives exactly one message.
@ApplicationScoped
public class NotificationSink {
@Incoming("totals")
public void notify(Double total) {
System.out.println("Final order total: " + total);
}
}
There is no polling.
No coordination logic.
No shared state across services.
Verifying the Flow
When running the application:
Open the tap
Submit multiple orders
Close the tap
The system prints a single aggregated total only after the stream completes.
That behavior is deterministic and explicit.
Production Notes
The in-memory connector is ideal for development and testing
In production, use Kafka or AMQP connectors
Be deliberate about stream boundaries
Avoid mixing multiple producers into the same channel without explicit merging
Treat completion as a design decision, not a side effect
Reactive messaging does not replace REST.
It complements it where REST becomes a liability.
Further Reading
This article is part of The Main Thread, a publication focused on modern Java architecture, real-world systems, and production-grade engineering.
Read the full version here:
https://www.the-main-thread.com/p/quarkus-reactive-messaging-tutorial-java-event-driven
Because modern Java deserves better content.

Top comments (0)