Event-Driven Microservices with Spring Boot 3.4 and Apache Kafka
Introduction
In a traditional REST-based microservices architecture, services talk to each other directly: Service A calls Service B's API and waits for a response. This works, but it creates tight coupling — if Service B is slow or down, Service A is affected. It also makes it hard to add new services: if a third service needs to react to the same events, Service A must now know about it too.
Event-driven architecture inverts this relationship. Instead of calling each other, services publish events to a message broker. Other services subscribe to those events and react independently. The publisher doesn't know or care who is listening. You can add a new service without touching the publisher.
Apache Kafka is the most widely used event streaming platform in production today. It's designed for high throughput (millions of messages per second), durable storage, and replay — consumers can re-read past events at any time. Spring Boot's spring-kafka library makes integrating with Kafka idiomatic and familiar.
In this tutorial we'll build a real-world order processing system with three services:
- Order Service — receives REST requests and publishes
OrderPlacedevents to Kafka - Inventory Service — consumes
OrderPlacedevents and deducts stock - Notification Service — consumes
OrderPlacedevents and sends confirmation emails
Both consumer services react to the same event independently. We'll also implement Dead Letter Topic (DLT) error handling so failed messages don't block the pipeline.
Prerequisites
- Java 21
- Maven 3.9+
- Docker (for running Kafka locally)
- Basic understanding of Spring Boot
Running Kafka Locally with Docker
The easiest way to run Kafka for development is with the official apache/kafka image (KRaft mode — no ZooKeeper needed since Kafka 3.3):
docker run -d --name kafka \
-p 9092:9092 \
-e KAFKA_NODE_ID=1 \
-e KAFKA_PROCESS_ROLES=broker,controller \
-e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 \
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 \
-e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
-e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT \
-e KAFKA_CONTROLLER_QUORUM_VOTERS=1@localhost:9093 \
-e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
apache/kafka:3.9.0
Verify it's running:
docker exec kafka /opt/kafka/bin/kafka-topics.sh --list --bootstrap-server localhost:9092
Project Setup
For simplicity we'll build all three services in a single Maven multi-module project, but each module could equally be a standalone deployable.
Root pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.vimleshpandey.demo</groupId>
<artifactId>kafka-microservices</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>order-service</module>
<module>inventory-service</module>
<module>notification-service</module>
<module>shared-events</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Spring Kafka version: Spring Boot 3.4.5 resolves spring-kafka to version 3.3.x automatically — no need to specify the version explicitly.
Shared Events Module
Event classes are shared between producer and consumers. This is the contract between services.
package com.vimleshpandey.demo.events;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record OrderPlacedEvent(
String orderId,
String customerId,
String customerEmail,
String productId,
int quantity,
BigDecimal totalAmount,
Instant placedAt
) {
public static OrderPlacedEvent of(String customerId, String customerEmail,
String productId, int quantity,
BigDecimal totalAmount) {
return new OrderPlacedEvent(
UUID.randomUUID().toString(),
customerId,
customerEmail,
productId,
quantity,
totalAmount,
Instant.now()
);
}
}
Using Java records for events is ideal: they're immutable, serialise cleanly to JSON, and act as self-documenting contracts.
Order Service (Producer)
application.yml
spring:
application:
name: order-service
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
properties:
spring.json.add.type.headers: false # don't embed Java type in headers
app:
kafka:
topic:
orders: orders.placed
server:
port: 8081
Kafka Topic Configuration
package com.vimleshpandey.demo.order.config;
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;
@Configuration
public class KafkaTopicConfig {
@Value("${app.kafka.topic.orders}")
private String ordersTopic;
@Bean
public NewTopic ordersTopic() {
return TopicBuilder.name(ordersTopic)
.partitions(3)
.replicas(1)
.build();
}
@Bean
public NewTopic ordersDeadLetterTopic() {
return TopicBuilder.name(ordersTopic + ".DLT")
.partitions(1)
.replicas(1)
.build();
}
}
Spring's KafkaAdmin bean (auto-configured) picks up NewTopic beans and creates the topics on startup if they don't exist.
Order Producer Service
package com.vimleshpandey.demo.order.service;
import com.vimleshpandey.demo.events.OrderPlacedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Service
public class OrderProducerService {
private static final Logger log = LoggerFactory.getLogger(OrderProducerService.class);
private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;
@Value("${app.kafka.topic.orders}")
private String ordersTopic;
public OrderProducerService(KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishOrderPlaced(OrderPlacedEvent event) {
CompletableFuture<SendResult<String, OrderPlacedEvent>> future =
kafkaTemplate.send(ordersTopic, event.orderId(), event);
future.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Failed to publish OrderPlaced event for order {}: {}",
event.orderId(), ex.getMessage());
} else {
log.info("Published OrderPlaced event: orderId={}, partition={}, offset={}",
event.orderId(),
result.getRecordMetadata().partition(),
result.getRecordMetadata().offset());
}
});
}
}
Key design choices:
- The message key is
orderId— Kafka guarantees ordering within a partition, so usingorderIdas the key ensures all events for the same order go to the same partition. send()returns aCompletableFuture— we handle success and failure asynchronously without blocking the REST thread.
REST Controller
package com.vimleshpandey.demo.order.controller;
import com.vimleshpandey.demo.events.OrderPlacedEvent;
import com.vimleshpandey.demo.order.service.OrderProducerService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderProducerService producerService;
public OrderController(OrderProducerService producerService) {
this.producerService = producerService;
}
@PostMapping
@ResponseStatus(HttpStatus.ACCEPTED)
public OrderAcceptedResponse placeOrder(@RequestBody PlaceOrderRequest request) {
OrderPlacedEvent event = OrderPlacedEvent.of(
request.customerId(),
request.customerEmail(),
request.productId(),
request.quantity(),
request.totalAmount()
);
producerService.publishOrderPlaced(event);
return new OrderAcceptedResponse(event.orderId(), "Order accepted and queued");
}
record PlaceOrderRequest(
String customerId,
String customerEmail,
String productId,
int quantity,
BigDecimal totalAmount
) {}
record OrderAcceptedResponse(String orderId, String message) {}
}
Note the @ResponseStatus(HttpStatus.ACCEPTED) — the REST endpoint returns 202 Accepted because the order is queued for async processing, not yet fulfilled.
Inventory Service (Consumer 1)
application.yml
spring:
application:
name: inventory-service
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: inventory-service-group
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "com.vimleshpandey.demo.events"
spring.json.value.default.type: "com.vimleshpandey.demo.events.OrderPlacedEvent"
listener:
ack-mode: MANUAL_IMMEDIATE
server:
port: 8082
Inventory Consumer
package com.vimleshpandey.demo.inventory.consumer;
import com.vimleshpandey.demo.events.OrderPlacedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.kafka.retrytopic.TopicSuffixingStrategy;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.retry.annotation.Backoff;
import org.springframework.stereotype.Component;
@Component
public class InventoryEventConsumer {
private static final Logger log = LoggerFactory.getLogger(InventoryEventConsumer.class);
@RetryableTopic(
attempts = "3",
backoff = @Backoff(delay = 2000, multiplier = 2.0),
topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_DELAY_VALUE,
dltTopicSuffix = ".DLT"
)
@KafkaListener(
topics = "orders.placed",
groupId = "inventory-service-group"
)
public void handleOrderPlaced(@Payload OrderPlacedEvent event, Acknowledgment ack) {
log.info("[Inventory] Processing order: {} — product: {}, qty: {}",
event.orderId(), event.productId(), event.quantity());
try {
deductStock(event.productId(), event.quantity());
ack.acknowledge();
log.info("[Inventory] Stock deducted for order {}", event.orderId());
} catch (Exception e) {
log.error("[Inventory] Failed to process order {}: {}",
event.orderId(), e.getMessage());
throw e; // triggers @RetryableTopic retry
}
}
private void deductStock(String productId, int quantity) {
// In a real service: query DB, check stock, update row
log.info("[Inventory] Deducting {} units of product {}", quantity, productId);
// Simulate occasional failures for demo purposes
if (productId.startsWith("ERR")) {
throw new RuntimeException("Product " + productId + " not found in inventory");
}
}
}
@RetryableTopic — the modern approach to retries:
- Spring Kafka 2.7+ introduced
@RetryableTopicwhich creates retry topics (e.g.,orders.placed-retry-2000,orders.placed-retry-4000) automatically - Failed messages are forwarded to the retry topic with an exponential backoff delay
- After all retries exhausted, the message lands in the Dead Letter Topic (DLT):
orders.placed.DLT - This is non-blocking — the consumer doesn't sit and sleep waiting for retries; it moves on and processes other messages
Notification Service (Consumer 2)
The notification service listens to the same topic with a different consumer group — it receives every event independently of the inventory service.
application.yml
spring:
application:
name: notification-service
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: notification-service-group
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "com.vimleshpandey.demo.events"
spring.json.value.default.type: "com.vimleshpandey.demo.events.OrderPlacedEvent"
server:
port: 8083
Notification Consumer
package com.vimleshpandey.demo.notification.consumer;
import com.vimleshpandey.demo.events.OrderPlacedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.retry.annotation.Backoff;
import org.springframework.stereotype.Component;
@Component
public class NotificationEventConsumer {
private static final Logger log = LoggerFactory.getLogger(NotificationEventConsumer.class);
@RetryableTopic(
attempts = "3",
backoff = @Backoff(delay = 1000, multiplier = 2.0),
dltTopicSuffix = ".notification.DLT"
)
@KafkaListener(
topics = "orders.placed",
groupId = "notification-service-group"
)
public void handleOrderPlaced(OrderPlacedEvent event) {
log.info("[Notification] Sending confirmation to {} for order {}",
event.customerEmail(), event.orderId());
sendEmail(
event.customerEmail(),
"Order Confirmed — " + event.orderId(),
buildEmailBody(event)
);
}
private void sendEmail(String to, String subject, String body) {
// In production: Spring Mail / SendGrid / SES
log.info("[Notification] EMAIL → {} | Subject: {}", to, subject);
}
private String buildEmailBody(OrderPlacedEvent event) {
return String.format(
"Hi, your order %s for %d unit(s) of product %s totalling %s has been received.",
event.orderId(), event.quantity(), event.productId(), event.totalAmount()
);
}
}
Dead Letter Topic (DLT) Handler
Messages that fail all retry attempts land in the DLT. You need a dedicated handler to monitor and alert on these:
package com.vimleshpandey.demo.inventory.consumer;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class DltConsumer {
private static final Logger log = LoggerFactory.getLogger(DltConsumer.class);
@KafkaListener(
topics = "orders.placed.DLT",
groupId = "inventory-dlt-group"
)
public void handleDeadLetter(ConsumerRecord<String, byte[]> record) {
log.error("[DLT] Unprocessable message landed in DLT. " +
"Key: {}, Topic: {}, Partition: {}, Offset: {}",
record.key(), record.topic(),
record.partition(), record.offset());
// In production:
// 1. Alert via PagerDuty / Slack
// 2. Persist to a poison-message store for manual review
// 3. Optionally replay after the underlying bug is fixed
}
}
Testing
Spring Kafka ships with @EmbeddedKafka — you can run real Kafka broker in-process for integration tests without Docker:
package com.vimleshpandey.demo.order;
import com.vimleshpandey.demo.events.OrderPlacedEvent;
import com.vimleshpandey.demo.order.service.OrderProducerService;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.test.context.EmbeddedKafka;
import org.springframework.test.context.ActiveProfiles;
import java.math.BigDecimal;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@EmbeddedKafka(
partitions = 1,
topics = {"orders.placed"}
)
@ActiveProfiles("test")
class OrderProducerServiceTest {
@Autowired
private OrderProducerService producerService;
private final BlockingQueue<OrderPlacedEvent> receivedEvents =
new LinkedBlockingQueue<>();
@KafkaListener(topics = "orders.placed", groupId = "test-consumer")
public void consume(OrderPlacedEvent event) {
receivedEvents.add(event);
}
@Test
void shouldPublishOrderPlacedEvent() throws InterruptedException {
OrderPlacedEvent event = OrderPlacedEvent.of(
"cust-123", "test@example.com", "PROD-456",
2, new BigDecimal("49.98")
);
producerService.publishOrderPlaced(event);
OrderPlacedEvent received = receivedEvents.poll(10, TimeUnit.SECONDS);
assertThat(received).isNotNull();
assertThat(received.customerId()).isEqualTo("cust-123");
assertThat(received.quantity()).isEqualTo(2);
}
}
Running the System
# Terminal 1 — Order Service (producer)
cd order-service && mvn spring-boot:run
# Terminal 2 — Inventory Service (consumer 1)
cd inventory-service && mvn spring-boot:run
# Terminal 3 — Notification Service (consumer 2)
cd notification-service && mvn spring-boot:run
# Place an order
curl -s -X POST http://localhost:8081/api/orders \
-H 'Content-Type: application/json' \
-d '{
"customerId": "cust-001",
"customerEmail": "alice@example.com",
"productId": "PROD-100",
"quantity": 3,
"totalAmount": 149.97
}' | jq .
You'll immediately see both consumer services log their reactions:
[Inventory] Processing order: abc-123 — product: PROD-100, qty: 3
[Notification] Sending confirmation to alice@example.com for order abc-123
Test error handling with a product ID prefixed ERR:
curl -X POST http://localhost:8081/api/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":"c1","customerEmail":"e@e.com","productId":"ERR-999","quantity":1,"totalAmount":9.99}'
You'll see the inventory service retry 3 times with exponential backoff, then the DLT handler log the dead letter.
How the Consumer Group Model Works
Understanding Kafka's consumer group model is essential:
- Each consumer group has its own offset pointer in each partition
inventory-service-groupandnotification-service-groupare independent — they both read every message from the topic, independently- Within a consumer group, partitions are distributed across instances — if you start 3 instances of the inventory service and the topic has 3 partitions, each instance handles one partition in parallel (horizontal scaling)
This means adding the Notification Service required zero changes to the Order Service. The architecture is genuinely extensible.
Common Pitfalls and Tips
1. spring.json.trusted.packages is mandatory
Without it, the JsonDeserializer refuses to deserialise messages and throws ClassNotFound or a trust exception. Always set it to the package containing your event classes.
2. Don't use AUTO_COMMIT
The default enable-auto-commit=true can cause messages to be marked as consumed before your handler finishes — if the handler crashes mid-way, the message is lost. Set ack-mode: MANUAL_IMMEDIATE and call ack.acknowledge() only after successful processing.
3. @RetryableTopic vs DefaultErrorHandler
Spring Kafka provides two retry mechanisms. @RetryableTopic (introduced in Spring Kafka 2.7) is the modern, non-blocking approach that forwards to retry topics. DefaultErrorHandler with FixedBackOff is the older approach that blocks the thread. Use @RetryableTopic for production.
4. Partition count and consumer scaling
The number of partitions is the maximum parallelism ceiling. If your topic has 3 partitions and you start 4 consumer instances in the same group, one instance will be idle. Choose partition count upfront based on your expected scale — you can increase partitions but you cannot decrease them.
5. Message ordering
Kafka guarantees ordering within a partition. If ordering matters (e.g., all events for the same order must be processed in sequence), use the same key for all related events — they'll land on the same partition.
6. Schema evolution
JSON is flexible but fragile — adding a required field breaks old consumers. Consider Apache Avro with a Schema Registry for production systems where event schemas evolve over time.
Conclusion
You've built a production-pattern event-driven system where:
- The Order Service publishes events without knowing who consumes them
- Inventory and Notification services react independently to the same events
@RetryableTopichandles transient failures without blocking the consumer- Dead Letter Topics catch poison messages for manual review and alerting
- The system scales horizontally — add more partitions and more consumer instances
Event-driven architecture with Kafka is the backbone of large-scale systems at LinkedIn, Netflix, Uber, and Airbnb. Spring Boot's spring-kafka makes it accessible with the same declarative, annotation-driven model you already know from Spring — but with the resilience and throughput of one of the most battle-tested messaging systems in the industry.