1. Minimal messaging path
Connecting to RabbitMQ and the Queue bean does not prove anything by itself. Until a message has gone from the HTTP action to the recipient's log, wiring remains just a pretty hypothesis. That is why, in this lecture, we will deliberately build the smallest but honest message path: one queue, one string, and one consumer that writes to the log.
The scenario will look roughly like this (and this really is the whole "architecture" for today):
sequenceDiagram
participant C as "HTTP client (curl/Postman/.http)"
participant A as "Catalog Service (Spring Boot)"
participant MQ as RabbitMQ
C->>A: POST /api/catalog/items
A->>MQ: send "ITEM_CREATED:42:SKU-42"
MQ-->>A: deliver message to listener
A-->>A: log "Received from catalog.events: ITEM_CREATED:42:SKU-42"
Pay attention to a subtle point: the publisher and consumer live in the same application. In "real" life, these are often two different services, but for learning purposes that is actually a plus. We can see the entire path in one container's logs and avoid getting distracted by inter-service debates that are better left to other courses.
To avoid confusion in terminology, let's fix a mini map in table form. It is not for theory, but so that next week you can open the logs and understand where "the wire broke".
| Role | What it is in our project | What code/object does it |
|---|---|---|
| Publisher | the one who sends the message | AmqpTemplate.convertAndSend(...) |
| Queue | a named "mailbox" | Queue bean, for example "catalog.events" |
| Broker | the service that stores and distributes messages | the rabbitmq container in Compose |
| Consumer | the one who receives the message | @RabbitListener(...) method |
2. Message format: string
Let me say it straight away: "string" is not because this is "like in 2005", but because it is the best format for the first step, when you are learning to see a cause-and-effect chain. As soon as we turn the message into an object, we get an additional axis of complexity: a DTO class, serialization, converters, compatibility, fields, contract versions. All of that is useful, but right now it only gets in the way of understanding what is actually happening.
So today the message contract will be deliberately simple: one string containing the event type and a couple of values. The string should be something you can open in docker compose logs and read with your own eyes like an ordinary human, not like an archaeologist of binary artifacts.
For example, for the "catalog item created" event, we can make a message like this:
ITEM_CREATED:<id>:<sku>
To avoid spreading the format across the codebase and to keep from running into "oops, there is a colon here and a dash there", it is convenient to move message assembly into a small utility class. It is not "architecture for architecture's sake"; it is simply a way to keep the format in one place.
public final class CatalogEventMessages {
private CatalogEventMessages() {}
public static String itemCreated(long itemId, String sku) {
// Build a human-readable message contract: TYPE:<id>:<sku>
return "ITEM_CREATED:%d:%s".formatted(itemId, sku);
}
}
A nice bonus here is that formatted(...) reads naturally even for a beginner: we substitute id and sku, and get a ready-made string. And yes, if you are thinking right now, "what if sku contains a colon?" — that is a good thought. In a real contract, you would choose the format more carefully, but for a teaching smoke scenario this is enough discipline.
The queue name has already been fixed in CatalogQueues.CATALOG_EVENTS, so there is no second configuration point here. That is also part of readability: one queue, one name, and the sender and listener look exactly at it.
3. Publisher: sending through AmqpTemplate
The most common beginner mistake in messaging integrations is trying to weave RabbitMQ into business logic so tightly that nothing works without it. In our project, we already have a rule: profiles (standalone, postgres, cache, messaging) should change the infrastructure environment, not break the application's core use cases. That means the event publication code should be connectable and disconnectable in a clean way.
We already have the abstraction: CatalogItemService depends on CatalogEventPublisher, without messaging it uses NoOpCatalogEventPublisher, and with messaging Spring injects RabbitCatalogEventPublisher. Now this publisher only has one concrete job left — build the string and send it to CatalogQueues.CATALOG_EVENTS.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Profile("messaging")
@Component
public class RabbitCatalogEventPublisher implements CatalogEventPublisher {
private static final Logger log = LoggerFactory.getLogger(RabbitCatalogEventPublisher.class);
private final AmqpTemplate amqpTemplate;
public RabbitCatalogEventPublisher(AmqpTemplate amqpTemplate) {
this.amqpTemplate = amqpTemplate;
}
@Override
public void itemCreated(long itemId, String sku) {
String message = CatalogEventMessages.itemCreated(itemId, sku);
amqpTemplate.convertAndSend(CatalogQueues.CATALOG_EVENTS, message);
log.info("Sent to {}: {}", CatalogQueues.CATALOG_EVENTS, message);
}
}
There is one important but friendly point here: convertAndSend(destination, message) in this form uses the "simple" path, where destination is the queue name. Under the hood, RabbitMQ can deliver the message to the queue by its name through the default exchange. That is more than enough for us right now.
The marker log after sending is not about beauty, but about diagnostics. When you later look at docker compose logs app, the line Sent to catalog.events: ... quickly answers the question: "Did sending actually happen, or am I looking for the bug in the wrong place?"
4. Consumer: @RabbitListener and the log
On the recipient side, we need exactly one effect: proof that the message did not "evaporate into space" and really reached the handler. No business logic, no database changes, no "let's recalculate the cache here right away" — otherwise we lose transparency. Let the consumer today be like a cashier in a supermarket: "scan the item, say 'beep', done."
In Spring Boot, it is enough to add a component with a method marked with @RabbitListener. Spring will start the listener infrastructure, connect to the broker, and begin pulling messages from the specified queue. We deliberately accept String so that we do not have to discuss serialization.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Profile("messaging")
@Component
public class CatalogEventListener {
private static final Logger log = LoggerFactory.getLogger(CatalogEventListener.class);
// Listen to a specific queue — this is our "end of the wire" for a smoke check
@RabbitListener(queues = CatalogQueues.CATALOG_EVENTS)
public void onMessage(String message) {
// The log is our proof that the message really reached the handler
log.info("Received from {}: {}", CatalogQueues.CATALOG_EVENTS, message);
}
}
Why is the listener also under the @Profile("messaging")? Because without the messaging profile, it is not needed and is even harmful: in modes without RabbitMQ, it will try to connect to a broker that is not there and break application startup. The profile makes behavior predictable: turn on messaging — get both the publisher and the consumer; leave it off — the application lives without queues, as planned.
Separately, note that we write logs through slf4j, not through System.out.println(...), because that is the natural language of a Spring Boot application. In a container, this log will still end up in stdout/stderr, so docker logs and docker compose logs will see it without any problem.
5. Publisher call site
Now the main practical question: who should call the publisher? RabbitMQ is not telepathic; it does not know that you created a CatalogItem. So in the code there must be a moment when the domain action has completed successfully, and we decide, "yes, it is time to send the event."
The most illustrative (and methodologically correct for our course) option is to call the publisher in the same service where the catalog item is created. This gives a short cause-and-effect chain: "called the service → saved → sent the message". For a teaching project, that is perfect: you can easily find the sending point and quickly tie it to the HTTP endpoint.
Roughly, a piece of the create method might look like this:
// 1) Save first (to guarantee that we get an id)
CatalogItem saved = repository.save(item);
// 2) Then publish the event with the already known data
eventPublisher.itemCreated(saved.getId(), saved.getSku());
return saved;
For this to work in all profiles, CatalogItemService should depend not on RabbitCatalogEventPublisher, but on the CatalogEventPublisher interface. Then Spring will inject the correct implementation based on the active profiles. Turn on messaging — get RabbitMQ sending. Do not turn on messaging — get the NoOp implementation, and the service will continue to live a normal life.
This is a very important discipline for a "container-ready" project: external dependencies are added so that the application does not turn into a fragile house of cards. We already saw this with PostgreSQL and Redis; now we are repeating the same style with RabbitMQ.
6. Smoke check in Compose
After the code is ready, you want to do a short and honest smoke check. Not "I opened the management UI and something is blinking there", but "I performed a domain action and saw in the logs: sent and received". This is the moment when the system stops being a diagram on paper and becomes a working environment.
The verification logic is very simple. You bring up the Compose stack with the postgres,cache,messaging profiles, make a POST /api/catalog/items, and in the application logs you expect to see two marker lines: Sent to catalog.events: ... and Received from catalog.events: .... If you see both, then the app → broker → app chain really works.
For the request, you can use a .http file or curl. Example for .http (it is small and fits well in the repository under requests/):
POST http://localhost:8080/api/catalog/items
Content-Type: application/json
{"sku":"SKU-42","title":"Docker Mug","price":12.50}
And then comes the nicest part: you do not "guess" what happened, you read what the system told you in the logs. If both lines are there, you already have a working baseline: rabbitmq is up as a service, the application starts with postgres,cache,messaging, SPRING_RABBITMQ_* points to rabbitmq:5672, the catalog.events queue exists, and the publisher and listener really go through the message path.
7. Typical mistakes in minimal messaging
In a minimal messaging scenario, the mistakes are especially instructive, because there is almost nothing to break... and yet things still break. That is normal: a beginner in programming often thinks that if a topic is simple, then it should "just work". In practice, simple topics are great because they quickly show exactly where you made a mistake and give you a chance to learn to read the system instead of relying on luck.
Mistake #1: the service depends directly on the Rabbit class, and the application crashes without the messaging profile.
This is a classic: you injected RabbitCatalogEventPublisher directly into CatalogItemService, and then you run in a mode without RabbitMQ — and Spring cannot create the bean. The fix is discipline: the service should depend on the CatalogEventPublisher interface, and the enabled implementations should be controlled via @Profile.
Mistake #2: different queue names in the publisher and consumer.
In the sender you wrote "catalog.events", but in the listener — "catalog.event" or "catalog-events". The system may still look "alive", RabbitMQ is up, the application does not crash, but the listener stays silent. The boring but effective solution is to move the queue name into one constant (CatalogQueues.CATALOG_EVENTS) and use it everywhere.
Mistake #3: the consumer works all the time, even when the messaging profile is off.
If you forget @Profile("messaging") on the listener, then in modes without RabbitMQ the application will start trying to connect to the broker and will fail on startup or hang with connection errors. It is important to remember: a listener is not just a method, it is an "active" part of the application that lives only where the infrastructure exists.
Mistake #4: the event is sent "before we have definitely created the entity", and the data in the message is weird.
For example, you sent the message before saving, and the id is still null, or you sent the message in the middle of logic where an error can still occur. For a teaching scenario, the simplest rule is: save first, get the id, then send. In the "adult" world, transactions and guaranteed delivery are much deeper topics, but today we need clear causality.
Mistake #5: you try to make the message an object right away, and instead of RabbitMQ you end up studying serialization.
This is not "a bad idea forever", but it is a bad idea today. As soon as you switch to an object, the questions appear: "how does it become bytes?", "what encoding?", "what converter?". If the goal of the day is to check the environment wiring, the string wins because you can read it from the logs without extra preparation.
GO TO FULL VERSION