Building Publish-Subscribe Messaging with Spring AMQP

Implementing a publish-subscribe messaging system with Spring AMQP and RabbitMQ gives Java applications a reliable way to distribute events to multiple consumers. A publisher sends one message to an exchange, while independent subscribers receive their own copy through separate queues.

This pattern is useful when an order event must trigger several activities at once. An Australian ecommerce platform might update inventory, send a customer notification, record an audit entry and start a payment workflow without forcing the checkout request to wait for every operation.

Spring AMQP provides the Java abstractions needed to declare exchanges, queues and bindings, publish messages and process deliveries. RabbitMQ supplies routing, persistence and acknowledgement features, while Spring Boot reduces the configuration required to connect these parts.

A sound understanding of Java fundamentals makes the implementation easier to extend and troubleshoot. Developers new to the ecosystem can review this Java learning guide before building the messaging layer.

Understanding the publish-subscribe model

In RabbitMQ, a fanout exchange broadcasts every published message to all queues bound to it. Each subscriber normally owns a separate queue, so a billing consumer, notification consumer and reporting consumer can process the same event independently.

A queue is different from a consumer. The queue stores messages until a consumer is ready, while the consumer is the application code that handles them. If two application instances share one queue, RabbitMQ distributes messages between them for load balancing. If they use separate queues, each receives a copy.

For an order-created event, an exchange such as orders.events might have queues named orders.billing, orders.notifications and orders.reporting. This arrangement keeps subscribers loosely coupled and allows one team to add a new consumer without changing the publisher.

Configuring exchanges, queues and bindings

Spring Boot can declare RabbitMQ infrastructure through Java configuration. A fanout exchange and durable queues survive broker restarts when durable messages and suitable policies are used.

@Configuration
public class RabbitConfig {

    @Bean
    FanoutExchange orderExchange() {
        return new FanoutExchange("orders.events");
    }

    @Bean
    Queue billingQueue() {
        return QueueBuilder.durable("orders.billing").build();
    }

    @Bean
    Queue notificationQueue() {
        return QueueBuilder.durable("orders.notifications").build();
    }

    @Bean
    Binding billingBinding(FanoutExchange exchange, Queue billingQueue) {
        return BindingBuilder.bind(billingQueue).to(exchange);
    }

    @Bean
    Binding notificationBinding(
            FanoutExchange exchange, Queue notificationQueue) {
        return BindingBuilder.bind(notificationQueue).to(exchange);
    }
}

A fanout exchange ignores routing keys, so the binding only expresses that a queue subscribes to the exchange. For selective delivery, use a topic exchange with patterns such as order.created or order.*. This is often preferable when a larger Melbourne or Sydney platform has many event types and wants subscribers to receive only relevant messages.

Publishing events from a Spring service

The publisher should send a meaningful event contract rather than exposing a database entity. A small record containing the order identifier, customer reference, total amount and creation time is easier to version and safer to share across services.

public record OrderCreatedEvent(
        String orderId,
        String customerId,
        BigDecimal total,
        Instant createdAt) {
}

The RabbitTemplate publishes the event through the exchange. JSON conversion is generally more interoperable than Java native serialisation, especially when a future subscriber is written in another language.

@Service
public class OrderEventPublisher {

    private final RabbitTemplate rabbitTemplate;

    public OrderEventPublisher(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void publish(OrderCreatedEvent event) {
        rabbitTemplate.convertAndSend("orders.events", "", event);
    }
}

Configure a Jackson2JsonMessageConverter so the message body contains JSON and includes suitable type metadata or an explicit event type header. For Australian businesses integrating with local payment providers, keeping payment details out of the event is also important: publish a payment reference rather than card data.

Consuming messages safely

A subscriber can listen to its own queue with @RabbitListener. The method should perform an idempotent operation, because a message may be delivered again after a consumer crashes before acknowledgement.

@Component
public class NotificationConsumer {

    @RabbitListener(queues = "orders.notifications")
    public void handle(OrderCreatedEvent event) {
        // Send an email, SMS or push notification.
        System.out.println("Notifying customer " + event.customerId());
    }
}

Use a unique event or order identifier to prevent duplicate invoices, messages or fulfilment actions. Configure manual acknowledgement only when the application needs precise control; otherwise, Spring’s listener container can acknowledge successful method completion and reject failures according to the chosen policy.

A dead-letter exchange is valuable for malformed payloads and repeated processing failures. In production across Sydney, Brisbane or Perth, monitor queue depth, consumer lag, rejected messages and processing time so regional traffic spikes do not remain hidden.

Reliability, scaling and operations

Publisher confirms help the application detect whether RabbitMQ accepted a message. Durable queues, persistent messages and replicated RabbitMQ infrastructure improve resilience, but they do not replace an application-level retry strategy. Transient database or payment API failures should be retried with a limit and back-off rather than causing an endless redelivery loop.

The consumer should acknowledge a message after its business transaction succeeds. When database changes and message acknowledgement must remain coordinated, consider an outbox pattern: save the event in the same transaction as the order, then publish it from a separate relay process.

Security also matters. Use TLS connections, separate virtual hosts and credentials with the minimum required permissions. Australian organisations handling personal information should align message retention, access controls and audit logs with the Privacy Act and their internal data policies.

Testing and production readiness

Run RabbitMQ with Docker during development and use Testcontainers for integration tests. Verify that one published event reaches every expected queue, that a failed consumer receives a retry, and that a permanently invalid message reaches the dead-letter queue.

Test the contract as well as the transport. A consumer should tolerate added optional fields and reject incompatible changes clearly. Time-based tests should use UTC timestamps, even when operations are coordinated around AEST or AEDT business hours.

For a local deployment, calculate network latency between application regions and the broker rather than assuming a Sydney broker is ideal for every workload. Dashboards, structured logs and correlation identifiers make it much easier to trace an order from an HTTP request through RabbitMQ and into each subscriber.