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
<?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>
# 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"]
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
-- 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
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) {
}
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);
}
}
}
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) {
}
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
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:
- Claim the event:
INSERT ... ON CONFLICT DO NOTHINGon the primary keyevent_id. Zero rows means another delivery (or another consumer instance, concurrently) already claimed it → skip. - Do the effect — send the e-mail.
- 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
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);
}
}
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;
}
}
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();
}
}
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
CommonErrorHandlerbean 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
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
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.IllegalArgumentExceptioncurl -s localhost:8025/api/v1/messages | jq '.total'2
What happened, in order:
- The group
notificationis 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. - The two real
OrderPlacedevents (holds0001and0002) 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.