Kafka Streams Part 1: Stream Processing Made Simple
Kafka Streams: From Zero to Production(3-part series)
- P1Kafka Streams Part 1: Stream Processing Made Simple(here)
- P2Kafka Streams Part 2: KStreams, KTables, Joins and Windows
- P3Kafka Streams Part 3: Making It Production-Ready

Imagine a bakery. Every morning it collects all the orders from yesterday, bakes everything at once, and delivers the whole batch at night. That works — but what if a customer wants a croissant right now?
That is the difference between batch processing and stream processing.
| Batch (the old way) | Streaming (the Kafka Streams way) | |
|---|---|---|
| When data is processed | Later, in big chunks | Immediately, one event at a time |
| Reaction time | Hours | Milliseconds |
| Data has an end? | Yes, a file or table finishes | No, it keeps flowing forever |
| Example | A nightly sales report | A fraud alert the second a suspicious card is swiped |
Most tutorials about Kafka Streams drown you in jargon: topologies, serdes, rebalancing, exactly-once semantics... This series takes a different path. We go step by step, in plain words, with code you can actually run. By the end, you will be able to build and run real stream processing applications with confidence.
In this first part, we cover the fundamentals and build our first app together.
What Is Kafka Streams, Really?
Kafka Streams (also called Kafka Streams DSL) is a Java library for processing data that flows through Apache Kafka.
That sentence has three important words. Let's unpack them:
- Java library — It is just a dependency you add to your project. Like Jackson or Guava. You write normal Java code.
- Processing data — It transforms, filters, counts, joins, and enriches records as they arrive.
- Through Kafka — The input and output of your processing are Kafka topics.
Here is the part that surprises most people:
Kafka Streams is not a separate server or cluster. There is nothing extra to install or run. Your application is the stream processor. Add 5 more copies of your app, and your processing power grows. This is very different from Spark or Flink, where you run and manage a separate processing cluster.
Why Not Just Use Plain Kafka Consumers?
Fair question. You can already read from a topic with a plain consumer. So why a library?
A plain consumer hands you raw bytes, one record at a time. Everything else is your problem:
- Want to count events per user? Build your own state store, and somehow recover it after a crash.
- Want to join two topics? Write all that plumbing yourself.
- Want to scale from 1 machine to 6? Good luck splitting partitions and rebalancing safely.
- Want to process every record exactly once, even after failures? That's a research paper worth of work.
Kafka Streams gives you all of that out of the box:
| You need | Plain consumers | Kafka Streams |
|---|---|---|
| Read/write topics | ✅ | ✅ |
| Filter, map, transform | DIY | ✅ Built-in |
| Running state (counts, sums) | DIY (and crash recovery!) | ✅ Automatic |
| Joins between topics | DIY | ✅ Built-in |
| Time windows ("per minute") | DIY | ✅ Built-in |
| Exactly-once processing | DIY (very hard) | ✅ One config line |
| Scaling by adding instances | DIY | ✅ Automatic |
In short: plain consumers give you the raw ingredients. Kafka Streams gives you the whole kitchen.
Kafka in 5 Minutes (The Parts That Matter)
You don't need to be a Kafka expert for this series. But four ideas come up constantly, so let's learn them now.
1. Events
An event is a fact that happened: "Order 123 was placed", "User 42 clicked the buy button". In Kafka, an event is called a record, and it has three parts we care about:
- Key (optional): what the event is about, e.g.
user-42 - Value: the event data itself, e.g.
{"url": "/checkout", "amount": 30} - Timestamp: when it happened
2. Topics
A topic is like a named conveyor belt for one kind of event: orders, clicks, payments. Producers put records on the belt. Consumers read them off.
3. Partitions
Here is where it gets interesting. A topic is split into partitions. Each partition is an ordered, append-only log:
Two golden rules of partitions:
- Records with the same key always land in the same partition. All events for
user-42go to exactly one partition, in order. This is how Kafka keeps per-key order. - A partition is assigned to exactly one consumer in a group. Two consumers never read the same partition. This is how Kafka parallelizes work without you writing a single line of coordination code.
Keep these two rules in your pocket. Almost everything in Kafka Streams — grouping, joining, scaling — is built on them.
More partitions = more parallelism, later. The number of partitions of your input topic is the maximum number of parallel workers your Kafka Streams app can have. We return to this in Part 3.
4. Consumer Groups
Consumers with the same group ID share the work: partitions are divided among them. Add a consumer, and Kafka hands it some partitions. Remove one, and Kafka redistributes. This rebalancing happens automatically.
Your Kafka Streams app joins a consumer group under the hood — you just never see it, because the library manages it. The group ID comes from a config called application.id, which brings us to...
Your First App: Word Count
Time to write real code. We'll build the "hello world" of stream processing: count how many times each word appears, live, as sentences arrive.
The plan: read sentences from a sentences topic, split them into words, keep a running count per word, and write each updated count to a word-counts topic.
Here is the whole journey of one sentence:
That picture has a name: a topology. It is a pipeline of processing steps. You describe it once, and Kafka Streams runs it forever, on any number of machines.
Step 1: The Project
Create a plain Java project (Java 17 or newer) with this dependency:
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-streams</artifactId>
<version>4.0.0</version>
</dependency>That's it. One dependency.
Step 2: The Code
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.kstream.KTable;
import org.apache.kafka.streams.kstream.Produced;
import java.util.Arrays;
import java.util.Properties;
public class WordCountApp {
public static void main(String[] args) {
// --- 1. Configuration ---
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "wordcount-app");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
Serdes.String().getClass());
// --- 2. Describe the pipeline (the topology) ---
StreamsBuilder builder = new StreamsBuilder();
// Read every sentence from the "sentences" topic
KStream<String, String> sentences = builder.stream("sentences");
// Split each sentence into lowercase words
KStream<String, String> words = sentences
.flatMapValues(text -> Arrays.asList(text.toLowerCase().split("\\W+")));
// Make each word the key, then count occurrences per key
KTable<String, Long> counts = words
.groupBy((sentenceKey, word) -> word)
.count();
// Write every updated count to the output topic
counts.toStream()
.to("word-counts", Produced.with(Serdes.String(), Serdes.Long()));
// --- 3. Start the app ---
KafkaStreams streams = new KafkaStreams(builder.build(), props);
// Stop cleanly when the JVM shuts down (Ctrl+C)
Runtime.getRuntime().addShutdownHook(new Thread(streams::close));
streams.start();
System.out.println("Word count app started. Try me!");
}
}Not bad, right? That is a complete, fault-tolerant, scalable, real-time application. Let's walk through the interesting parts.
The Config, Explained
| Config | What it does |
|---|---|
application.id | Names your app. It becomes the consumer group ID and the prefix for internal topics. Choose it carefully — changing it later is like moving to a new house without telling anyone. |
bootstrap.servers | Address of your Kafka cluster. One broker is enough to start; the client discovers the rest. |
DEFAULT_KEY_SERDE_CLASS_CONFIG | How to turn keys into bytes and back. More on this below. |
DEFAULT_VALUE_SERDE_CLASS_CONFIG | Same, for values. |
What's a serde? Kafka stores bytes, not objects. A serde (serializer + deserializer) converts your objects to bytes going in, and back to objects coming out. For now, Serdes.String() is all we need. In Part 3 we build custom JSON serdes for our own classes.
The Pipeline, Line by Line
builder.stream("sentences") — This is a source: it reads from a topic and gives you a KStream, which is a stream of key-value records. Think of it as an endless list of (null, "Kafka is fast") entries — no key, just sentences as values.
flatMapValues(...) — Turns each value into many values. One sentence in, several words out. "Kafka is fast" becomes kafka, is, fast. This is your everyday flatMap from Java streams — it just runs on an infinite stream instead of a list.
groupBy((sentenceKey, word) -> word) — Re-keys every record so the word is the new key. Why? Because of our golden rule: same key → same partition. Grouping by word guarantees all kafka records end up together, so one counter can count them all.
count() — Keeps a running count per key. This needs memory of the past (state), and Kafka Streams handles it silently: it stores the counts in a fast local database and backs it up to a hidden Kafka topic so nothing is lost on a crash. This returns a KTable — a constantly updated view of "current count per word". We dig into KTables in Part 2.
counts.toStream().to("word-counts", ...) — KTables update in place, but topics only grow — so we convert the table back to a stream of updates and write each one out. Every time a word's count changes, a fresh record like (kafka, 4) lands in the output topic.
The classic first-app crash. count() produces Long values, but our default value serde serializes Strings. Without Produced.with(Serdes.String(), Serdes.Long()) on the to(...) call, the app dies with a ClassCastException the moment it tries to write its first result. Every Kafka Streams developer hits this once. Now you won't.
Step 3: Run It
Start a local Kafka with Docker (single command, no config files):
docker run -d --name kafka -p 9092:9092 apache/kafka:4.0.0Create the two topics:
docker exec kafka /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server localhost:9092 --create \
--topic sentences --partitions 3
docker exec kafka /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server localhost:9092 --create \
--topic word-counts --partitions 3Run the app from your IDE (or mvn compile exec:java). Then open a console producer and type some sentences:
docker exec -it kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 --topic sentences> kafka streams is fast
> kafka is funFinally, watch the output:
docker exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic word-counts \
--from-beginning --property print.key=true --property key.separator=" -> "You'll see something like:
kafka -> 1
streams -> 1
is -> 1
fast -> 1
kafka -> 2
fun -> 1Notice kafka appears twice — first with 1, then with 2. That's the update behavior of count(): every new occurrence emits the new total. The output topic contains the history of updates, and the latest record per key always holds the current count.
Type another sentence in the producer and watch the counts change instantly. Congratulations — that's stream processing. 🎉
How Scaling Works (A Sneak Peek)
Here's a beautiful property of what we just built. Start one instance of the app, and it handles all 3 partitions. Start three instances with the same application.id, and Kafka assigns one partition to each — triple the throughput, zero code changes:
Each partition-sized piece of work is called a stream task. Tasks are the unit of parallelism: 3 partitions means at most 3 tasks, whether they run as 3 threads on one machine or 3 machines entirely. Kill an instance, and its tasks are handed to survivors automatically.
We go deep on scaling (threads vs instances vs standby replicas) in Part 3.
What's Next?
You now understand what Kafka Streams is, how Kafka topics work under it, and you've built and run a real streaming app.
But we casually used two mysterious types — KStream and KTable — and waved our hands at "state". In Part 2, we make them crystal clear, and they unlock the truly powerful stuff:
- KStream vs KTable: the diary vs the whiteboard (the single most important concept in Kafka Streams)
- Stateful operations:
count,aggregate,reduce - Joining streams together — like SQL, but live
- Windowing: how to count "per minute" on a stream that never ends
Continue to Part 2: KStreams, KTables, Joins and Windows →
Key Takeaways from Part 1:
- Stream processing handles events one at a time, immediately — batch's opposite
- Kafka Streams is a library inside your app, not a separate cluster to run
- Same key → same partition → per-key order; one partition → one consumer → parallelism without coordination code
- You describe a topology (a pipeline of steps); the library runs it forever, anywhere
application.idnames your app, its consumer group, and its internal topics — don't change it casuallycount()outputsLong— passProduced.with(..., Serdes.Long())or you'll meet aClassCastException- Partitions of the input topic cap your parallelism: more partitions, more possible tasks
Tags
Related Posts

Kafka Streams Part 3: Making It Production-Ready
Take Kafka Streams to production — custom Serdes, event time, exactly-once processing, error handling with dead-letter queues, scaling, testing, and the mistakes everyone makes.

Kafka Streams Part 2: KStreams, KTables, Joins and Windows
Master the two core abstractions of Kafka Streams — KStream and KTable — then learn stateful operations, joining streams like SQL, and windowing time on data that never ends.

Kafka Streams with Spring Boot: Real-Time Pipelines the Spring Way
Build a production-shaped streaming app with Spring Boot and Spring Kafka — REST input, JSON events, running aggregates, and windowed fraud alerts, with Spring managing all the plumbing.