A comprehensive guide to Kafka Streams operations with practical examples using Spring Boot 3.x and Java 17.
- Java: 17
- Spring Boot: 3.5.10
- Spring Kafka: Latest (managed by Spring Boot parent)
- Kafka Streams: Latest (managed by Spring Boot parent)
- Build Tool: Maven
- Java 17+
- Docker & Docker Compose (for Kafka)
- Maven 3.6+
docker-compose up -dmvn clean install
mvn spring-boot:runKafka Streams provides a rich set of operations for processing event streams. This guide covers the most commonly used stateless and stateful operations.
Filters records based on a predicate. Only records where the condition returns true pass through.
- Remove records that don't meet criteria
- Eliminate invalid or unwanted messages
stream
.filter((key, stock) -> stock.stockAmount() > 25000)
.peek((key, stock) -> logger.warn("filter --- ⚠️ ⏰ STOCK ALERT for {}", stock));- Filters stocks where the amount is greater than ₹25,000
- Only matching records are passed downstream
- Example: Alert when stock value exceeds threshold
Inverse of filter(). Filters records where the predicate returns false.
- Exclude specific conditions
- Opposite logic of filter()
stream
.filterNot((key, stock) -> stock.stockAmount() < 10000)
.peek((key, stock) -> logger.warn("filterNot --- ⚠️ NORMAL STOCK for {}", stock));- Keeps stocks where amount is NOT less than ₹10,000
- Equivalent to:
stock.stockAmount() >= 10000 - Use case: Process only normal/significant transactions
Transforms both key and value into a new key-value pair.
- Restructure data with new keys and values
- Change data type or format
- Enrichment operations
stream.map((key, stock) ->
KeyValue.pair(stock.userID(), "user spent amount : " + stock.stockAmount())
).peek((key, value) ->
logger.info("map --- User Stocks Summary: Key: {}, Value: {}", key, value)
);- Transforms stock records to user-focused summaries
- New key:
userID(from stock data) - New value: String representation of spending
- Use case: Convert stock IDs to user IDs for user-centric analytics
Transforms only the value, keeping the key unchanged.
- Transform values while preserving keys
- Lighter than map() when key doesn't change
- Type conversions
stream.mapValues(stock ->
"Stocks of ₹" + stock.amount() + " by user " + stock.userId()
).peek((key, stock) ->
logger.info("mapValues --- User Stocks Summary Value Only: Key: {}, Value: {}", key, stock)
);- Only transforms the value (stock object → string description)
- Keeps original key (stockId)
- More efficient than
map()when key doesn't change - Use case: Format data for display
Transforms each record into zero or more records with potentially different keys.
- Explode one record into multiple records
- Complex transformations
- Unpacking nested data
stream.flatMap((key, stock) -> {
List<KeyValue<String, Item>> result = new ArrayList<>();
for(Item item : stock.items()) {
result.add(KeyValue.pair(stock.stockId(), item));
}
return result;
}).peek((key, item) ->
logger.info("flatMap ---- Item Purchased: Stocks ID: {}, Item: {}", key, item)
);- Takes a stock with multiple items and creates separate records per item
- One stock record → Multiple item records
- Use case: Disaggregate composite records into individual elements
Transforms value into zero or more values, keeping the key unchanged.
- Explode values without changing keys
- Unpacking collections in values
- More efficient than flatMap() when key stays the same
stream.flatMapValues(Stocks::items)
.peek((key, item) ->
logger.info("flatMapValues --- Item Purchased Value Only: Stocks ID: {}, Item: {}", key, item)
);- Extracts items from each stock record
- Key remains stockId
- Simpler than flatMap() with less overhead
- Use case: Disaggregate without key transformation
Splits a stream into multiple streams based on predicates.
- Route records to different streams
- Conditional processing
- Separate handling based on properties
KStream<String, Stocks>[] branch = stream
.branch(
(key, stock) -> stock.countryLocation().equalsIgnoreCase("DE"),
(key, stock) -> stock.countryLocation().equalsIgnoreCase("ES")
);
branch[0].peek((key, stock) ->
logger.info("🇩🇪 GERMANY Stocks: Key: {}, Stocks: {}", key, stock)
).to("stocks-result", Produced.with(Serdes.String(), new StocksSerde()));
branch[1].peek((key, stock) ->
logger.info("🇪🇸 SPAIN Stocks: Key: {}, Stocks: {}", key, stock)
).to("stock_results_ES", Produced.with(Serdes.String(), new StocksSerde()));- First predicate: Filters German stocks → sent to
stocks-resulttopic - Second predicate: Filters Spanish stocks → sent to
stock_results_EStopic - Use case: Geographic routing, different processing by region
Groups records by a key and counts occurrences. This is a stateful operation.
- Count occurrences of grouped elements
- Aggregation operations
- Requires state store
// Count stocks by location
stream.groupBy((key, stock) -> stock.countryLocation())
.count()
.toStream()
.peek((loc, count) ->
logger.info("🌍 Location {} has {} stocks", loc, count)
);
// Count stocks by user
stream.groupBy((key, stock) -> stock.userID())
.count(Materialized.as("user-stock-count-store"))
.toStream()
.peek((userId, count) ->
logger.info("👥 User {} made {} stocks", userId, count)
);- Groups stocks by country location / user ID
- Counts the number of records in each group
Materialized.as()creates a named state store for querying- Use case: Analytics - count stocks per location/user
- Kafka Streams maintains internal state for counting
- Data is persisted in local RocksDB
- Can be queried via interactive queries
Aggregates grouped records into a running result. This is a stateful operation.
- Complex aggregations (sum, average, etc.)
- Running calculations
- Maintaining aggregated state
stream.groupBy((key, stock) -> stock.sector())
.aggregate(
() -> 0.0, // Initializer: starting value
(type, stock, currentSum) -> currentSum + stock.stockAmount(), // Aggregator
Materialized.with(Serdes.String(), Serdes.Double())
).toStream()
.peek((type, total) ->
logger.info("Stock Sector: {} | 💰 Running Total Amount: {}", type, total)
);Three components of aggregate():
-
Initializer
() -> 0.0- Starting value for each group
- Called once per unique key
-
Aggregator
(type, stock, currentSum) -> currentSum + stock.stockAmount()- Process each record
type: grouped key (sector)stock: current record (stock object)currentSum: accumulated value (previous sum)- Returns: new accumulated value
-
Materialized
Materialized.with(Serdes.String(), Serdes.Double())- Defines key/value serialization
- Creates state store for persistence
- Sector-wise total stock amounts
- Running total per category
- Complex stateful calculations
| Operation | Stateful? | Input | Output | Common Use Case |
|---|---|---|---|---|
filter() |
No | 1 record | 0 or 1 record | Remove unwanted records |
filterNot() |
No | 1 record | 0 or 1 record | Inverse filtering |
map() |
No | 1 record | 1 record | Transform key & value |
mapValues() |
No | 1 record | 1 record | Transform value only |
flatMap() |
No | 1 record | 0+ records | Explode records |
flatMapValues() |
No | 1 record | 0+ records | Explode values |
branch() |
No | 1 record | Split streams | Route by condition |
groupBy() |
Yes | Multiple | Grouped stream | Preparation for aggregation |
count() |
Yes | Group | Count | Count per group |
aggregate() |
Yes | Group | Aggregated value | Sum, average, custom agg |
View src/main/resources/application.yml for Kafka configuration:
spring:
kafka:
bootstrap-servers: localhost:9092
streams:
application-id: stocks-streams
state-dir: kafka-stream-logssrc/main/java/com/kodebytes/acasado/
├── controller/
│ └── StockController.java # REST endpoint to publish stocks
├── events/
│ └── Stocks.java # Domain model
├── streams/
│ └── StockStatelessStream.java # Stream processing operations
├── serdes/
│ └── StocksSerde.java # Custom serialization
└── config/
└── KafkaConfig.java # Kafka configuration
curl -X POST http://localhost:8080/api/stocks/publishLogs will show the operations being executed:
🌍 Location Germany has 5 stocks
👥 User user123 made 3 stocks
💰 Running Total Amount for Tech: 150000.50
⚠️ STOCK ALERT for stock with amount 35000
filter(),map(),mapValues(),flatMap(),flatMapValues(),branch()- No state store required
- Fast and memory efficient
- Process one record at a time
groupBy(),count(),aggregate()- Require state store (RocksDB)
- Can query current state
- Maintain running calculations
Use custom SerDes for domain objects:
stream = builder.stream("stocks",
Consumed.with(Serdes.String(), new StocksSerde()));- Use peek() for debugging - Add logging without transforming data
- Name your state stores - Makes debugging and monitoring easier
- Handle errors gracefully - Use exception handlers in production
- Consider topology optimization - Reduce state store footprint
- Test with realistic data volumes - Performance varies with scale
- Monitor state store size - Watch RocksDB disk usage
This project is for educational purposes.
Happy Streaming! 🎉