I keep going back to RabbitMQ when I need reliable async messaging without the operational weight of a full event streaming platform. Here's how I set it up in Spring Boot, the patterns I actually use in production, and where it fits alongside the async jobs powering ITJobOpportunities.
I'd touched RabbitMQ before, on a hospital microservices program where we needed to decouple lab result processing from the intake service. But I'd never built the whole thing from scratch myself. Building ITJobOpportunities as a solo founder means I don't get to hand the messaging layer to a platform team. If it breaks, I'm the one who notices the Slack alert on my phone later at night. This is what I can talk about setting RabbitMQ in our ATS infrastructure. What RabbitMQ actually is, in my head RabbitMQ is a broker that sits between the thing producing work and the thing consuming it. You send a message to an exchange, the exchange decides which queue (or queues) it belongs in based on routing rules, and consumers pull messages off those queues whenever they're ready. That's it. Three pieces: exchanges, queues, bindings. Everything else is detail. I get asked sometimes why I didn't just use Kafka, since I've used it on a few consulting engagements around lending microservices and document ingestion pipelines. Honestly, for ITJobOpportunities, Kafka felt like overkill. I don't need a distributed log with replay semantics and consumer groups rebalancing across partitions. I need "send this job alert email" and "notify the recruiter that someone applied" to happen reliably, without blocking my API thread. RabbitMQ is lighter to run, easier to reason about, and the AMQP model maps cleanly onto point-to-point and fanout patterns that cover most of what a job platform actually needs. If I were building something with massive throughput and needed event replay for analytics, I'd reach for Kafka again. For task queues and notification fanout, RabbitMQ wins for me every time. Setting it up locally I run RabbitMQ in Docker for local dev, same as I do for PostgreSQL and Keycloak on the jobs-posting backend. docker run -d --name rabbitmq \ -p 5672:5672 -p 15672:15672 \ rabbitmq:3.13-management The management plugin on port 15672 is worth the extra few seconds it takes to load. You can watch queues fill up and drain in real time, which saved me more debugging time than I want to admit during the first week I had this running. Adding it to Spring Boot The dependency is one line if you're on Spring Boot 3.5.x like I am on jobs-posting. implementation 'org.springframework.boot:spring-boot-starter-amqp' And the connection config in application.yml : spring: rabbitmq: host: localhost port: 5672 username: guest password: guest In production those credentials come from environment variables injected at container runtime, not sitting in a YAML file. Same pattern I use for the Keycloak secrets and the OpenRouter API key on jobs-posting. Declaring exchanges, queues, and bindings This is where a lot of tutorials get sloppy, showing a producer and a consumer without explaining who actually creates the queue in the first place. In Spring Boot, I like declaring the whole topology as beans so the app is self-sufficient on startup. No manual clicking around in the management UI. @Configuration public class NotificationRabbitConfig { public static final String EXCHANGE = "job.notifications"; public static final String CANDIDATE_QUEUE = "job.notifications.candidate"; public static final String RECRUITER_QUEUE = "job.notifications.recruiter"; @Bean public TopicExchange notificationExchange() { return new TopicExchange(EXCHANGE); } @Bean public Queue candidateQueue() { return QueueBuilder.durable(CANDIDATE_QUEUE).build(); } @Bean public Queue recruiterQueue() { return QueueBuilder.durable(RECRUITER_QUEUE).build(); } @Bean public Binding candidateBinding() { return BindingBuilder.bind(candidateQueue()) .to(notificationExchange()) .with("application.submitted.candidate"); } @Bean public Binding recruiterBinding() { return BindingBuilder.bind(recruiterQueue()) .to(notificationExchange()) .with("application.submitted.recruiter"); } } I picked a topic exchange here on purpose. When a candidate uses Easy Apply on ITJobOpportunities, two things need to happen: the candidate gets a confirmation, and the recruiter gets notified with the relevance score attached. One business event, two different routing keys, two different queues. Direct exchanges work fine for simple one-to-one delivery, but the moment you need to fan a single event out to multiple consumers, topic exchanges are just less annoying to maintain. Publishing messages The producer side uses RabbitTemplate , and Spring Boot autoconfigures it once you've got the starter on the classpath. @Service @RequiredArgsConstructor public class ApplicationEventPublisher { private final RabbitTemplate rabbitTemplate; public void publishEasyApply(EasyApplyEvent event) { rabbitTemplate.convertAndSend( NotificationRabbitConfig.EXCHANGE, "application.submitted.candidate", event ); rabbitTemplate.convertAndSend( NotificationRabbitConfig.EXCHANGE, "application.submitted.recruiter", event ); } } You'll also need a message converter bean, otherwise you're stuck serializing Java objects with the default converter, which is fine until you try to consume the message from another service or debug it through the management UI and see binary instead of readable JSON. @Bean public MessageConverter jsonMessageConverter() { return new Jackson2JsonMessageConverter(); } @Bean public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter converter) { RabbitTemplate template = new RabbitTemplate(connectionFactory); template.setMessageConverter(converter); return template; } I learned that one the hard way. I spent about twenty minutes staring at hex garbage in the RabbitMQ management console before I remembered the converter matters. Consuming messages On the other side, @RabbitListener does most of the heavy lifting. @Component @RequiredArgsConstructor public class CandidateNotificationListener { private final EmailService emailService; @RabbitListener(queues = NotificationRabbitConfig.CANDIDATE_QUEUE) public void onApplicationSubmitted(EasyApplyEvent event) { emailService.sendApplicationConfirmation( event.candidateEmail(), event.jobTitle() ); } } Dead simple. No polling, no cron job checking a database table for pending notifications, which is what I was doing before, and it was as janky as it sounds. The listener wakes up when a message arrives and does its job. That's it. Where it gets real: reliability Here's the part nobody tells you when you're following a "hello world" tutorial: what happens when the consumer throws an exception? By default, RabbitMQ keeps redelivering that message forever, and your logs fill up with the same stack trace every few milliseconds. I found that out during a load test when a malformed email address kept crashing the listener in a tight loop. The fix is a dead-letter exchange. Any message that fails too many times gets routed somewhere else instead of retried infinitely. @Bean public Queue candidateQueue() { return QueueBuilder.durable(NotificationRabbitConfig.CANDIDATE_QUEUE) .withArgument("x-dead-letter-exchange", "job.notifications.dlx") .withArgument("x-dead-letter-routing-key", "candidate.dead") .build(); } I also set a retry policy with RetryInterceptorBuilder so a transient failure, like an SMTP hiccup or a network blip, gets a few attempts with backoff before it gets shipped off to the dead letter queue for a human, usually me, to look at later. @Bean public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory( ConnectionFactory connectionFactory) { SimpleRab...