java 118 lines · 3 tabs

Idempotent Kafka Consumer With @KafkaListener and Manual Ack in Spring Boot

Shared by codesnips Aug 2026
3 tabs
package com.shop.payments.messaging;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

public record PaymentEvent(
        String eventId,
        String orderId,
        long amountCents,
        String status) {

    @JsonCreator
    public PaymentEvent(
            @JsonProperty("eventId") String eventId,
            @JsonProperty("orderId") String orderId,
            @JsonProperty("amountCents") long amountCents,
            @JsonProperty("status") String status) {
        this.eventId = eventId;
        this.orderId = orderId;
        this.amountCents = amountCents;
        this.status = status;
    }

    public boolean isSuccessful() {
        return "CAPTURED".equalsIgnoreCase(status);
    }
}
3 files · java Explain with highlit

This snippet shows how a Spring Boot service consumes an order-payment event from Kafka and updates aggregate state safely, with manual acknowledgment and idempotency baked in. The three tabs move from wire format, to the listener that binds to a topic, to the transactional service that mutates domain state.

In PaymentEvent, the payload is modeled as an immutable record carrying an eventId, orderId, amountCents, and status. The eventId is the key detail: Kafka guarantees at-least-once delivery, so the same message can arrive more than once after a rebalance or a redelivery. Carrying a stable per-event identifier lets the consumer detect and drop duplicates rather than double-applying a payment. Modeling the message as a record keeps deserialization explicit and the object side-effect free.

In PaymentEventListener, the @KafkaListener annotation binds a method to the order-payments topic under a named groupId, which is what makes horizontal scaling work — partitions are distributed across instances sharing that group. The method receives the deserialized PaymentEvent, the raw Kafka key, and an Acknowledgment handle. Because the container is configured for manual ack (AckMode.MANUAL), the offset is only committed via ack.acknowledge() after the service call returns successfully. If processing throws, the offset is not committed and the record is redelivered, so no event is silently lost. The listener deliberately keeps logic thin: it logs, delegates, and acknowledges.

In PaymentApplicationService, the actual state change runs inside @Transactional. It first calls processedEventRepository.existsByEventId(...) to short-circuit duplicates; combined with a unique constraint on event_id, this gives idempotency even under concurrent redelivery, since a racing duplicate insert fails the transaction and rolls back the state change. On a fresh event it loads the Order, applies the payment via markPaid, and records the eventId so future duplicates are rejected. The trade-off is an extra read and write per message, which is cheap relative to the correctness it buys. Developers reach for this pattern whenever a consumer performs non-idempotent mutations — balances, inventory, status transitions — where at-least-once delivery would otherwise corrupt state. Manual ack plus a dedupe table is the pragmatic combination that keeps throughput high while guaranteeing effectively-once processing.


Related snips

Share this code

Here's the card — post it anywhere.

Idempotent Kafka Consumer With @KafkaListener and Manual Ack in Spring Boot — share card
Link copied