If you're building an event-driven system with Spring, the "right" messaging technology depends more on your requirements than on what's most popular. A common architecture today is:
- Spring Boot for services
- Apache Kafka for event streaming and asynchronous communication
- RabbitMQ for traditional message queues and work distribution
- An Outbox Pattern for reliable event publishing
- Idempotent consumers to handle duplicate deliveries safely
Kafka vs. RabbitMQ
| Feature | Kafka | RabbitMQ |
|---|
| Primary model | Event streaming | Message broker/queue |
| Ordering | Per partition | Per queue (with caveats) |
| Message retention | Configurable (days/months) | Usually removed after acknowledgement |
| Replay events | Excellent | Limited |
| Throughput | Very high | High |
| Fan-out | Excellent | Good |
| Long-term event history | Yes | No |
| Typical use | Event sourcing, analytics, microservices | Background jobs, task queues, RPC |
Choose Kafka when
- Services communicate primarily through events.
- Consumers may join later.
- You need event replay.
- High throughput matters.
- Multiple independent consumers process the same events.
Example:
OrderCreated
├── Inventory Service
├── Billing Service
├── Email Service
└── Analytics Service
Each service reads independently.
Choose RabbitMQ when
- You need work queues.
- Task distribution is the main goal.
- Low latency commands.
- Complex routing with exchanges.
- Scheduling or delayed messages.
Example:
Generate PDF
Resize Image
Send Email
Process Invoice
Workers consume tasks from queues.
Use both
Many organizations do.
REST API
|
Order Service
|
Outbox
|
Kafka ----------------------------+
| |
Inventory Analytics
Billing Search
Email
|
RabbitMQ
|
Email Workers
Kafka distributes business events while RabbitMQ handles internal work queues.
Spring support
For Kafka:
- Spring for Apache Kafka
@KafkaListener
- Transactions
- Retry topics
- Dead-letter topics
For RabbitMQ:
- Spring AMQP
@RabbitListener
- Dead-letter exchanges
- Retry policies
Spring has mature support for both.
The Outbox Pattern
One of the biggest problems:
Save Order
Publish Event
What if:
DB commit succeeds
Kafka publish fails
Now the database says the order exists.
No one else knows.
The opposite is also possible:
Kafka publish succeeds
DB rolls back
Now consumers see an order that never existed.
Correct approach
Store the event in the same database transaction.
BEGIN
Insert Order
Insert Outbox Event
COMMIT
Now both succeed or fail together.
A separate publisher reads the outbox table.
Orders
------
id
status
Outbox
------
id
aggregate_id
event_type
payload
published
created_at
Publisher:
while(true)
{
SELECT unpublished events;
publish to Kafka;
mark published;
}
No distributed transaction required.
Debezium
Instead of polling:
SELECT * FROM outbox
Many teams use change data capture.
DB
|
WAL / Binlog
|
Debezium
|
Kafka
Advantages:
- lower latency
- fewer database queries
- more scalable
- highly reliable
This has become a common production approach.
Idempotency
Assume Kafka delivers twice.
Consumer receives:
OrderCreated(123)
Processes it.
Then receives:
OrderCreated(123)
again.
Without protection:
Charge credit card twice
Send email twice
Decrease inventory twice
Bad.
Solution 1: Processed-event table
processed_events
event_id
processed_at
Consumer:
if exists(event_id)
ignore
else
process
insert event_id
Store the "processed" marker atomically with your business changes so they either both commit or both roll back.
Solution 2: Business idempotency
Instead of:
Increment balance
Do:
Set balance to X
or
Set order status = SHIPPED
Repeating the operation has no effect.
Solution 3: Natural uniqueness
For example:
Payment ID
Invoice ID
Order ID
Use database unique constraints.
Duplicate processing fails harmlessly.
Event design
Avoid:
UserChanged
Prefer:
UserEmailChanged
UserRegistered
UserDeleted
UserAddressUpdated
Small, meaningful events are easier to evolve.
Event versioning
Don't change existing payloads in incompatible ways.
Instead:
OrderCreated v1
OrderCreated v2
or
{
"version":2,
...
}
Consumers can migrate gradually.
Delivery semantics
Understand what your broker guarantees.
- At-most-once: Messages may be lost, but won't be delivered more than once.
- At-least-once: Messages can be delivered multiple times, so consumers should be idempotent. This is the most common and practical choice.
- Exactly-once: Kafka supports exactly-once semantics in certain producer/consumer workflows, but this does not automatically make downstream effects (such as database writes or external API calls) exactly-once. Idempotent application logic is still important.
Retry strategy
Don't retry forever.
Typical flow:
Topic
↓
Consumer
↓
Retry
↓
Retry
↓
Retry
↓
Dead Letter Topic
Investigate messages from the dead-letter topic rather than blocking the entire consumer group.
Ordering
Kafka guarantees ordering within a partition.
Use a stable partition key.
Example:
Order ID
All events for one order stay together.
Avoid random partition keys if event order matters for a given entity.
Recommended production stack
For most Spring microservice systems, a solid default architecture is:
- Spring Boot
- Spring for Apache Kafka
- Transactional Outbox Pattern
- Debezium (CDC) for publishing outbox events at scale
- Idempotent consumers using event IDs and atomic persistence of processed markers
- Dead-letter topics and bounded retries
- Schema evolution with explicit event versioning (or a schema registry if using Avro/Protobuf)
- Correlation IDs and distributed tracing for observability
- Metrics, logging, and alerting around consumer lag, retry rates, and dead-letter queues
This combination provides reliable event publication, resilience to duplicate deliveries, and a scalable foundation for event-driven microservices without relying on distributed transactions.