Add the kafka-basics module

This commit is contained in:
2026-08-29 09:47:25 +05:30
commit 3a682e496e
28 changed files with 1403 additions and 0 deletions
@@ -0,0 +1,20 @@
package com.ankurm.kafkabasics;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* An order-events pipeline, small enough to read in one sitting.
*
* <p>Everything the companion article claims was produced by the tests in {@code src/test},
* against a real Kafka broker running in KRaft mode. See the module README for how to point it
* at a Testcontainers broker instead.
*/
@SpringBootApplication
public class KafkaBasicsApplication {
public static void main(String[] args) {
SpringApplication.run(KafkaBasicsApplication.class, args);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.kafkabasics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* The consuming side. Note what is NOT here: no polling loop, no offset commit, no rebalance
* listener. The container owns all of that, which is the actual value Spring Kafka adds.
*
* @see <a href="../../../../../docs/05-consuming.md">docs/05-consuming.md</a>
*/
@Component
public class OrderConsumer {
private static final Logger log = LoggerFactory.getLogger(OrderConsumer.class);
private final List<Received> received = new CopyOnWriteArrayList<>();
/** What arrived, with the metadata the tests assert on. */
public record Received(OrderEvent event, int partition, long offset, String key) {
}
@KafkaListener(topics = OrderProducer.TOPIC, groupId = "orders-basic")
public void onOrder(@Payload OrderEvent event,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset,
@Header(name = KafkaHeaders.RECEIVED_KEY, required = false) String key) {
log.info("received orderId={} partition={} offset={}", event.orderId(), partition, offset);
this.received.add(new Received(event, partition, offset, key));
}
public List<Received> received() {
return this.received;
}
public void clear() {
this.received.clear();
}
}
@@ -0,0 +1,22 @@
package com.ankurm.kafkabasics;
import java.math.BigDecimal;
import java.time.Instant;
/**
* The payload. A record works as a Kafka value with no annotations at all, because Jackson 3
* handles records natively &mdash; but note that it needs a canonical constructor the
* deserializer can call, which is the one thing a record gives you for free and a Lombok
* {@code @Builder}-only class does not.
*
* @param orderId the partition key. Same customer, same order, same partition, same order of
* delivery &mdash; see docs/04-keys-and-partitions.md
*/
public record OrderEvent(String orderId, String customerId, BigDecimal amount, Instant placedAt) {
public static OrderEvent of(String orderId, String customerId, String amount) {
return new OrderEvent(orderId, customerId, new BigDecimal(amount),
Instant.parse("2026-08-29T10:15:30Z"));
}
}
@@ -0,0 +1,66 @@
package com.ankurm.kafkabasics;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Component;
import java.util.concurrent.CompletableFuture;
/**
* Three ways to send, and only two of them tell you when the send failed.
*
* @see <a href="../../../../../docs/02-producing.md">docs/02-producing.md</a>
*/
@Component
public class OrderProducer {
private static final Logger log = LoggerFactory.getLogger(OrderProducer.class);
static final String TOPIC = "orders";
private final KafkaTemplate<String, OrderEvent> template;
public OrderProducer(KafkaTemplate<String, OrderEvent> template) {
this.template = template;
}
/**
* Fire and forget. {@code send} returns a {@code CompletableFuture} and this method throws
* it away, so a broker-side rejection is invisible here: the method returns normally, the
* message never lands, and nothing in your logs says so unless you have the producer's own
* logger turned up. This is the single most common way to lose messages in Spring Kafka.
*/
public void sendAndForget(OrderEvent event) {
this.template.send(TOPIC, event.orderId(), event);
}
/** Asynchronous, but the outcome is handled. This is the shape you want by default. */
public CompletableFuture<SendResult<String, OrderEvent>> send(OrderEvent event) {
CompletableFuture<SendResult<String, OrderEvent>> future =
this.template.send(TOPIC, event.orderId(), event);
future.whenComplete((result, ex) -> {
if (ex != null) {
log.error("send failed for orderId={}", event.orderId(), ex);
}
else {
log.info("sent orderId={} to {}-{}@{}", event.orderId(),
result.getRecordMetadata().topic(), result.getRecordMetadata().partition(),
result.getRecordMetadata().offset());
}
});
return future;
}
/**
* Synchronous. Correct when the caller must not proceed unless the write is durable, and
* expensive for exactly that reason: it blocks a thread for a network round trip plus the
* replication acknowledgement.
*/
public SendResult<String, OrderEvent> sendAndWait(OrderEvent event) throws Exception {
return this.template.send(new ProducerRecord<>(TOPIC, event.orderId(), event)).get();
}
}