Are you still thinking in request and response when your work really looks like a stream of facts? When a customer signs up, a payment clears, stock runs out, or an email bounces, that is an event. On the JVM we have a trusty tool for this kind of flow. JMS. It is not shiny, but it gets the job done and keeps the wheels turning.
Events change how you draw the boxes on the whiteboard. Instead of a giant service that must know everything, you publish facts and let other parts react. That decouples time and space. The producer does not wait. The consumer does not care who sent it. On the JVM, Java Message Service sits in the middle with providers like ActiveMQ, HornetQ, WebSphere MQ, and Tibco EMS. Java 7 just landed, but JMS 1.1 still feels like that reliable friend who shows up early. It has queues for work dispatch and topics for fan out. It has message headers that help with selection and tracing. And it has a clear story for delivery and retries that you can explain to ops without a novel.
The trick is to think about events first, then pick the right shape in JMS.
Queues vs topics and the shape of work
Use a queue when you want one worker to grab one piece of work. That is great for payment charging or PDF rendering. Use a topic when you want many listeners to hear the same fact. That is great for order created or user signed up. With topics, make the subscription durable so you do not miss messages while the app restarts. Keep the payload small and put routing hints in headers. Many teams send a compact JSON or XML body and add a type or eventVersion header. Save commands for queues and facts for topics. It sounds simple, and it keeps systems from becoming a ball of yarn.
// Simple durable topic consumer with selector
ConnectionFactory cf = new org.apache.activemq.ActiveMQConnectionFactory("tcp://localhost:61616");
Connection conn = cf.createConnection();
conn.setClientID("billing_service");
Session session = conn.createSession(false, Session.CLIENT_ACKNOWLEDGE);
Topic topic = session.createTopic("orders.events");
// Only receive order created events
MessageConsumer consumer = session.createDurableSubscriber(topic, "billing_sub", "type = 'CREATED'", false);
conn.start();
while (true) {
Message msg = consumer.receive();
try {
TextMessage tm = (TextMessage) msg;
String orderId = tm.getStringProperty("orderId");
String body = tm.getText();
// charge the order...
msg.acknowledge(); // mark as processed
} catch (Exception ex) {
session.recover(); // ask broker to redeliver later
}
}Need a reply? Use JMSCorrelationID with a temp queue for the response and keep the event flow clean.
Delivery, retries, and the mess in between
Pick persistent delivery for facts that must not vanish. Non persistent is fine for metrics or a cache warm up. For retries, choose between CLIENT_ACKNOWLEDGE and a transacted session. For money things, a transaction can be worth it, but XA will make you earn your coffee. My rule on projects has been simple. Make consumers idempotent so redelivery is always safe. If a message keeps failing, send it to a dead letter queue with headers that explain what went wrong. Add a back off so you do not flood the service on every retry. Most brokers let you set redelivery policy in a few lines.
<!-- Spring plus ActiveMQ example for redelivery and a listener container -->
<bean id="connectionFactory" class="org.apache.activemq.ActiveMQConnectionFactory">
<property name="brokerURL" value="tcp://localhost:61616"/>
<property name="redeliveryPolicy">
<bean class="org.apache.activemq.RedeliveryPolicy">
<property name="maximumRedeliveries" value="5"/>
<property name="initialRedeliveryDelay" value="1000"/>
<property name="backOffMultiplier" value="2"/>
<property name="useExponentialBackOff" value="true"/>
</bean>
</property>
</bean>
<bean id="listenerContainer" class="org.springframework.jms.listener.DefaultMessageListenerContainer">
<property name="connectionFactory" ref="connectionFactory"/>
<property name="destinationName" value="orders.queue"/>
<property name="sessionTransacted" value="true"/>
<property name="concurrency" value="3"/>
<property name="messageListener">
<bean class="com.example.OrderListener"/>
</property>
</bean>Back pressure matters. Slow consumers should not take the house down. Tune prefetch and allow the broker to hold the line.
Selectors, payloads, and versioning
Message selectors are a quiet superpower. Set type, tenant, or priority as properties and let consumers filter without touching the body. Keep the body boring and friendly. JSON or XML both work. Avoid schema drama by carrying an eventVersion header and write consumers that accept old and new shapes for a while. Never break a field name. Add new fields and default smartly. Keep messages small. Store the heavy details in your database and send a key. For tracing, include a correlation id and a causation id so you can follow a request across services when production gets moody.
- One event per fact. No batches in a single message unless you want hurt.
- Idempotent consumers. Use natural keys and check before write.
- DLQ with alerts. If it lands there, a human should know.
- Time boxes. Consumers should fail fast and retry later.
- Schema patience. Keep old readers working while new writers roll out.
The JVM world is buzzing about Akka and actors. RabbitMQ and ZeroMQ are popular in shops that do not want JMS. Kafka from LinkedIn is getting attention for log like feeds. That is fine. JMS still shines for many workloads and the knowledge transfers across tools.
Design events first and the code will follow.