DEV Community

Cover image for Building Cross-Framework Messaging with Quarkus, Micronaut, and RabbitMQ
anand jaisy
anand jaisy

Posted on

Building Cross-Framework Messaging with Quarkus, Micronaut, and RabbitMQ

The JVM ecosystem offers a wide range of powerful frameworks, each with its own strengths and capabilities. In a modern distributed architecture, however, applications are not always built using the same framework. Services developed with frameworks such as Quarkus, Micronaut, and Spring Boot may need to communicate seamlessly as part of the same system.

This guide demonstrates how RabbitMQ can enable cross-framework asynchronous communication between JVM applications. We will build two applications using different frameworks: a Quarkus application that publishes LeaveRequest messages and a Micronaut application that consumes and processes them.

The first application, built with Quarkus, publishes a LeaveRequest object as a message to RabbitMQ. The second application, built with Micronaut, receives the LeaveRequest message and processes it according to the application's business logic.

By the end of this guide, you will have a practical understanding of how two applications built with different Java frameworks can communicate asynchronously using RabbitMQ.

Lets begin the journey

To ensure that both applications use a consistent message contract, create a separate Gradle project named common. This project will contain the shared LeaveRequest model and can be referenced as a dependency by both the Quarkus and Micronaut applications.

@Introspected
@Serdeable
public record LeaveRequest(
        String personName,
        String personRole,
        String facilityName,
        String wardName,
        String shiftName,
        String leaveReason,
        String recipientName,
        String recipientEmail,
        String recipient,
        String subject) {}
Enter fullscreen mode Exit fullscreen mode

The dependency on the common project will be

dependencies {
    annotationProcessor("io.micronaut:micronaut-inject-java:5.1.12")
    implementation("io.micronaut.serde:micronaut-serde-jackson:3.1.1")
}
Enter fullscreen mode Exit fullscreen mode

The @Introspected and @Serdeable annotations enable Micronaut to generate the metadata required for efficient introspection and serialization.

Connecting Quarkus to RabbitMQ

To connect the Quarkus application to RabbitMQ, we will use the SmallRye Reactive Messaging RabbitMQ Connector. Under the hood, SmallRye uses the Vert.x RabbitMQ Client to communicate with RabbitMQ. The connector enables Quarkus applications to publish and consume messages using the AMQP protocol.

Add the below dependency in quarkus app

dependencies {
    implementation("io.quarkus:quarkus-messaging-rabbitmq")
}
Enter fullscreen mode Exit fullscreen mode

Configuring Rabbitmq Broker connection

rabbitmq-host=localhost
rabbitmq-port=5672
rabbitmq-username=xxxx
rabbitmq-password=xxxx
Enter fullscreen mode Exit fullscreen mode
mp.messaging.outgoing.<CHANNEL_NAME>.connector=smallrye-rabbitmq
Enter fullscreen mode Exit fullscreen mode

Connector: In MicroProfile Reactive Messaging, a connector is an SPI that connects application messaging channels to an external messaging system, such as RabbitMQ.

For RabbitMQ, the smallrye-rabbitmq connector is responsible for bridging the application's reactive messaging channels with the RabbitMQ broker.

Channels: Channels act as communication pipelines between your application and the messaging infrastructure. They allow applications to send and receive messages reactively using MicroProfile Reactive Messaging annotations and configuration.

SmallRye Reactive Messaging provides two primary types of channels.

Types of Channels in SmallRye Reactive Messaging (RabbitMQ)

  • Incoming Channel (@Incoming)
    An incoming channel represents a consumer that receives messages from RabbitMQ. Messages are delivered reactively from the messaging broker into the application.

  • Outgoing Channel (@Outgoing)
    An outgoing channel represents a producer that publishes messages to RabbitMQ. Messages flow reactively from the application to the configured RabbitMQ exchange.

How Channels Work

  • Each @Incoming and @Outgoing is mapped to a RabbitMQ exchange or queue.
  • The configuration for these channels is defined in application.properties.
  • mp.messaging.incoming.<channel-name> should be used for consuming messages and for outgoing messages mp.messaging.outgoing.<channel-name>

Example

mp.messaging.outgoing.leave-applied.connector=smallrye-rabbitmq
mp.messaging.outgoing.leave-applied.exchange.name=amp.beanOnbean
mp.messaging.outgoing.leave-applied.exchange.declare=false
Enter fullscreen mode Exit fullscreen mode

SetUp exchange, queue and routing key

Before messages can be routed effectively, RabbitMQ requires the necessary exchanges, queues, and bindings to be configured.

In this example, the Quarkus application performs the RabbitMQ infrastructure setup during application startup. The following enum defines the queues and their associated routing keys:

public enum MessageBrokerTopic {

    SEND_EMAIL_LEAVE_APPLIED(
            ApplicationConstants.RabitMQTopic.SEND_EMAIL_LEAVE_APPLIED,
            String.format("%s%s", ApplicationConstants.RabitMQTopic.SEND_EMAIL_LEAVE_APPLIED, ApplicationConstants.ApplicationDefaultValue.DOT_ROUTING_KEY)

    ),
    SEND_EMAIL_AI_ASSIGN_WORKER(
            ApplicationConstants.RabitMQTopic.SEND_EMAIL_AI_ASSIGN_WORKER,
            String.format("%s%s", ApplicationConstants.RabitMQTopic.SEND_EMAIL_AI_ASSIGN_WORKER, ApplicationConstants.ApplicationDefaultValue.DOT_ROUTING_KEY)
    );
    private final String queue;
    private final String routingKey;

    MessageBrokerTopic(String queue, String routingKey) {
        this.queue = queue;
        this.routingKey = routingKey;
    }

    public String getQueue() {
        return queue;
    }

    public String getRoutingKey() {
        return routingKey;
    }
}
Enter fullscreen mode Exit fullscreen mode

The following MessageBrokerSetup class connects to RabbitMQ when the Quarkus application starts, declares the required queues, and binds them to the configured exchange using their respective routing keys.

@ApplicationScoped
public class MessageBrokerSetup {
    private static final Logger logger = LoggerFactory.getLogger(MessageBrokerSetup.class);
    private static final String EXCHANGE = ApplicationConstants.ApplicationDefaultValue.AMP_DOT
            + ApplicationConstants.ApplicationDefaultValue.BEANONBEAN_STAFF;

    private final Vertx vertx;
    private final String host;
    private final int port;
    private final String username;
    private final String password;
    private RabbitMQClient rabbitMQClient;

    public MessageBrokerSetup(Vertx vertx,
                               @ConfigProperty(name = "rabbitmq-host") String host,
                               @ConfigProperty(name = "rabbitmq-port") int port,
                               @ConfigProperty(name = "rabbitmq-username") String username,
                               @ConfigProperty(name = "rabbitmq-password") String password) {
        this.vertx = vertx;
        this.host = host;
        this.port = port;
        this.username = username;
        this.password = password;
    }

    void onStart(@Observes StartupEvent event) {
        RabbitMQOptions options = new RabbitMQOptions()
                .setHost(host)
                .setPort(port)
                .setUser(username)
                .setPassword(password);
        rabbitMQClient = RabbitMQClient.create(vertx, options);
        rabbitMQClient.startAndAwait();

        setupQueues();
        logger.info("RabbitMQ queues and bindings setup complete");
    }

    private void setupQueues() {
        for (MessageBrokerTopic topic : MessageBrokerTopic.values())
            this.createBindQueue(topic.getQueue(), topic.getRoutingKey());
    }

    private void createBindQueue(String queueName, String routingKey) {
        rabbitMQClient.queueDeclareAndAwait(queueName, true, false, false);
        rabbitMQClient.queueBindAndAwait(queueName, EXCHANGE, routingKey);
        logger.info("Queue {} bound to exchange {} with routing key {}", queueName, EXCHANGE, routingKey);
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach centralizes the RabbitMQ infrastructure configuration within the application. When the Quarkus application starts, it establishes a connection to RabbitMQ, declares the required queues, and creates the bindings between each queue and the exchange.

Once the infrastructure is configured, the Quarkus application's outgoing channels can publish messages to the exchange, and RabbitMQ routes those messages to the appropriate queues based on the configured routing keys. The Micronaut application can then consume the messages asynchronously and process them independently of the Quarkus application.

Producing messages to Queue

Now that the RabbitMQ exchange, queues, and bindings have been configured, the next step is to publish messages from the Quarkus application.

The RabbitMQProducer class is responsible for sending LeaveRequest messages to RabbitMQ using SmallRye Reactive Messaging.

@ApplicationScoped
public class RabbitMQProducer {

    private static final String ROUTING_KEY = String.format("%s%s",
            ApplicationConstants.RabitMQTopic.SEND_EMAIL_LEAVE_APPLIED,
            ApplicationConstants.ApplicationDefaultValue.DOT_ROUTING_KEY);

    @Channel("leave-applied")
    Emitter<LeaveRequest> emitter;

    public void sendMessage(LeaveRequest request) {
        OutgoingRabbitMQMetadata metadata = OutgoingRabbitMQMetadata.builder()
                .withRoutingKey(ROUTING_KEY)
                .build();

        emitter.send(Message.of(request).addMetadata(metadata));
    }
}
Enter fullscreen mode Exit fullscreen mode

Understanding the Producer
The @Channel annotation injects an Emitter connected to the leave-applied outgoing channel.

The channel name must match the channel configured in application.properties

The Emitter provides a programmatic way to send messages to the configured RabbitMQ channel.

Finally, the LeaveRequest is wrapped in a Message object and the RabbitMQ metadata is attached before sending it through the emitter.

emitter.send(Message.of(request).addMetadata(metadata));
Enter fullscreen mode Exit fullscreen mode

Calling from the service

@ApplicationScoped
public final class StaffShiftService implements IStaffShiftService { 
   @Override
    @Transactional
    public IResult applyLeave(UUID facilityId, StaffShiftLeaveRequest request) {
        rabbitMQProducer.sendMessage(new LeaveRequest(    "John Smith",
    "Registered Nurse",
    "Melbourne Hospital",
    "Medical Ward",
    "Morning",
    "Sick Leave",
    "Jane Smith",
    "jane.smith@example.com",
    "Jane Smith",
    "Leave Request");
        var result = this.staffShiftRepository.applyLeave(facilityId, request);
        return result.map(item -> IResult.success(new StaffShiftResponse(item.id, facilityId, item.scheduleId, item.userProfile.id, item.userAcknowledged, item.userDeclined, item.userDeclinedReason)))
                .orElseGet(() -> IResult.failure(new Exception("Staff shift not found for the given facility and user profile.")));
    }
}
Enter fullscreen mode Exit fullscreen mode

The Second Part of the Story: Consuming Messages in Micronaut

With the Quarkus application publishing LeaveRequest messages to RabbitMQ, the next step is to consume those messages in our Micronaut application.

This demonstrates the key benefit of using RabbitMQ as the communication layer: the producer and consumer do not need to use the same Java framework. Quarkus can publish the message, while Micronaut independently consumes and processes it.

Adding RabbitMQ Dependencies
The Micronaut application requires the RabbitMQ integration and serialization support:

dependencies {
    annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
    implementation("io.micronaut.rabbitmq:micronaut-rabbitmq")
    runtimeOnly("ch.qos.logback:logback-classic")
    runtimeOnly("org.eclipse.angus:angus-mail")

    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
Enter fullscreen mode Exit fullscreen mode

This provides Micronaut's RabbitMQ integration, allowing the application to connect to RabbitMQ and consume messages from queues.

The micronaut-serde-processor annotation processor is also important because the LeaveRequest object needs to be deserialized from the message received from RabbitMQ.

Creating the RabbitMQ Event Listener
The Micronaut application can consume the message using a RabbitMQ listener:

@RabbitListener
public class EmailEventListener {
    private final IEmailService emailService;

    public EmailEventListener(IEmailService emailService) {
        this.emailService = emailService;
    }

    @Queue(ApplicationConstants.RabitMQTopic.SEND_EMAIL_LEAVE_APPLIED)
    public void receiveEmailEvent(LeaveRequest request) {
        IO.println("Received email event: " + request);
        //emailService.send(request);
    }
}
Enter fullscreen mode Exit fullscreen mode

The @Queue annotation identifies the RabbitMQ queue from which the application should consume messages:

RabbitMQ connection config for micronaut app

rabbitmq.host=localhost
rabbitmq.port=5672
rabbitmq.username=admin
rabbitmq.password=admin
Enter fullscreen mode Exit fullscreen mode

Conclusion

By leveraging a shared contract module alongside an AMQP broker like RabbitMQ, we have effectively decoupled our JVM services across framework, runtime, and deployment boundaries.

Key takeaways from this architecture:

  • Framework Independence: The Quarkus application produces events using MicroProfile Reactive Messaging (smallrye-rabbitmq), while the Micronaut application consumes them asynchronously via @RabbitListener. Neither service relies on or has knowledge of the other's underlying framework stack.

  • Contract Integrity: Isolating the LeaveRequest payload in a dedicated common Gradle project guarantees schema consistency and type safety across services, supported by lightweight reflection-free serialization (micronaut-serde).

  • Operational Resilience: Shifting non-blocking, event-driven processes (such as email dispatch) off the critical path ensures main application flows—like submitting a leave application—remain highly performant and responsive.

This asynchronous integration pattern provides a scalable blueprint for building heterogeneous, modern JVM microservices that are modular, decoupled, and easy to maintain over time.

Top comments (0)