DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

OrderHub Day 26: my first Kafka consumer, where inventory reserves stock all by itself

Day 25 of building OrderHub — my from-scratch Spring Boot order system — gave order-service a Kafka producer: when an order is placed it publishes an OrderPlaced event and moves on. But an event no one reads does nothing. Today I add the other half: inventory-service subscribes to the order-placed topic and reserves stock the moment an event arrives — with zero change to order-service. That decoupling is the whole payoff of going event-driven, and two production-shaped details make it real: idempotency and graceful failure.

The dependency arrow reversed

On Day 18, reserving stock was a synchronous Feign call from order-service: it had to know inventory-service's address and block for the reply. Now inventory-service reacts on its own, and order-service does not know it exists. Adding the next reaction (Day 27's payment service) is just another consumer on the same topic — again with no producer change.

// Day 18 (synchronous): order-service KNOWS + BLOCKS on inventory
order-service --HTTP reserve--> inventory-service   (waits for 200)

// Day 26 (event-driven): inventory REACTS on its own
order-service --publish--> [ order-placed ]
                               '--> inventory-service @KafkaListener → reserve stock
Enter fullscreen mode Exit fullscreen mode

Share the contract, not the class

To deserialize the JSON the consumer needs a type to bind onto. The tempting move — reuse order-service's event class — is exactly wrong: a shared compiled type couples two separate deployables' builds and release cycles, which is what events are meant to avoid. So inventory-service defines its own record with field names matching the producer's. The services share a contract (the JSON shape), not code.

public record OrderPlacedEvent(
    String eventId, String orderId, String customer,
    String item,       // == the inventory SKU to reserve against
    int quantity,
    String status, Instant placedAt, Instant occurredAt
) {}
Enter fullscreen mode Exit fullscreen mode

The producer serialized with no type headers (to stay language-neutral), so the consumer's JsonDeserializer must be told the target type and given a trusted-packages allow-list — never a blind wildcard on a topic you do not fully control.

The subscription is one annotation

@KafkaListener is the whole subscription: the topic (a config placeholder so it matches the producer), the consumer group, and the JSON-typed factory so the method takes an OrderPlacedEvent directly. The subtle knob is autoStartup — flip it off and the container never starts, so non-Kafka tests stay hermetic without ever reaching for a broker.

@KafkaListener(
    topics           = "${inventory.events.order-placed-topic:order-placed}",
    groupId          = "${inventory.events.consumer-group-id:inventory-service}",
    containerFactory = "orderEventListenerContainerFactory",
    autoStartup      = "${inventory.events.enabled:true}")   // off => never starts
public void onOrderPlaced(OrderPlacedEvent event) {
    // reserve stock, idempotently and gracefully
}
Enter fullscreen mode Exit fullscreen mode

A consumer group is how work is shared: run three instances in one group and Kafka spreads the partitions across them. A different service uses its own group id and gets its own independent copy of every event — offsets are tracked per group.

Idempotency: at-least-once means handle each order once

This is the property that separates a toy consumer from a real one. Kafka guarantees at-least-once delivery, not exactly-once: after a rebalance, a retry, or a crash between processing and committing the offset, the same event can arrive again. If the listener reserved stock on every delivery, one order could silently hold its quantity twice. The fix is to key on a stable business id — the order id — and claim it atomically before touching stock.

// ReservationLedger — atomic first-wins claim
public boolean claim(String orderId) {
    return claimed.putIfAbsent(orderId, TRUE) == null;  // true only the FIRST time
}

// in the listener, BEFORE reserving:
if (!ledger.claim(event.orderId())) {
    log.info("Duplicate OrderPlaced for {} — skipping", event.orderId());
    return;                       // redelivery → do nothing, no double-reserve
}
Enter fullscreen mode Exit fullscreen mode

At-least-once delivery, processed exactly once.

Graceful failure: never crash the listener on a poison message

A business failure — the order names a SKU that is out of stock, or one we do not carry — is expected, not exceptional. The dangerous mistake is to let that exception escape the listener: the container would treat the record as unprocessed, never advance the offset, and redeliver the same record forever — a "poison message" that wedges the whole partition. So I catch the business exceptions, record the outcome as a durable mark, and return normally so the offset commits and the consumer moves on.

try {
    StockItem item = inventoryService.reserve(event.item(), event.quantity());
    ledger.record(Reservation.reserved(orderId, event.eventId(), event.item(),
        event.quantity(), item.available()));
} catch (InsufficientStockException e) {          // EXPECTED — don't rethrow
    ledger.record(Reservation.failed(orderId, event.eventId(), event.item(),
        event.quantity(), "INSUFFICIENT_STOCK"));
    log.warn("Insufficient stock for order {}: {}", orderId, e.getMessage());
}
Enter fullscreen mode Exit fullscreen mode

Prove it without Docker

The consumer test is hermetic: @EmbeddedKafka stands up a throwaway in-JVM broker, the same consumer code is pointed at it, and because consuming is asynchronous, Awaitility waits until the stock is decremented. Three cases are proven — a plain reserve, a duplicate that does not double-reserve, and an out-of-stock order that is marked without crashing.

@SpringBootTest(classes = TestApp.class, properties =
    "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}")
@EmbeddedKafka(partitions = 1, topics = "order-placed")   // in-JVM broker, no Docker
class OrderPlacedConsumerTest {
  @Test void consumingOrderPlacedReservesStock() {
    producer.send("order-placed", "ORD-K", event("ORD-K","KEYBOARD-001",2));
    await().atMost(20, SECONDS).untilAsserted(() ->
      assertThat(inventory.getStock("KEYBOARD-001").available()).isEqualTo(40));
  }   // + duplicate-is-idempotent + insufficient-is-graceful
}
Enter fullscreen mode Exit fullscreen mode

The trade-off is eventual consistency: stock is reserved a moment after the order is saved, not inside the same request. Day 28 chains these reactions into a choreography saga; Day 32 hardens idempotency into true exactly-once with the eventId.

Publish an OrderPlaced and watch inventory-service react — flip "redeliver same" to see idempotency, or order the out-of-stock item to see graceful failure:
https://dev48v.infy.uk/orderhub.php

Repo: https://github.com/dev48v/order-hub-from-zero

Top comments (0)