DEV Community

Markus
Markus

Posted on • Originally published at the-main-thread.com on

Building Event-Driven Pipelines in Java Without Blocking Everything

Reactive Java

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:

  1. Orders are accepted via REST
  2. Orders flow through a kitchen processor
  3. All orders are aggregated
  4. 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) {}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)