Skip to content

3. Idempotent Consumer and DLT

Kafka and the outbox both deliver at least once: a relay that crashes after the ack republishes a batch; a consumer that crashes after sending the e-mail but before committing its offset receives the message again; an operator who rewinds a consumer group replays hours of events. The notification service must therefore make a duplicate harmless, and it must not let one bad message stop the whole partition.

Problem Technique Where in the code
the same event arrives twice idempotent consumer: remember processed eventIds, in the same transaction as the effect NotificationService
a transient failure (SMTP hiccup) retry with exponential backoff, in place KafkaConfig
a message that can never succeed (malformed, invalid address) dead-letter topic (DLT): park it, keep the partition flowing KafkaConfig, DeadLetterListener

The notification service

A Spring Boot service in services/notification/, port 8086. It also keeps the legacy POST /notifications endpoint that order calls in NOTIFY_MODE=sync, so both architectures can be measured with the same code.

services/notification/
├── .dockerignore
├── Dockerfile
├── pom.xml
└── src/main/
    ├── java/boxoffice/notification/
    │   ├── NotificationApplication.java
    │   ├── NotificationProperties.java
    │   ├── OrderEvent.java
    │   ├── EmailSender.java
    │   ├── NotificationService.java
    │   ├── NotificationController.java
    │   ├── OrderEventListener.java
    │   ├── DeadLetterListener.java
    │   └── KafkaConfig.java
    └── resources/
        ├── application.yaml
        └── db/migration/V1__create_notification.sql

Build files and schema

services/notification/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>notification</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
services/notification/Dockerfile
# syntax=docker/dockerfile:1
# same Dockerfile as catalog (lab 1); only the port changes
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8086
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
services/notification/.dockerignore
target/
.idea/
.vscode/
*.iml
services/notification/src/main/resources/application.yaml
server:
  port: 8086

spring:
  application:
    name: notification
  mvc:
    problemdetails:
      enabled: true
  datasource:
    url: jdbc:postgresql://${DB_HOST:db}:${DB_PORT:5432}/${DB_NAME:boxoffice}?currentSchema=notification
    username: ${DB_USER:boxoffice}
    password: ${DB_PASSWORD:boxoffice}
  flyway:
    schemas: notification
  kafka:
    bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:kafka:9092}
    consumer:
      group-id: notification
      auto-offset-reset: earliest
    listener:
      ack-mode: record
      concurrency: ${NOTIFY_CONCURRENCY:3}
  mail:
    host: ${MAIL_HOST:mailpit}
    port: 1025

boxoffice:
  notification:
    email-latency: ${EMAIL_LATENCY:50ms}
    idempotent: ${NOTIFY_IDEMPOTENT:true}

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      probes:
        enabled: true        # /actuator/health/liveness and /readiness
services/notification/src/main/resources/db/migration/V1__create_notification.sql
-- one row per event already handled: the idempotency memory of this consumer
CREATE TABLE processed_events (
    event_id     UUID        PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- one row per e-mail actually sent (no unique constraint: we want to SEE duplicates)
CREATE TABLE notifications (
    id          BIGSERIAL    PRIMARY KEY,
    event_id    UUID         NOT NULL,
    order_id    UUID         NOT NULL,
    email       VARCHAR(320) NOT NULL,
    kind        VARCHAR(50)  NOT NULL,
    occurred_at TIMESTAMPTZ  NOT NULL,
    sent_at     TIMESTAMPTZ  NOT NULL DEFAULT clock_timestamp()
);

-- poison messages, kept for inspection and replay
CREATE TABLE dead_letters (
    id          BIGSERIAL   PRIMARY KEY,
    topic       TEXT        NOT NULL,
    partition   INT         NOT NULL,
    record_offset BIGINT    NOT NULL,
    record_key  TEXT,
    payload     TEXT,
    reason      TEXT,
    received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

sent_at uses clock_timestamp() (the real time of the insert), not now() (the time the transaction started): with the e-mail sent inside the transaction, now() would hide its latency.

The event, the e-mail and the properties

services/notification/src/main/java/boxoffice/notification/OrderEvent.java
package boxoffice.notification;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

/** The consumer's own view of the event: unknown fields are ignored (tolerant reader). */
public record OrderEvent(
        UUID eventId,
        String eventType,
        UUID orderId,
        String showId,
        String holdId,
        String customerEmail,
        BigDecimal amount,
        Instant occurredAt) {
}
services/notification/src/main/java/boxoffice/notification/EmailSender.java
package boxoffice.notification;

import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;

@Component
public class EmailSender {

    private final JavaMailSender mail;
    private final NotificationProperties props;

    public EmailSender(JavaMailSender mail, NotificationProperties props) {
        this.mail = mail;
        this.props = props;
    }

    public void orderReceived(OrderEvent e) {
        if (e.customerEmail().startsWith("poison")) {
            // a permanent failure: retrying will never help
            throw new IllegalArgumentException("mailbox rejected: " + e.customerEmail());
        }
        simulateProviderLatency();
        SimpleMailMessage m = new SimpleMailMessage();
        m.setFrom("tickets@boxoffice.local");
        m.setTo(e.customerEmail());
        m.setSubject("BoxOffice: we received your order " + e.orderId());
        m.setText("Order " + e.orderId() + " for show " + e.showId() + " (R$ " + e.amount()
                + ") is waiting for payment.");
        mail.send(m);
    }

    /** A real e-mail API takes tens to hundreds of ms per call; Mailpit takes ~1 ms. */
    private void simulateProviderLatency() {
        try {
            Thread.sleep(props.emailLatency());
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(ex);
        }
    }
}
services/notification/src/main/java/boxoffice/notification/NotificationProperties.java
package boxoffice.notification;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "boxoffice.notification")
public record NotificationProperties(
        @DefaultValue("50ms") Duration emailLatency,
        @DefaultValue("true") boolean idempotent) {
}
services/notification/src/main/java/boxoffice/notification/NotificationApplication.java
package boxoffice.notification;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class NotificationApplication {

    public static void main(String[] args) {
        SpringApplication.run(NotificationApplication.class, args);
    }
}

Idempotency: dedupe in the same transaction as the effect

services/notification/src/main/java/boxoffice/notification/NotificationService.java
package boxoffice.notification;

import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class NotificationService {

    private static final Logger log = LoggerFactory.getLogger(NotificationService.class);

    private final JdbcClient jdbc;
    private final EmailSender email;
    private final NotificationProperties props;

    public NotificationService(JdbcClient jdbc, EmailSender email, NotificationProperties props) {
        this.jdbc = jdbc;
        this.email = email;
        this.props = props;
    }

    /** @return true if an e-mail was sent, false if the event was a duplicate */
    @Transactional
    public boolean handle(OrderEvent e) {
        if (props.idempotent()) {
            int fresh = jdbc.sql("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING")
                    .param(e.eventId())
                    .update();
            if (fresh == 0) {
                log.info("duplicate event {} for order {} ignored", e.eventId(), e.orderId());
                return false;
            }
        }
        email.orderReceived(e); // throws -> the whole transaction rolls back, the dedupe row too
        jdbc.sql("""
                INSERT INTO notifications (event_id, order_id, email, kind, occurred_at)
                VALUES (?, ?, ?, ?, ?)
                """)
                .params(e.eventId(), e.orderId(), e.customerEmail(), e.eventType(),
                        OffsetDateTime.ofInstant(e.occurredAt(), ZoneOffset.UTC))
                .update();
        return true;
    }
}

The order of operations is the whole point:

  1. Claim the event: INSERT ... ON CONFLICT DO NOTHING on the primary key event_id. Zero rows means another delivery (or another consumer instance, concurrently) already claimed it → skip.
  2. Do the effect — send the e-mail.
  3. Commit. If step 2 throws, the claim is rolled back and the retry can try again.

One window remains: the e-mail is sent and then the commit fails (database down at that exact instant). The redelivery will send a second e-mail. No consumer can make an external side effect exactly-once on its own; the complete fix is an idempotency key accepted by the provider (most e-mail and payment APIs take one — pass the eventId). Exactly-once processing inside Kafka (transactions, read_committed) covers Kafka-to-Kafka pipelines, not e-mails.

In the interview

"Kafka gives at-least-once; I make consumers idempotent with a dedupe table keyed by event id, written in the same transaction as the effect, and pass the event id as the idempotency key to external providers." That sentence answers most "what about duplicates?" follow-ups.

Listeners, retries and the dead-letter topic

services/notification/src/main/java/boxoffice/notification/OrderEventListener.java
package boxoffice.notification;

import tools.jackson.databind.ObjectMapper;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class OrderEventListener {

    private final ObjectMapper mapper;
    private final NotificationService notifications;

    public OrderEventListener(ObjectMapper mapper, NotificationService notifications) {
        this.mapper = mapper;
        this.notifications = notifications;
    }

    @KafkaListener(topics = "order-events", groupId = "notification")
    public void onOrderEvent(ConsumerRecord<String, String> record) {
        OrderEvent event = mapper.readValue(record.value(), OrderEvent.class);
        if (event.eventId() == null || event.eventType() == null) {
            throw new IllegalArgumentException("not a BoxOffice event: " + record.value());
        }
        if (!"OrderPlaced".equals(event.eventType())) {
            return; // other event types are not for this consumer (yet)
        }
        notifications.handle(event);
    }
}
services/notification/src/main/java/boxoffice/notification/KafkaConfig.java
package boxoffice.notification;

import tools.jackson.core.JacksonException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;

@Configuration
public class KafkaConfig {

    /** Picked up by Spring Boot's listener container factory (one CommonErrorHandler bean). */
    @Bean
    DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) {
        // after the retries, publish to <topic>-dlt, same partition, with the exception in headers
        DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template);

        ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
        backOff.setInitialInterval(500L);  // 0.5 s, 1 s, 2 s
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(5_000L);

        DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
        // permanent failures: retrying is pointless, go straight to the DLT
        handler.addNotRetryableExceptions(JacksonException.class, IllegalArgumentException.class);
        return handler;
    }
}
services/notification/src/main/java/boxoffice/notification/DeadLetterListener.java
package boxoffice.notification;

import java.nio.charset.StandardCharsets;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.common.header.Header;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.stereotype.Component;

@Component
public class DeadLetterListener {

    private static final Logger log = LoggerFactory.getLogger(DeadLetterListener.class);

    private final JdbcClient jdbc;

    public DeadLetterListener(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @KafkaListener(topics = "order-events-dlt", groupId = "notification-dlt")
    public void onDeadLetter(ConsumerRecord<String, String> record) {
        // the listener's exception is wrapped: the CAUSE class says what really failed
        Header cause = record.headers().lastHeader(KafkaHeaders.DLT_EXCEPTION_CAUSE_FQCN);
        String why = cause == null ? "?" : new String(cause.value(), StandardCharsets.UTF_8);
        log.warn("dead letter at {}-{}@{}: {}", record.topic(), record.partition(), record.offset(), why);
        jdbc.sql("""
                INSERT INTO dead_letters (topic, partition, record_offset, record_key, payload, reason)
                VALUES (?, ?, ?, ?, ?, ?)
                """)
                .params(record.topic(), record.partition(), record.offset(), record.key(), record.value(), why)
                .update();
    }
}
services/notification/src/main/java/boxoffice/notification/NotificationController.java
package boxoffice.notification;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

/** The synchronous "before" path, used by order in NOTIFY_MODE=sync. */
@RestController
public class NotificationController {

    private final NotificationService notifications;

    public NotificationController(NotificationService notifications) {
        this.notifications = notifications;
    }

    @PostMapping("/notifications")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void send(@RequestBody OrderEvent event) {
        notifications.handle(event);
    }
}

How a failing record flows:

sequenceDiagram
    participant K as order-events (partition 3)
    participant L as listener
    participant H as DefaultErrorHandler
    participant D as order-events-dlt (partition 3)
    K->>L: offset 41
    L--xH: MailSendException (transient)
    H->>L: retry after 0.5 s
    L--xH: fails again
    H->>L: retry after 1 s, then 2 s
    L--xH: still failing
    H->>D: publish the record + exception headers
    H->>K: commit offset 42, move on
    K->>L: offset 42 (the partition flows again)
  • Retries happen in place, blocking only that partition for at most 0.5 + 1 + 2 = 3.5 s; the other partitions keep flowing on the other consumer threads.
  • Non-retryable exceptions skip the backoff: a malformed JSON or a rejected address goes to the DLT immediately.
  • Spring Boot plugs the single CommonErrorHandler bean into its listener container factory; no extra wiring is needed.

Blocking vs non-blocking retries

Spring Kafka also offers @RetryableTopic: failed records go to order-events-retry-0, -retry-1… topics with delays, so the main partition is never blocked. The price is more topics and no ordering between a retried event and later events of the same key. For e-mails, blocking a few seconds is fine; for high-volume streams, non-blocking retries are common.

Add notification to Compose and test

docker-compose.yaml (excerpt)
  notification:
    build: ./services/notification
    environment:
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: ${DB_NAME:-boxoffice}
      DB_USER: ${DB_USER:-boxoffice}
      DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
      KAFKA_BOOTSTRAP_SERVERS: kafka:9092
      MAIL_HOST: mailpit
      NOTIFY_CONCURRENCY: ${NOTIFY_CONCURRENCY:-3}   # consumer threads (max useful: 6 partitions)
      EMAIL_LATENCY: ${EMAIL_LATENCY:-50ms}          # simulated e-mail provider latency
      NOTIFY_IDEMPOTENT: ${NOTIFY_IDEMPOTENT:-true}  # false = no dedupe (to see duplicates)
    ports:
      - "8086:8086"
    depends_on:
      db:
        condition: service_healthy
      mailpit:
        condition: service_started
      kafka-init:
        condition: service_completed_successfully
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 768M
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:8086/actuator/health/readiness || exit 1"]
      interval: 10s
      timeout: 3s
      start_period: 40s
      retries: 3
docker compose up -d --build --wait notificationdocker compose logs notification | grep -i "dead letter"notification-1 | ... b.notification.DeadLetterListener : dead letter at order-events-dlt-4@0: java.lang.IllegalArgumentException
notification-1 | ... b.notification.DeadLetterListener : dead letter at order-events-dlt-3@0: java.lang.IllegalArgumentException
notification-1 | ... b.notification.DeadLetterListener : dead letter at order-events-dlt-4@1: java.lang.IllegalArgumentException
curl -s localhost:8025/api/v1/messages | jq '.total'2

What happened, in order:

  1. The group notification is new, so it starts at the earliest offset and meets the three tour messages from step 1 first: they are not BoxOffice events → IllegalArgumentException → straight to the DLT, without blocking the real events behind them.
  2. The two real OrderPlaced events (holds 0001 and 0002) produce two e-mails in Mailpit (http://localhost:8025).

Experiment: replay without duplicates

Rewind the whole group and let it consume everything again:

docker compose stop notification
docker compose exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server kafka:9092 \
  --group notification --topic order-events --reset-offsets --to-earliest --execute
docker compose start --wait notification
docker compose logs notification | grep duplicate
curl -s localhost:8025/api/v1/messages | jq '.total'

The logs show duplicate event ... ignored for each order and Mailpit still has 2 e-mails. Run NOTIFY_IDEMPOTENT=false docker compose up -d --wait notification, repeat the first three commands and count again: every replay sends every e-mail again. Turn the dedupe back on with docker compose up -d --wait notification before you go on.

Experiment: a poison order

curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \
  -d '{"showId":"show-42","holdId":"hold-0003","customerEmail":"poison@example.com","amount":350.00}'
docker compose exec db psql -U boxoffice -d boxoffice \
  -c "SELECT topic, partition, record_offset, reason FROM notification.dead_letters;"

The order succeeds (the fan is not affected), the e-mail fails permanently, and the record lands in order-events-dlt: after a second or two, the query's last row is the poison order (the rows before it are the tour messages, once per pass of the group over the topic); open it in Kafka UI and look at the kafka_dlt-* headers (original topic, partition, offset, exception).

A DLT nobody reads is a silent data loss

Every dead letter is a customer who did not get an e-mail. In production: alert on DLT growth (lab 8), fix the cause, then replay the records back to order-events (a small tool or kafka-console-consumer | kafka-console-producer with the original keys). Replay is safe precisely because the consumer is idempotent.

Next: Waiting room