Event-Driven Systems with Kafka — The Java Standard¶
Event-driven architecture inverts the direction of coupling: instead of service A calling service B, A emits a fact (“link created”) and any interested service consumes it. Done well, this makes systems easier to evolve, easier to scale, and easier to debug in retrospect. Done badly, it produces silent data loss and race conditions you cannot reproduce.
Kafka is the default event backbone for Java shops in 2026. This file gives you the operational understanding to use it without shooting yourself.
Kafka Fundamentals in 6 Bullets¶
Topic — an append-only log, retained by time or size.
Partition — a topic is split into N partitions. Ordering is guaranteed within a partition, never across.
Offset — the position of a record inside a partition. Consumers commit offsets to track progress.
Consumer group — a set of consumers that share the partitions of a topic. Each partition is read by exactly one consumer in the group at a time.
Replication factor — each partition has R replicas across brokers. RF=3 is the production standard.
Broker — a Kafka server. A cluster is typically 3, 5, or 7 brokers (odd numbers, for quorum).
⚠️ What most people get wrong: they assume topic-wide ordering. Kafka only guarantees ordering per partition. If ordering across a business key matters (all events for
userId=42in order), you must partition by that key.
Partitioning — The One Decision That Matters¶
The partition key determines both parallelism and ordering. Choose it once, live with it forever (repartitioning is painful).
Scenario |
Partition key |
|---|---|
Per-user event stream where order matters |
|
Per-order lifecycle |
|
Metrics / analytics where order doesn’t matter |
Round-robin (null key) |
Hot key problem (one user = 90% of traffic) |
Composite key or a shard suffix |
Default partition count: start with 12 (num.partitions=12). More partitions = more parallelism but also more open file handles and longer rebalance times. 200 partitions per topic is usually the ceiling before problems.
Spring Kafka — Producer¶
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
spring:
kafka:
bootstrap-servers: kafka-1:9092,kafka-2:9092,kafka-3:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: all # wait for all in-sync replicas
properties:
enable.idempotence: true # exactly-once producer (see below)
max.in.flight.requests.per.connection: 5
retries: 2147483647 # effectively infinite; idempotence prevents duplicates
compression.type: zstd
linger.ms: 20 # small batching window
batch.size: 65536
@Service
@RequiredArgsConstructor
class LinkEventPublisher {
private final KafkaTemplate<String, LinkCreatedEvent> kafka;
public void publishCreated(LinkCreatedEvent event) {
// Partition by userId so all events for one user land on the same partition
kafka.send("link.created.v1", event.userId(), event)
.whenComplete((res, ex) -> {
if (ex != null) log.error("publish failed for {}", event, ex);
else log.debug("published to {}-{} offset {}",
res.getRecordMetadata().topic(),
res.getRecordMetadata().partition(),
res.getRecordMetadata().offset());
});
}
}
Delivery Semantics — Say It Precisely¶
At an study, if you say “exactly once” without qualifying, you’re wrong. The precise picture:
Semantic |
Producer config |
Consumer behavior |
Reality |
|---|---|---|---|
At-most-once |
|
Auto-commit before processing |
Fast, loses data on failure. Rarely wanted. |
At-least-once |
|
Manual commit after processing |
Default. Handler MUST be idempotent. |
Exactly-once (idempotent producer) |
|
Same as above |
Producer won’t duplicate under retry. Consumer still needs idempotent handler. |
Exactly-once transactions (EOS) |
|
|
End-to-end EOS within Kafka, including reads and writes across topics. Higher latency. |
The honest advice: aim for at-least-once + idempotent consumers. Reach for full EOS transactions only when you’re producing derived data from Kafka input to Kafka output (Kafka Streams territory).
Spring Kafka — Consumer¶
spring:
kafka:
consumer:
group-id: link-analytics
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "com.shortly.events"
isolation.level: read_committed
max.poll.records: 100
enable-auto-commit: false # NEVER auto-commit in production
auto-offset-reset: earliest
listener:
ack-mode: manual_immediate
concurrency: 3 # 3 threads = up to 3 partitions in parallel
@Component
@RequiredArgsConstructor
class LinkCreatedListener {
private final AnalyticsService analytics;
@KafkaListener(topics = "link.created.v1", groupId = "link-analytics")
public void onLinkCreated(ConsumerRecord<String, LinkCreatedEvent> rec, Acknowledgment ack) {
try {
analytics.recordCreation(rec.value()); // MUST be idempotent (upsert by eventId)
ack.acknowledge();
} catch (TransientException e) {
// Don't ack; Spring will redeliver based on the retry topic config
throw e;
} catch (PoisonPillException e) {
log.error("poison pill at {}-{}@{}", rec.topic(), rec.partition(), rec.offset(), e);
ack.acknowledge(); // skip; the error handler routes it to DLT
throw e;
}
}
}
Retry Topic + Dead-Letter Topic (DLT)¶
One of the great Spring Kafka features. Failed messages route to topic.retry with backoff, then to topic.DLT after exhaustion.
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<Object,Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template);
var backoff = new ExponentialBackOff(1000L, 2.0);
backoff.setMaxInterval(30_000L);
var handler = new DefaultErrorHandler(recoverer, backoff);
handler.addNotRetryableExceptions(DeserializationException.class,
PoisonPillException.class,
IllegalArgumentException.class);
return handler;
}
Naming convention: link.created.v1 → link.created.v1.retry → link.created.v1.DLT. Everyone knows what they mean.
⚠️ What most people get wrong: they build sophisticated retry logic and forget to monitor the DLT. A DLT with 10 million messages in it is not resilience — it’s a leak. Alert on DLT lag > 0.
Schema — Avro or Protobuf, Not JSON¶
JSON is fine for the first month. As soon as producers and consumers evolve independently, you need a schema registry. Confluent Schema Registry (with Avro or Protobuf) is the standard.
Format |
Pros |
Cons |
|---|---|---|
JSON |
Human readable, zero setup |
No compatibility guarantees, verbose on wire |
Avro |
Compact, strict schema evolution rules (BACKWARD/FORWARD/FULL compatibility) |
Requires registry, slightly steeper Java DX |
Protobuf |
Very compact, familiar to gRPC users, ubiquitous tooling |
Requires registry, nullability semantics are subtle |
Compatibility modes — pick BACKWARD unless you know why not:
BACKWARD: new consumers can read old data (add-only fields with defaults).
FORWARD: old consumers can read new data.
FULL: both.
Kafka Streams — Stateful Processing¶
Kafka Streams is a library (not a cluster) that turns a Java app into a stream processor with state stores backed by internal topics. Use it for:
Aggregations — count clicks per link per hour
Joins — enrich order events with user data from another topic
Windowed operations — rolling averages, sessions
@Bean
KStream<String, LinkCreatedEvent> pipeline(StreamsBuilder builder) {
KStream<String, LinkCreatedEvent> created = builder.stream("link.created.v1");
created
.groupBy((k, v) -> v.userId())
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofSeconds(30)))
.count(Materialized.as("links-per-user-5m"))
.toStream()
.to("link.aggregate.per_user.v1", Produced.with(WindowedSerdes.timeWindowedSerdeFrom(String.class), Serdes.Long()));
return created;
}
For most microservice use cases, plain consumers are enough. Reach for Streams when you have real stream-processing needs — don’t use it as “a nicer consumer.”
Debezium — Change Data Capture¶
Debezium reads the write-ahead log of your database (Postgres, MySQL, MongoDB, SQL Server) and publishes row-level changes as Kafka events. This is the right way to build the “outbox pattern” without a custom polling job.
Two dominant patterns:
Outbox pattern: your service writes to a local
outboxtable in the same transaction as the domain change. Debezium ships the outbox rows to Kafka. Guarantees the event is published if and only if the data change committed.Direct CDC: Debezium ships every row change on every table. Simpler to set up, but couples consumers to your table schema — a refactor breaks them.
Prefer outbox for public events; direct CDC is fine for internal analytics pipelines.
The Alternatives — Honest Take¶
Kafka is the default. But it is not the only tool, and it is over-provisioned for many workloads.
Broker |
Best for |
Avoid when |
|---|---|---|
Kafka |
Event streaming, high throughput, replay, analytics, CDC |
You just need a work queue with 100 msg/s |
RabbitMQ (with Quorum Queues on 3.13+) |
Classic queue + complex routing (topic/fanout/headers), request/reply, RPC |
You need long retention or replay |
Apache Pulsar |
Unified streaming + queuing, geo-replication, multi-tenancy |
Small team; operational complexity is real |
NATS / NATS JetStream |
Low-latency real-time, edge / IoT, service mesh eventing |
Long retention, heavy analytics |
AWS SQS / SNS |
Simple decoupling on AWS, no ops |
Ordering guarantees (only FIFO queues), replay |
Redis Streams |
Small-scale event log, already have Redis |
Anything with multiple consumers at scale |
Community verdict as of 2026:
Kafka remains the default for enterprise Java event streaming.
RabbitMQ, hardened by Quorum Queues, is having a comeback for the boring-good queueing case.
Pulsar is “for the brave” — real strengths, real ops cost.
NATS is winning at the low-latency edge where Kafka’s setup would be overkill.
Operational Sins Checklist¶
enable.auto.commit=truein productionNo monitoring on consumer lag
No monitoring on DLT depth
acks=1because “acks=all was slow” (fix the batch size instead)Single-partition topic that later needs parallel processing
JSON in production without a schema registry
Non-idempotent consumer handler on an at-least-once pipeline
Consumer processing time >
max.poll.interval.ms(default 5min) — silently kicked from the group
Practice¶
Set up a local 3-broker Kafka cluster with
docker-compose+ Kafka UI.Build a producer that emits 1 million
LinkCreatedEventrecords withuserIdas key. Confirm partitioning by inspecting partition sizes.Build a consumer with manual ack, retry topic, and DLT. Poison one message and watch the DLT.
Kill a broker while the pipeline runs. Prove no data loss with
acks=all+ RF=3.
Return to README.md · Previous: 01_microservices_patterns.md · Next: 03_caching_and_state.md