Consuming JMS messages in Grails with ActiveMQ

Many Grails applications need to accept work from another system without holding a web request open. An order events queue, for example, lets a booking service acknowledge a customer in Sydney while a warehouse team in Melbourne updates fulfilment records. The Grails service publishes a JMS message, ActiveMQ stores it, and a background consumer performs the business action. Learn more about Poker Addon Explained Strategy.

Australian customers commonly place orders from a phone during a commute or between other daily errands, so background processing must be dependable rather than tied to a browser session. The web tier should return quickly, while the listener validates the event, updates domain data and records enough evidence to diagnose a failure. This separation also prevents a temporary broker or database outage from forcing customers to repeat an order. Learn more about Xn Dveloppeurweb Bhb.com.

The pattern suits the Australian market, from small retailers using courier services to national logistics and healthcare providers coordinating several sites. If a payload contains a customer’s name, address or payment-related identifier, assess whether the Privacy Act 1988 and Australian Privacy Principles apply. Minimise those fields, restrict access and define retention before enabling the queue.

JMS gives the two applications a stable contract, but only if that contract is explicit. Keep broker details out of controllers and domain rules out of transport code. That boundary makes the Grails application easier to test, replace and operate.

Define the message contract

Start with the event rather than the broker API. For an order-created event, define an identifier, event version, occurred-at timestamp, order identifier and the minimum business data required by the consumer. A stable logical name such as grails.orders.created.v1 lets producers publish without knowing which Grails service will consume the work. Keep the event identifier unique because it later supports idempotent processing.

Think of the queue contract as a narrow extension point rather than business logic disguised as messaging. The [poker strategy guide] illustrates why a modular add-on works when its interface and host rules stay separate; the same separation keeps queue details out of controllers and domain rules out of transport code.

Use a queue when one consumer should handle each message and a topic when independent services need their own copy. Most order workflows begin with a queue because two competing consumers must not both fulfil the same order. A topic suits broadcast events, but durable subscription, filtering and message redelivery require deliberate broker configuration.

Prefer a versioned JSON payload over a Java object serialised by the producer. JSON is inspectable in the ActiveMQ console and remains readable when a consumer moves from Grails to another service. Keep optional fields additive, reject incompatible versions and publish a schema with the contract.

{
  "eventId": "01J2R7KQ9F3M8N4P6T0Y",
  "eventVersion": 1,
  "occurredAt": "2025-02-18T09:15:00+10:00",
  "orderId": "ORD-4821",
  "action": "CREATE"
}

Connect Grails to ActiveMQ

The example targets the javax.jms generation: ActiveMQ Classic 5.x and a Grails version whose Spring JMS dependency also uses javax.jms. Add the broker client and Spring’s JMS support to build.gradle, letting the Grails dependency management select its Spring version.

dependencies {
    implementation "org.apache.activemq:activemq-client:5.18.6"
    implementation "org.springframework:spring-jms"
}

Pin an approved patch release and include both dependencies in dependency scanning. If the application uses Spring 6 or jakarta.jms, switch to a Jakarta-compatible ActiveMQ Artemis client and broker, then change the listener imports. Mixing javax.jms and jakarta.jms classes will fail during compilation or startup.

Create one connection factory and one JmsTemplate as singleton beans in grails-app/conf/spring/resources.groovy. Read broker settings from environment variables so the same build can run against local, test and production brokers.

import org.apache.activemq.ActiveMQConnectionFactory
import org.apache.activemq.command.ActiveMQQueue
import org.springframework.jms.core.JmsTemplate

def brokerUrl = System.getenv('JMS_BROKER_URL')
def username = System.getenv('JMS_USERNAME')
def password = System.getenv('JMS_PASSWORD')
def queueName = System.getenv('JMS_QUEUE') ?: 'grails.orders.created.v1'

beans = {
    activeMQConnectionFactory(ActiveMQConnectionFactory) { bean ->
        bean.brokerURL = brokerUrl
        bean.userName = username
        bean.password = password
    }

    jmsTemplate(JmsTemplate) { bean, ref ->
        bean.connectionFactory = ref.activeMQConnectionFactory
    }

    orderCreatedQueue(ActiveMQQueue) { bean ->
        bean.physicalName = queueName
    }
}

Add a startup configuration check so a missing broker URL fails with a useful message. Outside a trusted development network, use an ssl:// broker URL and configure the broker truststore plus client certificates where required. The ActiveMQ web console on port 8161 should remain on localhost or a protected administrative network rather than being exposed with default credentials.

Build a durable queue listener

The listener can implement MessageListener directly, or sit behind Spring’s message adapter. A direct listener keeps the transport boundary visible. Place it under src/main/groovy and register it explicitly as a Spring bean.

import groovy.json.JsonSlurper
import javax.jms.Message
import javax.jms.MessageListener
import javax.jms.TextMessage

class OrderCreatedListener implements MessageListener {

    OrderEventsService orderEventsService

    void onMessage(Message message) {
        if (!(message instanceof TextMessage)) {
            throw new IllegalArgumentException('Expected a text message')
        }

        String body = ((TextMessage) message).getText()
        Map event = new JsonSlurper().parseText(body)

        if (!event.eventId || !event.orderId) {
            throw new IllegalArgumentException('Missing eventId or orderId')
        }

        orderEventsService.process(event)
    }
}

OrderEventsService is an ordinary Grails service with access to the database and transaction support. The listener validates the transport payload before calling it, and it lets exceptions escape rather than swallowing them.

Inside the same beans closure, add the listener and Spring’s DefaultMessageListenerContainer. The container starts with the Grails application, reconnects after a broker interruption and coordinates acknowledgement.

import javax.jms.Session
import org.springframework.jms.listener.DefaultMessageListenerContainer

    orderCreatedListener(OrderCreatedListener)

    orderCreatedContainer(DefaultMessageListenerContainer) { bean, ref ->
        bean.connectionFactory = ref.activeMQConnectionFactory
        bean.destination = ref.orderCreatedQueue
        bean.messageListener = ref.orderCreatedListener
        bean.sessionTransacted = false
        bean.acknowledgeMode = Session.AUTO_ACKNOWLEDGE
        bean.concurrentConsumers = 1
        bean.recoveryInterval = 5000
    }

With AUTO_ACKNOWLEDGE, the container completes acknowledgement after the listener returns successfully. The service should commit its database transaction before returning. Increase concurrentConsumers only after confirming that the downstream work is thread-safe and that strict ordering is unnecessary.

Handle failures without losing work

Delivery guarantees are usually at least once, not exactly once. A consumer can complete a database change and then lose its connection before the broker receives the acknowledgement, causing the same event to arrive again. Store processed eventId values in a table with a unique constraint. Within the database transaction, claim the identifier and apply the business change together; a duplicate event then becomes a safe no-op.

Configure bounded retries for temporary failures. Inside the Grails bean configuration, add bean.errorHandler = new DefaultErrorHandler(new FixedBackOff(1000L, 3L)), using the corresponding Spring JMS and backoff imports. After the attempts are exhausted, reject the message and configure ActiveMQ’s dead-letter policy to route it to grails.orders.created.v1.DLQ. A malformed payload or unsupported event version should reach quarantine without an unbounded retry loop.

Write a structured log for each failure with the broker message ID, correlation ID, eventId, attempt number and exception class. Redact customer details and include enough context for support staff in Melbourne or Brisbane to reproduce the issue without exposing personal information. Never log the broker password, full credentials or an entire sensitive payload. A replay tool should require an approved dead-letter identifier and preserve an audit trail of who released it.

Test the complete message path

Run ActiveMQ locally or in an isolated integration environment. A container is convenient for development, although continuous delivery should use a pinned image digest rather than a moving tag.

docker run --rm --name grails-activemq \
  -e ACTIVEMQ_ADMIN_PASSWORD='local-only-change-me' \
  -p 127.0.0.1:61616:61616 \
  -p 127.0.0.1:8161:8161 \
  apache/activemq:5.18.6

Use the broker’s documented image credentials and publish only to loopback. The test should cover the real TCP connector, serialisation and acknowledgement path; mocking MessageListener alone cannot reveal a broker URL, selector or transaction-policy mistake.

Publish a valid event through a Grails service so the test uses the same JmsTemplate as production.

import groovy.json.JsonOutput

class OrderEventsPublisher {

    def jmsTemplate

    void publish(Map event) {
        String destination =
            System.getenv('JMS_QUEUE') ?: 'grails.orders.created.v1'
        jmsTemplate.convertAndSend(destination, JsonOutput.toJson(event))
    }
}

Verify the consumer’s database row, log entry and queue depth. Include failure scenarios: send an invalid payload, stop the broker during handling, publish the same eventId twice and restart the application. The expected results are a dead-lettered invalid message, redelivery after recovery, one business effect for the duplicate and no lost messages. An integration test with a disposable broker can automate these checks, while unit tests should cover validation and idempotency without needing ActiveMQ.

Deploy securely for Australian operations

Choose where the broker runs as carefully as the Grails nodes. A cloud region in Sydney or Melbourne can support latency and data-residency goals, but location alone does not establish privacy compliance. Use encrypted connections, private networking where possible, secret rotation and audited administrative access. For an organisation assessed against the Australian Signals Directorate’s Cyber Security Framework, align broker hardening and incident response with the applicable Essential Eight practices.

Monitor broker availability, connection count, queue depth, oldest-message age, redelivery rate and dead-letter growth. Business dashboards can translate those signals for teams working across Australian Eastern Standard Time, Australian Central Time and Australian Western Standard Time. Store event timestamps in UTC, include an offset and use IANA identifiers such as Australia/Sydney; daylight-saving changes make ambiguous local timestamps particularly risky.

Runbooks should remain usable across language and skill differences. Teams that document in both English and French can treat the [French development resource] as a companion while the English runbook pairs each step with broker commands, expected logs and escalation contacts. A bilingual team can then rehearse a broker outage without relying on one engineer’s memory.

Build the smallest working slice now: start one broker, publish one valid event, confirm the database result and rehearse one duplicate. Add TLS, monitoring and replay controls before real customer data enters the queue, then take that tested path into your next Australian deployment.