Project: Event-Driven Order System
This project ties together everything Kafka gives you — durable topics, partitioned ordering, consumer groups, listeners, and dead-letter handling — into a realistic e-commerce flow. We build three Spring Boot services: an order-service that produces an OrderCreated event, a payment-service and an inventory-service that consume it and react independently, and status events that flow back so the order-service can settle the order. This is the canonical microservices pattern: no service calls another directly, everything coordinates through the log, and failures route to a dead-letter topic instead of being lost.
Topic design
Each business capability owns its own topic. Keys are the orderId, which guarantees all events for one order land on the same partition and are processed in order. Use a small, fixed partition count per topic (e.g. 6) sized for your throughput, and replication factor 3 in production.
| Topic | Key | Producer | Consumers | Purpose |
|---|---|---|---|---|
orders.created | orderId | order-service | payment, inventory | A new order was placed |
payments.completed | orderId | payment-service | order-service | Payment succeeded or failed |
inventory.reserved | orderId | inventory-service | order-service | Stock reserved or rejected |
orders.created.DLT | orderId | error handler | ops/alerting | Poison messages from consumers |
kafka-topics.sh --create --topic orders.created \
--bootstrap-server localhost:9092 \
--partitions 6 --replication-factor 3
Event contracts
Model every event as an immutable Java record. Past-tense names signal these are facts, not commands.
public record OrderCreated(
String orderId, String customerId, String sku, int quantity, long amountCents) {}
public record PaymentCompleted(String orderId, boolean approved, String reason) {}
public record InventoryReserved(String orderId, boolean reserved, String reason) {}
Producing the order event
The order-service persists the order, then publishes OrderCreated keyed by orderId. We use the async send and attach a callback so a broker failure is logged rather than silently dropped.
@Service
public class OrderService {
private final KafkaTemplate<String, OrderCreated> kafka;
public OrderService(KafkaTemplate<String, OrderCreated> kafka) {
this.kafka = kafka;
}
public String place(OrderCreated order) {
kafka.send("orders.created", order.orderId(), order)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Failed to publish OrderCreated {}", order.orderId(), ex);
}
});
return order.orderId();
}
}
Persist the order before sending, and consider the transactional outbox pattern if you need an atomic write-and-publish. A naive “save then send” can leave the DB and Kafka out of sync if the send fails.
Consuming and reacting
Both downstream services subscribe to orders.created but use different consumer group IDs, so each gets its own copy of every event. The payment-service charges the customer and emits PaymentCompleted; the inventory-service reserves stock and emits InventoryReserved.
@Component
public class PaymentListener {
private final KafkaTemplate<String, PaymentCompleted> kafka;
public PaymentListener(KafkaTemplate<String, PaymentCompleted> kafka) {
this.kafka = kafka;
}
@KafkaListener(topics = "orders.created", groupId = "payment-service")
public void onOrder(OrderCreated order) {
boolean ok = charge(order.customerId(), order.amountCents());
kafka.send("payments.completed", order.orderId(),
new PaymentCompleted(order.orderId(), ok, ok ? "OK" : "DECLINED"));
}
}
The deserializer config in application.yml tells Spring how to turn bytes back into the record type and which packages are safe to deserialize:
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: payment-service
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.devcraftly.orders.events"
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
Closing the loop and handling failures
The order-service listens for both reply events and marks the order CONFIRMED only when payment is approved and stock is reserved. To stop a poison message from blocking a partition forever, wire a DefaultErrorHandler with a DeadLetterPublishingRecoverer that retries a few times then routes to orders.created.DLT.
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template);
// 3 retries, 2s apart, then publish to <topic>.DLT
return new DefaultErrorHandler(recoverer, new FixedBackOff(2000L, 3L));
}
When a record exhausts its retries, you will see it land on the DLT:
ERROR o.s.k.l.DeadLetterPublishingRecoverer - Publishing to orders.created.DLT
partition=2 offset=87 key=ord-9f3 reason=PaymentGatewayTimeout
Event flow
┌──────────────┐ OrderCreated ┌──────────────────┐
POST ───► │ order-service├─────────────────►│ orders.created │
└──────┬───────┘ └───────┬──────────┘
▲ │ (fan-out)
PaymentCompleted / InventoryReserved ┌───────┴────────┐
│ ▼ ▼
┌──────────┴──────────┐ ┌─────────────────┐ ┌──────────────────┐
│ payments.completed │◄─────┤ payment-service │ │ inventory-service├──► inventory.reserved
└─────────────────────┘ └─────────────────┘ └──────────────────┘
│ retries exhausted │
▼ ▼
orders.created.DLT ◄───────┘
Best Practices
- Key by
orderIdso every event for an order is ordered on one partition and processed by one consumer thread at a time. - Give each consuming service its own
group-idso the sameOrderCreatedevent fans out to payment and inventory independently. - Make listeners idempotent — Kafka is at-least-once by default, so a charge or reservation must tolerate being seen twice (dedupe on
orderId). - Always configure a
DefaultErrorHandlerwith a DLT so one bad payload never stalls a partition; alert on DLT depth. - Use the transactional outbox (or Kafka transactions) when a state change and its event must be atomic.
- Version your event records additively — add fields, never repurpose them — so old and new consumers coexist during rollout.
- Keep events as facts, not commands:
OrderCreated, notCreateOrder, so producers stay ignorant of consumers.