Spring Kafka Integration
Spring for Apache Kafka in production: KafkaTemplate, @KafkaListener containers, JSON serialisation without trusting the wire, DefaultErrorHandler with backoff, and dead-letter topics.
On this page
Spring for Apache Kafka wraps the Java client in the same idioms as the rest of Spring — a template for producing, an annotation for consuming, and a container managing the polling loop. The defaults are reasonable; the ones that are not are the subject of this guide.
Key Takeaways
- Set
AckMode.MANUAL_IMMEDIATEorRECORD— auto-commit loses records on a crash. - Always wrap deserialisers in
ErrorHandlingDeserializer, or one bad record halts the partition forever. - Never enable
trusted.packages=*— it is a remote code execution vector. DefaultErrorHandler+DeadLetterPublishingRecoverergives you retry and DLT in three lines.- Use
@RetryableTopicfor non-blocking retries with long backoffs.
Configuration
spring:
kafka:
bootstrap-servers: 'kafka-1:9092,kafka-2:9092,kafka-3:9092'
producer:
acks: all
properties:
enable.idempotence: true
max.in.flight.requests.per.connection: 5
linger.ms: 10
compression.type: zstd
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
consumer:
group-id: billing
# Off. Auto-commit advances offsets on a timer regardless of whether
# processing succeeded, which silently loses records on a crash.
enable-auto-commit: false
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
# The wrapper turns a deserialisation failure into a null payload the
# error handler can route to the DLT, instead of an infinite loop.
value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
properties:
spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
# An allowlist. '*' means any class named in a message header gets
# instantiated — a remote code execution primitive.
spring.json.trusted.packages: 'com.acme.events'
spring.json.use.type.headers: false
spring.json.value.default.type: com.acme.events.OrderEvent
listener:
ack-mode: MANUAL_IMMEDIATE
concurrency: 3
poll-timeout: 3000The trusted.packages setting deserves the warning. JsonDeserializer reads a type header from the
message and instantiates that class. With *, anyone who can publish to your topic chooses which
class your JVM constructs, which is the classic deserialisation attack. Pin it to your own packages,
or disable type headers entirely and set a default type as above.
Two producer settings above work together in a way worth understanding rather than copying.
enable.idempotence has the broker deduplicate retries using a producer id and sequence number, which
turns a retried send from a probable duplicate into a no-op. It is also what makes
max.in.flight.requests.per.connection: 5 safe: without idempotence, five concurrent in-flight batches
can be reordered when one of them is retried, so preserving per-key order would force that value down
to 1 and surrender most of the throughput. With it on, the broker restores the order and you keep both
properties.
On the consumer side, the setting behind the most confusing production incident is
max.poll.interval.ms, which defaults to five minutes. If handling a batch takes longer than that, the
broker decides the consumer is dead and rebalances its partitions elsewhere — while the original
consumer is still happily processing them. The symptoms are duplicated work and a consumer group that
never settles, neither of which points at the actual cause. Either lower max.poll.records so a batch
finishes well inside the interval, or raise the interval to cover your slowest realistic batch.
Producing
@Component
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> template;
public void publish(OrderEvent event) {
var record = new ProducerRecord<String, OrderEvent>("orders", event.orderId(), event);
record.headers().add("eventId", event.eventId().getBytes(UTF_8));
template.send(record).whenComplete((result, ex) -> {
if (ex != null) {
// The send failed after retries. The event is lost unless it is
// in an outbox — which is the argument for using one.
log.error("publish failed for {}", event.orderId(), ex);
metrics.counter("kafka.publish.failed").increment();
}
});
}
}send is asynchronous and returns a future. Ignoring it means publish failures disappear silently —
the single most common Spring Kafka mistake. Either handle the future as above, or block on it if the
caller must know, or write to an outbox and let a relay handle delivery.
Consuming
@Component
public class OrderEventListener {
private final ProcessedEventRepository processed;
private final BillingService billing;
@KafkaListener(
topics = "orders",
groupId = "billing",
containerFactory = "kafkaListenerContainerFactory")
@Transactional
public void onOrderEvent(
@Payload OrderEvent event,
@Header(KafkaHeaders.RECEIVED_KEY) String key,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset,
@Header(value = "eventId", required = false) String eventId,
Acknowledgment ack) {
MDC.put("correlationId", eventId);
try {
// At-least-once delivery guarantees duplicates eventually. The
// unique constraint makes the check atomic; a SELECT-then-INSERT
// has a race two concurrent consumers will find.
processed.save(new ProcessedEvent(eventId, Instant.now()));
} catch (DataIntegrityViolationException duplicate) {
log.debug("event {} already processed", eventId);
ack.acknowledge();
return;
}
billing.handle(event);
// Acknowledge only after successful processing. An exception here
// skips the ack and the record is redelivered.
ack.acknowledge();
MDC.clear();
}
}Error handling and dead-letter topics
@Configuration
public class KafkaErrorConfig {
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template,
// Route to <topic>.DLT, keeping the original partition so
// ordering within a key survives into the DLT.
(record, ex) -> new TopicPartition(record.topic() + ".DLT", record.partition()));
var backoff = new ExponentialBackOffWithMaxRetries(4);
backoff.setInitialInterval(500);
backoff.setMultiplier(2.0);
backoff.setMaxInterval(10_000);
var handler = new DefaultErrorHandler(recoverer, backoff);
// These will never succeed on retry — send them straight to the DLT
// rather than burning four attempts and ten seconds per record.
handler.addNotRetryableExceptions(
DeserializationException.class,
MethodArgumentNotValidException.class,
UnknownEventTypeException.class);
handler.setRetryListeners((record, ex, attempt) ->
log.warn("retry {} for {}-{}@{}", attempt,
record.topic(), record.partition(), record.offset(), ex));
return handler;
}
}The recoverer copies the original topic, partition, offset, exception message and stack trace into headers on the DLT record. That is what makes the DLT diagnosable — without it you have a payload and no idea why it failed.
Alert on DLT depth. A dead-letter topic nobody watches is a silent data-loss mechanism, because records arrive there precisely when something needs human attention.
Non-blocking retries
The blocking retry above pauses the partition for the whole backoff. With a four-attempt exponential backoff that is over ten seconds during which no later record in that partition is processed — fine for a brief blip, unacceptable for a downstream outage lasting minutes.
@RetryableTopic(
attempts = "4",
backoff = @Backoff(delay = 1_000, multiplier = 3.0),
dltStrategy = DltStrategy.FAIL_ON_ERROR,
topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE,
exclude = { DeserializationException.class })
@KafkaListener(topics = "orders", groupId = "billing")
public void onOrderEvent(OrderEvent event) {
billing.handle(event);
}
@DltHandler
public void onDlt(OrderEvent event, @Header(KafkaHeaders.ORIGINAL_TOPIC) String topic) {
log.error("giving up on {} from {}", event.orderId(), topic);
alerting.raise("kafka-dlt", event.orderId());
}Spring creates orders-retry-0, orders-retry-1 and orders-dlt. A failed record is republished to
the next retry topic with a delay, and the main partition carries on immediately. The trade-off is
that per-key ordering is lost for retried records — the retry lands after records that came behind it.
Where ordering is essential, blocking retries are the correct choice despite the cost.
Batch consumption
@KafkaListener(topics = "metrics", batch = "true")
public void onBatch(List<ConsumerRecord<String, MetricEvent>> records, Acknowledgment ack) {
// One database round trip for 500 records instead of 500 round trips.
repository.saveAll(records.stream().map(r -> toEntity(r.value())).toList());
ack.acknowledge();
}Batching transforms throughput for write-heavy consumers. The cost is granularity: one bad record in
a batch of 500 fails the whole batch, so pair it with BatchListenerFailedException naming the index
of the offending record, which lets the error handler send just that one to the DLT.
Testing
@SpringBootTest
@Testcontainers
class OrderEventListenerTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:3.8.0"));
@Autowired KafkaTemplate<String, OrderEvent> template;
@Autowired BillingRepository repository;
@Test
void processesEachEventExactlyOnce() {
var event = new OrderEvent("ORD-1", "eventId-1", Money.of(1000, "EUR"));
template.send("orders", event.orderId(), event);
template.send("orders", event.orderId(), event); // deliberate duplicate
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertThat(repository.findByOrderId("ORD-1")).hasSize(1));
}
}Testing against a real broker is the only way to exercise rebalancing, offset commits and the error
handler. EmbeddedKafka is faster but diverges from real broker behaviour in exactly the areas most
likely to bite.
What to take away
Turn off auto-commit and acknowledge after processing. Wrap deserialisers so a poison record cannot
halt a partition, and never trust arbitrary type headers. Configure DefaultErrorHandler with a
bounded backoff and a dead-letter topic, alert on its depth, and reach for @RetryableTopic when
backoffs get long enough to matter.
Frequently Asked Questions
Why do I get a deserialisation error I cannot recover from?
Should the consumer be transactional?
How do I retry without blocking the partition?
Related tutorials
- Apache Kafka Architecture & Core ConceptsHow Kafka actually works: the partitioned log, leaders and in-sync replicas, producer acks and idempotence, consumer groups and rebalancing, and offset management.
- Kafka Streams & Stream ProcessingStream processing without a cluster: KStream and KTable semantics, stateless and stateful operations, windowing, joins, exactly-once v2 and testing with TopologyTestDriver.
- RabbitMQ Advanced PatternsBeyond basic queues: quorum queues and Raft, clustering and partition handling, federation and shovel for multi-datacentre, priority and lazy queues, and delayed delivery.
- Kafka Connect & Data IntegrationMoving data in and out of Kafka without writing code: source and sink connectors, Debezium change data capture, single message transforms, and running Connect in distributed mode.