Decoupled by Design: Mastering Events with Quarkus CDI
Learn how to fire and observe synchronous and asynchronous events in Quarkus to build clean, extensible Java applications.
Modern applications live and die by how well they manage dependencies. Tight coupling makes systems rigid, brittle, and hard to evolve. Loosely coupled designs, on the other hand, let features grow independently. One of the simplest yet most powerful tools in CDI with Quarkus for achieving this is the event/observer pattern.
In this tutorial, we’ll build a small Quarkus application where user registration triggers two independent actions:
An audit log entry
A welcome email
The trick is: the registration code doesn’t know who’s listening. It just fires an event. Observers pick it up when they care to.
Prerequisites
You’ll need:
Java 17+
Maven 3.9+
Quarkus 3.25+
Create a project with the Quarkus CLI:
quarkus create app org.acme:cdi-events-demo \
--extension=rest-jackson
cd cdi-events-demo
We use Quarkus REST with JSON support for our simple REST API. Find the little code of this tutorial in my Github repository.
The Event Payload
Events in CDI are just objects. They don’t need to implement an interface or extend a base class. The payload defines what’s being communicated.
package org.acme.events;
public class UserRegistrationEvent {
private final String username;
public UserRegistrationEvent(String username) {
this.username = username;
}
public String username() {
return username;
}
}
Keep the payload lean. It should only carry the information observers need.
Firing the Event
Inside the user registration workflow, inject the CDI Event<T>
interface and fire the payload when a new user is created.
package org.acme.service;
import org.acme.events.UserRegistrationEvent;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;
@ApplicationScoped
public class UserService {
@Inject
Event<UserRegistrationEvent> userRegistered;
public void registerUser(String username) {
// pretend to store the user somewhere
Log.infof("UserService: Registered user " + username);
// fire the event
userRegistered.fire(new UserRegistrationEvent(username));
}
}
The important line is userRegistered.fire(...)
. That’s the hand-off point. The service doesn’t know or care who’s listening.
Observing the Event
Observers declare interest in an event type with the @Observes
annotation. Let’s make two.
Audit logger
package org.acme.service;
import org.acme.events.UserRegistrationEvent;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
@ApplicationScoped
public class AuditService {
public void onUserRegistered(@Observes UserRegistrationEvent event) {
Log.infof("AuditService: User registered: " + event.username());
}
}
Welcome email sender
package org.acme.service;
import org.acme.events.UserRegistrationEvent;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
@ApplicationScoped
public class WelcomeEmailService {
public void sendWelcome(@Observes UserRegistrationEvent event) {
Log.infof("WelcomeEmailService: Sending welcome email to " + event.username());
}
}
Each service reacts independently. They don’t depend on each other or on the UserService
.
Adding a REST Endpoint
Let’s expose a simple endpoint for registering users. Rename the scaffolded GreetingResource.java:
package org.acme;
import org.acme.service.UserService;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
@Path("/users")
public class UserResource {
@Inject
UserService userService;
@POST
public Response register(String username) {
userService.registerUser(username);
return Response.ok("User registered: " + username).build();
}
}
Start your application:
quarkus dev
Now you can call it with:
curl -X POST http://localhost:8080/users -d "alice"
You should see log lines from all three services.
Going Asynchronous
By default, observers are synchronous. That means the REST call won’t finish until all observers are done. For long-running tasks, that’s a problem. CDI lets you switch to asynchronous delivery.
Modify the email sender:
import jakarta.enterprise.event.ObservesAsync;
public void sendWelcome(@ObservesAsync UserRegistrationEvent event) {
Log.infof("WelcomeEmailService: Asynchronously sending email to " + event.username());
}
Now Quarkus dispatches the observer in the background. The REST response returns immediately, while the email sending runs on a separate thread.
Production Notes: Error Handling and Retries
In real-world systems, observers aren’t just Log
statements. They may send emails, update external systems, or push to Kafka. That means they can fail.
Synchronous observers
If a synchronous observer (
@Observes
) throws an exception, it propagates back to the event producer.This can abort the original business method. For example, a user registration might roll back if the audit logger fails.
Tip: Keep synchronous observers lightweight and reliable. Use them only when failure should invalidate the original action (e.g. auditing to a database).
Asynchronous observers
For
@ObservesAsync
, exceptions don’t propagate back to the producer. They’re logged, and by default no retries happen.This is safer for optional work like sending emails, but failures can go unnoticed.
Mitigation strategies:
Use Quarkus logging to capture errors and send them to a centralized system (ELK, Loki, etc.).
Wrap async observers in a retry mechanism. SmallRye Fault Tolerance (
io.quarkus:quarkus-smallrye-fault-tolerance
) integrates with CDI methods:
import org.eclipse.microprofile.faulttolerance.Retry;
@Retry(maxRetries = 3, delay = 2000)
public void sendWelcome(@ObservesAsync UserRegistrationEvent event) {
Log.infof("WelcomeEmailService: Trying to send email to " + event.username());
// imagine email sending may fail
}
This gives you automatic retries with configurable delays.
Events and Transactions
Another subtle but important aspect: transaction boundaries.
What happens by default
CDI events fire inside the transaction of the producer.
If the transaction later rolls back, your observers may have already acted. That can lead to inconsistent side effects (e.g. sending a welcome email even though the user wasn’t persisted).
Transactional observers
To handle this, CDI defines transactional observers. They let you specify when the observer should be triggered relative to the transaction lifecycle:
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.event.TransactionPhase;
public void onUserRegistered(
@Observes(during = TransactionPhase.AFTER_SUCCESS) UserRegistrationEvent event) {
Log.infof("AuditService: Only log if the transaction commits successfully");
}
Phases you can choose:
IN_PROGRESS
(default): fires immediately within the transaction.BEFORE_COMPLETION
: fires just before commit.AFTER_COMPLETION
: fires after commit, regardless of success/failure.AFTER_SUCCESS
: fires only if commit succeeded.AFTER_FAILURE
: fires only if the transaction rolled back.
Best practice
Use
AFTER_SUCCESS
for actions that should only happen when the transaction truly commits (emails, external systems).Keep
IN_PROGRESS
observers for intra-transaction coordination (e.g. computing derived values before flush).
What if you need more than CDI events?
CDI events shine inside a single JVM. They are simple, fast, and type safe. At some point, you will need durability, cross-service communication, or integration with non-Java systems. That is when you reach for messaging.
When to stay with CDI events
All observers live in the same Quarkus service.
Side effects are local and fast.
You do not need persistence, back-pressure, or replay.
Failure in observers should either block the request (sync) or be retried locally (async with retries).
When to use a broker (Kafka, RabbitMQ)
You must cross service boundaries.
You need durability, ordering guarantees, or consumer scaling.
Producers and consumers evolve independently. Schemas matter.
You need replay, dead-letter queues, or auditability.
Traffic spikes require buffering and back-pressure.
Choosing the right abstraction in Quarkus
For in-JVM decoupling: CDI
Event<T>
with@Observes
or@ObservesAsync
.For inter-service streams: MicroProfile Reactive Messaging with SmallRye in Quarkus. Use connectors like
quarkus-smallrye-reactive-messaging-kafka
or-rabbitmq
.For scheduled or outbox patterns: Quarkus Scheduler plus a persistent outbox table to publish to Kafka reliably.
A common migration path
Start simple with CDI events for local reactions.
Identify a side effect that must survive restarts or cross services.
Introduce an outbox in the same transaction as the business write.
Add a background publisher that reads the outbox and emits to Kafka.
Replace the CDI observer with a Reactive Messaging consumer in the downstream service.
Minimal shape of the outbox publisher:
@ApplicationScoped
public class OutboxPublisher {
@Transactional
public void onUserRegistered(
@Observes(during = TransactionPhase.AFTER_SUCCESS) UserRegistrationEvent event) {
// Persist an outbox record in the same DB as the user write
OutboxRecord.persist(OutboxRecord.userRegistered(event.username()));
}
}
Then a periodic or CDC-based process publishes outbox records to Kafka, and a separate service consumes them:
// Producer side
@Outgoing("user-registered")
public Multi<OutboxRecord> streamOutbox() { /* read and emit */ }
// Consumer side in another service
@Incoming("user-registered")
public void handle(Record<String, String> record) { /* welcome email, audit, etc. */ }
Operational trade-offs
CDI events: zero infrastructure, low latency, tight transaction control, no durability.
Kafka/RabbitMQ: extra infra and costs, stronger delivery guarantees, flexible scaling, polyglot integration.
Rule of thumb
Use CDI events for in-process choreography. Use a broker for cross-process choreography and durability.
Build locally. Scale with a broker when it matters.
Final Takeaway
Events allow components to react to what’s happening without direct coupling. In Quarkus and CDI Lite:
Start with CDI events for simple, in-process reactions.
Use async observers for background tasks.
Add transaction phases to align with persistence.
Introduce fault tolerance for retries and resilience.
Graduate to Kafka or RabbitMQ when durability, distribution, or scalability require it.
That progression: From in-memory observers to distributed messaging is how you scale from a small Quarkus app to an enterprise-grade system.
It isn’t better to persist the outbox record in the same transaction as the business process? If it is saved, as in your example, in the after success phase there is still the risk of transaction failure, so the business process is completed successfully but the outbox record is not saved