DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

OrderHub Day 25: publishing my first Kafka event without ever breaking order creation

Through the first three phases of building OrderHub — my from-scratch Spring Boot order system — every service called its neighbours synchronously: order-service called inventory-service over Feign and blocked for the answer. That is simple but brittle. The caller's fate is tied to the callee's availability, and every new reaction to an order means editing order-service to add another call. Day 25 kicks off Phase 4: making the system event-driven. When an order is placed, order-service now publishes an OrderPlaced event and moves on, not knowing or caring who reacts. The golden rule of a first producer: publishing is additive, and must never break order creation.

The event is a fact

An event is a fact stated in the past tense — OrderPlaced, not PlaceOrder (that would be a command). It is immutable (a Java record), self-contained, and timestamped, so a consumer can act on it without calling back:

public record OrderPlacedEvent(
    String  eventId,     // unique id of THIS emission (idempotent consumers, later)
    String  orderId, String customer, String item, int quantity,
    String  status,      // PLACED
    Instant placedAt,    // when the order was created
    Instant occurredAt   // when this event was produced
) {
  public static OrderPlacedEvent from(Order o) {
    return new OrderPlacedEvent(UUID.randomUUID().toString(),
        o.getId(), o.getCustomer(), o.getItem(), o.getQuantity(),
        o.getStatus().name(), o.getCreatedAt(), Instant.now());
  }
}
Enter fullscreen mode Exit fullscreen mode

The eventId is distinct from the order id — it identifies this specific emission, which idempotent consumers will later use to detect a redelivery.

The producer: a typed KafkaTemplate

A producer needs three things: where the broker is, how to serialize a key and value, and a template to send. I wire them explicitly so the event type is baked into the template's generics — KafkaTemplate<String, OrderPlacedEvent> — which documents exactly what travels on this topic:

@Bean ProducerFactory<String, OrderPlacedEvent> orderEventProducerFactory() {
  var cfg = new HashMap<String,Object>();
  cfg.put(BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
  cfg.put(KEY_SERIALIZER_CLASS_CONFIG,   StringSerializer.class);   // key = order id
  cfg.put(VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);     // value = JSON
  cfg.put(ACKS_CONFIG, "all");                                      // strongest durability
  cfg.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);            // language-neutral
  return new DefaultKafkaProducerFactory<>(cfg);
}
Enter fullscreen mode Exit fullscreen mode

The key is the order id (a String), so every event about one order lands on the same partition and stays ordered. The value is JSON, so any consumer in any language can read it. acks=all waits for the in-sync replicas — the safest setting.

Never hardcode the broker address

The broker's location is environment-specific — a local docker-compose broker in dev, a managed cluster in prod — so it must not be baked into the jar. application.yml sets it to a placeholder with a local fallback:

spring:
  kafka:
    bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
Enter fullscreen mode Exit fullscreen mode

Dev falls back to the compose broker; prod injects the real address; the same jar runs everywhere. Moving or scaling the broker is an env-var change, not a rebuild.

The subtle part: a dead broker must not freeze the order

This is the most important detail of a first producer. KafkaTemplate.send() is asynchronous, but before it can send it must fetch cluster metadata — and if the broker is down, that fetch blocks. By default max.block.ms is 60 seconds, so a send to a dead broker would freeze the order-create request for a full minute. Unacceptable, since the order was already saved. So I cap the block:

cfg.put(MAX_BLOCK_MS_CONFIG,       2000);  // metadata/buffer wait cap (default 60000!)
cfg.put(REQUEST_TIMEOUT_MS_CONFIG, 2000);
cfg.put(DELIVERY_TIMEOUT_MS_CONFIG,3000);
Enter fullscreen mode Exit fullscreen mode

Fail fast, then swallow. The publisher catches every failure and lets the order stand:

public void publishOrderPlaced(Order order) {
  if (!properties.enabled()) return;                 // skip (tests / broker-less)
  var event = OrderPlacedEvent.from(order);
  try {
    kafkaTemplate.send(properties.orderPlacedTopic(), order.getId(), event)
      .whenComplete((res, ex) -> {                   // async result
        if (ex != null) log.warn("publish failed for {}: {}", order.getId(), ex.toString());
        else log.info("Published OrderPlaced for {}", order.getId());
      });
  } catch (Exception ex) {                            // sync failure (broker down)
    log.warn("Could not publish (order still succeeded): {}", ex.toString());
  }
}
Enter fullscreen mode Exit fullscreen mode

It is wired in as a single line at the end of placeOrder, after repository.save returns — you only ever announce an order that is truly durable. Fail-fast plus swallow is what keeps a Kafka outage from becoming an order-service outage.

Proving it without Docker

The producer test has to be hermetic. @EmbeddedKafka stands up a throwaway in-JVM broker, and the same producer code is pointed at it:

@EmbeddedKafka(partitions = 1, topics = "order-placed")
class OrderPlacedEventTest {
  @Test void placingAnOrderProducesOrderPlacedEvent() {
    Order placed = orderService.placeOrder("Ada", "KEYBOARD-001", 2);
    var record = KafkaTestUtils.getSingleRecord(consumer, "order-placed");
    assertThat(record.key()).isEqualTo(placed.getId());
    assertThat(record.value()).contains("\"status\":\"PLACED\"");
  }
}
Enter fullscreen mode Exit fullscreen mode

Meanwhile the full-stack integration tests flip orderhub.events.enabled=false so they never reach for a broker. The order is the source of truth; the event is a best-effort announcement. Day 26 adds the first consumer, and a later transactional outbox makes the publish itself reliable.

Toggle the broker and the publish switch and watch the order succeed either way:
https://dev48v.infy.uk/orderhub.php

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

Top comments (0)