Skip to content
Apache Kafka projects 4 min read

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.

TopicKeyProducerConsumersPurpose
orders.createdorderIdorder-servicepayment, inventoryA new order was placed
payments.completedorderIdpayment-serviceorder-servicePayment succeeded or failed
inventory.reservedorderIdinventory-serviceorder-serviceStock reserved or rejected
orders.created.DLTorderIderror handlerops/alertingPoison 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 orderId so 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-id so the same OrderCreated event 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 DefaultErrorHandler with 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, not CreateOrder, so producers stay ignorant of consumers.
Last updated June 1, 2026
Was this helpful?