Dev.to (Java)~4 min read·Jul 16, 2026
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.
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());
}
}
The eventId is distinct from the order id — it identifies this specific emission, which idempotent consumers will later use to detect a redelivery.
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);
}
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.
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}
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.
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);
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());
}
}
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.
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\"");
}
}
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