Quarkus Signals
experimentalSignals allow application components to interact in a loosely coupled fashion, by emitting and receiving signals. A signal is an object — optionally augmented with qualifiers and metadata — that is emitted by one component and delivered to one or more receivers.
- Resolution
-
The extension provides a type-safe resolution model, inspired by the CDI events.
- Emission Modes
-
Inspired by the Vert.x EventBus, the API supports unicast (point-to-point) and request-reply patterns in addition to the traditional multicast (publish-subscribe) pattern.
- Execution Model
-
Receivers are always executed asynchronously and support both blocking and non-blocking logic. The execution model is determined from the method return type and annotations such as
@Blocking,@NonBlocking, and@RunOnVirtualThread, following Quarkus conventions.
1. Installation
If you already have your Quarkus project configured, you can add the quarkus-signals extension to your project by running the following command in your project base directory:
quarkus extension add quarkus-signals
./mvnw quarkus:add-extension -Dextensions='quarkus-signals'
./gradlew addExtension --extensions='quarkus-signals'
Alternatively, add the following dependency to your project:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-signals</artifactId>
</dependency>
implementation("io.quarkus:quarkus-signals")
2. Signal Components
A signal consists of three parts:
-
A signal object — an instance of a Java class that carries the data.
-
An optional set of qualifiers — annotation instances that distinguish between different kinds of signals of the same type.
-
Optional metadata — arbitrary key-value pairs attached to the emission.
The combination of signal type and qualifiers determines which receivers are notified when a signal is emitted.
For example, suppose we have a signal object of type OrderPlaced:
public record OrderPlaced(String orderId, BigDecimal total) {}
| Signal objects should be immutable or thread-safe, because they can be accessed from multiple threads concurrently. |
We could define a qualifier to distinguish urgent orders:
@Qualifier
@Retention(RUNTIME)
@Target({PARAMETER, FIELD})
public @interface Urgent {}
3. Emitting Signals
The Signal interface is the entry point for emitting signals.
An application component obtains a Signal instance by injecting it as a CDI bean:
@Inject
Signal<OrderPlaced> orderPlaced;
Any combination of qualifiers may be specified at the injection point:
@Inject
@Urgent
Signal<OrderPlaced> urgentOrderPlaced;
3.1. Emission Modes
A signal can be emitted in three modes.
3.1.1. Publish (Multicast)
Delivers the signal to all matching receivers.
// Fire-and-forget
orderPlaced.publish(new OrderPlaced("123", BigDecimal.TEN));
// Returns a Uni that completes when all receivers are done
Uni<Void> result = orderPlaced.reactive().publish(new OrderPlaced("123", BigDecimal.TEN));
3.1.2. Send (Unicast)
Delivers the signal to a single matching receiver, selected in round-robin order.
// Fire-and-forget
orderPlaced.send(new OrderPlaced("123", BigDecimal.TEN));
// Returns a Uni that completes when the receiver is done
Uni<Void> result = orderPlaced.reactive().send(new OrderPlaced("123", BigDecimal.TEN));
3.1.3. Request (Unicast, Request-Reply)
Delivers the signal to a single matching receiver and returns the response. Only receivers whose response type is assignable to the requested type are considered.
// Blocking
String confirmation = orderPlaced.request(new OrderPlaced("123", BigDecimal.TEN), String.class);
// Reactive
Uni<String> confirmation = orderPlaced.reactive().request(new OrderPlaced("123", BigDecimal.TEN), String.class);
3.2. Selecting Subtypes and Qualifiers
A child Signal can be obtained from an existing one in order to narrow the type or add qualifiers:
// Add qualifiers
Signal<OrderPlaced> urgent = orderPlaced.select(new Urgent.Literal());
// Narrow the signal type
Signal<TaskCreated> created = taskSignal.select(TaskCreated.class);
// Combine both
Signal<TaskCreated> urgentCreated = taskSignal.select(TaskCreated.class, new Urgent.Literal());
3.3. Signal Metadata
Arbitrary key-value metadata can be attached to an emission.
Receivers can access this metadata via the SignalContext.
// Attach metadata to a single emission
orderPlaced
.withMetadata("traceId", "abc-123")
.withMetadata("source", "web")
.publish(new OrderPlaced("123", BigDecimal.TEN));
// Or replace all metadata at once
orderPlaced
.withReplacedMetadata(Map.of("traceId", "abc-123", "source", "web"))
.publish(new OrderPlaced("123", BigDecimal.TEN));
Both withMetadata() and withReplacedMetadata() return a new child Signal instance; the original instance is not modified.
|
3.4. No Matching Receiver
If no receiver matches the emitted signal:
-
publish()andsend()succeed silently — no error is raised. -
request()returnsnull. -
reactive().publish(),reactive().send()andreactive().request()complete with anullitem.
3.5. Lazy Uni Semantics
The Uni returned by reactive().publish(), reactive().send(), and reactive().request() is lazy: no signal is emitted until the Uni is subscribed.
Each subscription triggers a new, independent emission.
Uni<Void> uni = orderPlaced.reactive().publish(new OrderPlaced("123", BigDecimal.TEN));
// No signal has been emitted yet
// First subscription — emits the signal
uni.await().indefinitely();
// Second subscription of the same Uni — emits the signal again
uni.await().indefinitely();
3.6. Error Handling
For blocking emission methods (publish(), send(), and request()), receiver failures from publish() and send() are logged but not propagated to the caller.
For request(), the receiver’s exception is thrown directly on the calling thread; a checked exception is wrapped in a java.util.concurrent.CompletionException.
For reactive emission methods (reactive().publish(), reactive().send(), and reactive().request()), the failure is propagated through the returned Uni.
For send and request emissions (unicast), the receiver’s exception is propagated directly.
For publish emissions (multicast), where multiple receivers may fail, the failures are wrapped in a io.smallrye.mutiny.CompositeException.
4. Receiver Methods
A receiver method is a non-private, non-static method declared on a CDI bean.
It must have exactly one parameter annotated with @Receives.
The type of the annotated parameter determines the received signal type.
Additional parameters may be declared and are treated as injection points — the container will obtain and inject the corresponding bean instances when the receiver is invoked, just like CDI observer methods.
@ApplicationScoped
public class OrderHandlers {
void onOrderPlaced(@Receives OrderPlaced order, NotificationService notifications) {
notifications.send("Order placed: " + order.orderId());
}
}
4.1. Qualifiers
Qualifiers declared on the annotated parameter are used during type-safe resolution.
If a receiver declares no qualifier, it has exactly one qualifier — the default qualifier @Default.
void onOrder(@Receives OrderPlaced order) { (1)
// only receives signals emitted with the @Default qualifier
// note that @Inject Signal<Foo> has @Default qualifier
}
void onUrgentOrder(@Receives @Urgent OrderPlaced order) { (2)
// only receives signals emitted with the @Urgent qualifier
}
void onAnyOrder(@Receives @Any OrderPlaced order) { (3)
// receives all OrderPlaced signals
}
| 1 | Has qualifiers [@Default] and receives signals of type OrderPlaced emitted with the @Default qualifier. Note that if a Signal injection point declares no qualifier, it has exactly one qualifier — the default qualifier @Default. |
| 2 | Has qualifiers [@Urgent] and receives signals of type OrderPlaced emitted with the @Urgent qualifier. |
| 3 | Has qualifiers [@Any] and receives all signals of type OrderPlaced. |
The fact that a receiver which declares no qualifier has exactly one qualifier — @Default — is the only difference from the CDI specification, where similar observer methods have an empty set of qualifiers and therefore match any events of a specific type.
|
4.2. Return Values
A receiver method may return a value, which is used as the response for request / reactive().request() emissions.
The return value is ignored for publish and send emissions.
The return type is also used during Receiver Resolution — only receivers whose response type is assignable to the requested type are considered.
The method can return the value directly, or wrapped in a Uni or CompletionStage:
String onOrderPlaced(@Receives OrderPlaced order) {
return "confirmed-" + order.orderId();
}
Uni<String> onOrderPlaced(@Receives OrderPlaced order) {
return Uni.createFrom().item("confirmed-" + order.orderId());
}
CompletionStage<String> onOrderPlaced(@Receives OrderPlaced order) {
return CompletableFuture.completedFuture("confirmed-" + order.orderId());
}
4.3. Signal Context
The annotated parameter may also be of type SignalContext<T> instead of the signal type directly.
This gives the receiver access to the full signal context, including metadata, qualifiers, and emission type:
void onOrder(@Receives SignalContext<OrderPlaced> ctx) {
OrderPlaced order = ctx.signal();
Map<String, Object> metadata = ctx.metadata();
Set<Annotation> qualifiers = ctx.qualifiers();
SignalContext.EmissionType emissionType = ctx.emissionType();
Type responseType = ctx.responseType(); // null if not a request-reply emission
}
4.4. Execution Model
The execution model determines how a receiver method is invoked. It is resolved using the following rules, in order of priority:
-
If the method is annotated with
@io.smallrye.common.annotation.RunOnVirtualThread, the execution model isVIRTUAL_THREAD. -
If the method is annotated with
@io.smallrye.common.annotation.Blocking, the execution model isBLOCKING. -
If the method is annotated with
@io.smallrye.common.annotation.NonBlocking, the execution model isNON_BLOCKING. -
If none of the above is present on the method, the declaring class is checked for the same annotations.
-
If no annotation is found, the default is derived from the method signature:
-
A method returning
UniorCompletionStagedefaults toNON_BLOCKING— when Vert.x is available, it is executed on the event loop. -
Any other return type defaults to
BLOCKING— the method is offloaded to a worker thread.
-
5. Programmatic Receivers
Receivers can also be registered programmatically at runtime via the Receivers interface:
@Inject
Receivers receivers;
Use the fluent builder API to define and register a receiver:
Registration reg = receivers.newReceiver(OrderPlaced.class)
.notify(ctx -> System.out.println("Received: " + ctx.signal()));
The Registration handle can be used to unregister the receiver later:
reg.unregister();
5.1. Builder Options
The builder supports the following options:
Registration reg = receivers.newReceiver(OrderPlaced.class)
.setQualifiers(new Urgent.Literal()) (1)
.setExecutionModel(ExecutionModel.VIRTUAL_THREAD) (2)
.setResponseType(String.class) (3)
.notify(ctx -> { (4)
return Uni.createFrom().item("processed-" + ctx.signal().orderId());
});
| 1 | Set qualifiers for type-safe resolution. |
| 2 | Set the execution model (BLOCKING, NON_BLOCKING, or VIRTUAL_THREAD). |
| 3 | Set the response type for request-reply patterns. |
| 4 | The callback receives a SignalContext and returns a Uni. |
For simple fire-and-forget receivers, a Consumer-based callback can be used:
receivers.newReceiver(OrderPlaced.class)
.notify(ctx -> System.out.println(ctx.signal()));
For parameterized signal types, use TypeLiteral:
receivers.newReceiver(new TypeLiteral<List<OrderPlaced>>() {})
.notify(ctx -> System.out.println(ctx.signal()));
6. Receiver Resolution
When a signal is emitted, the container resolves the set of matching receivers using the signal type, qualifiers, and — for request emissions — the response type. The resolution rules are inspired by the CDI observer resolution rules:
-
A receiver matches if the signal type is assignable to the received signal type.
-
A receiver matches only if the receiver’s qualifiers are a subset of the signal’s qualifiers.
-
For request emissions, a receiver matches only if its response type is assignable to the requested response type.
Every signal implicitly carries the @Any qualifier.
A Signal injection point with no qualifiers carries @Default (per the CDI specification).
7. Pipeline Pattern
Receivers can inject Signal instances and emit new signals, enabling multi-stage processing pipelines.
Each stage receives a signal, processes it, and forwards the result to the next stage using request() or reactive().request().
@Singleton
public class ValidationStage {
@Inject
Signal<ValidatedOrder> validatedOrder;
ShipmentConfirmation onPlaceOrder(@Receives PlaceOrder order) {
// Validate, then forward to the next stage and return its result
return validatedOrder.request(
new ValidatedOrder(order.orderId(), order.item(), order.quantity()),
ShipmentConfirmation.class);
}
}
@Singleton
public class EnrichmentStage {
@Inject
Signal<EnrichedOrder> enrichedOrder;
Uni<ShipmentConfirmation> onValidatedOrder(@Receives ValidatedOrder order) {
int totalPrice = order.quantity() * 10;
return enrichedOrder.reactive().request(
new EnrichedOrder(order.orderId(), order.item(), order.quantity(), totalPrice),
ShipmentConfirmation.class);
}
}
@Singleton
public class ShipmentStage {
ShipmentConfirmation onEnrichedOrder(@Receives EnrichedOrder order) {
return new ShipmentConfirmation(order.orderId(), order.item(),
order.quantity(), order.totalPrice(), generateTrackingId());
}
}
The pipeline is triggered by a single request() call, and the final response propagates back through all stages:
@Inject
Signal<PlaceOrder> placeOrder;
ShipmentConfirmation confirmation = placeOrder.request(
new PlaceOrder("ORD-1", "Widget", 3), ShipmentConfirmation.class);
8. SPI
The io.quarkus.signals.spi package provides extension points for integrators.
Both SignalMetadataEnricher and ReceiverInterceptor implementations must be CDI beans annotated with @io.smallrye.common.annotation.Identifier to define a unique identifier.
8.1. Signal Metadata Enricher
A SignalMetadataEnricher enriches the metadata of a signal emission before any receiver is invoked.
It is called once per emission (i.e., per call to publish(), send(), or request()), not per receiver.
@Identifier("correlation-id")
@Singleton
public class CorrelationIdEnricher implements SignalMetadataEnricher {
@Override
public void enrich(EnrichmentContext context) {
context.putMetadata("correlationId", "corr-" + UUID.randomUUID());
}
}
The EnrichmentContext provides access to the SignalContext and a putMetadata() method.
An IllegalArgumentException is thrown if a metadata entry with the given key already exists.
8.2. Receiver Interceptor
A ReceiverInterceptor intercepts each receiver invocation.
For multicast emissions (publish), it is called once per matching receiver.
The interceptor must call proceed() to continue the interceptor chain and eventually invoke the receiver.
It may transform the result, handle errors, or skip the invocation entirely by returning a different Uni.
@Identifier("logging")
@Singleton
public class LoggingInterceptor implements ReceiverInterceptor {
@Override
public Uni<Object> intercept(InterceptionContext context) {
Log.infof("Invoking receiver for signal: %s", context.signalContext().signalType());
return context.proceed();
}
}
8.3. Component Ordering
The ordering of enrichers and interceptors relative to each other can be defined with the @RelativeOrder annotation.
Components are referenced by their @Identifier value.
@Identifier("correlation-id")
@RelativeOrder(before = "timestamp") (1)
@Singleton
public class CorrelationIdEnricher implements SignalMetadataEnricher {
// ...
}
@Identifier("timestamp")
@Singleton
public class TimestampEnricher implements SignalMetadataEnricher {
// ...
}
| 1 | The correlation-id enricher runs before the timestamp enricher. |
Cycles in the ordering are detected at build time and result in a deployment error.