A comprehensive Spring Boot application demonstrating Kafka Streams joins and aggregations with real-world stock market data scenarios. This project showcases how to enrich event streams by joining multiple data sources and performing stream aggregations.
- Overview
- Architecture
- Key Concepts
- Project Structure
- Data Models
- Stream Processing
- Getting Started
- API Endpoints
- Running the Application
- Understanding the Joins
- Understanding the Aggregates
- Sample Data
This application processes stock trading events and enriches them with user profile and alert information using Kafka Streams joins. It demonstrates:
- Left Joins: Enriching stock events with user profiles and alerts
- KStream and KTable Operations: Real-time stream processing with reference data
- Stream Aggregations: Computing running totals by sector
- Custom Serdes: JSON serialization/deserialization for domain objects
- Spring Boot Integration: Seamless Kafka configuration with Spring Cloud Stream
The use case: As stocks flow through the system, they are joined with matching user profiles and alerts to produce enriched stock events containing user risk tolerance, account type, and alert information.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Stock Enrichment Pipeline β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Stocks Topic UserProfiles Topic UserAlerts Topic β
β β β β β
β ββββββββββββββββββββββββΌββββββββββββββββββββββ β
β β β
β Re-key by userID β
β β β
β βββββββββββΌββββββββββ β
β β KStream (byUser)β β
β ββββββββββ¬βββββββββββ β
β β β
β Left Join #1 (User Profile) β
β β β
β ββββββββββΌβββββββββββ β
β β EnrichedStock β (Risk + Account) β
β ββββββββββ¬βββββββββββ β
β β β
β Left Join #2 (User Alert) β
β β β
β ββββββββββΌβββββββββββββββ β
β β EnrichedStock β (with AlertType) β
β ββββββββββ¬βββββββββββββββ β
β β β
β ββββββββββββ΄βββββββββββ β
β β β β
β Output to Group by Sector β
β Enriched Stocks (Aggregate) β
β β β β
β β Sector Totals β
β β (Running Sum) β
β β β
β Enriched Stock Events + Sector Aggregates β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Concept | Description |
|---|---|
| KStream | Immutable, append-only event stream. Each event is independent. Used for stock events. |
| KTable | Changelog stream where each key has at most one current value. Used for user profiles and alerts. |
| Join | Combine records from two streams/tables based on matching keys. |
| Aggregate | Combine multiple records into a single result, maintaining state. |
| Serde | Serializer/Deserializer for converting objects to/from Kafka format. |
-
Left Join (Stocks β UserProfile)
- Every stock record is joined with its corresponding user profile
- If profile doesn't exist, proceeds with null (left join behavior)
- Enriches stock with
riskToleranceandaccountType
-
Left Join (EnrichedStock β UserAlert)
- Further enriches with user alert information
- If no alert exists for the user, defaults to "NONE"
- Adds
alertTypeto create fully enriched stock event
- Sector Aggregation
- Groups stocks by sector
- Computes running total of stock amounts per sector
- Maintains state using materialized store
- Output: Sector β Total Stock Amount
src/main/java/com/kodebytes/acasado/
βββ SampleSpringKafkaJoinsApplication.java # Spring Boot entry point
βββ config/
β βββ KafkaConfig.java # Kafka template beans
β βββ OpenApiConfig.java # Swagger/OpenAPI configuration
βββ controller/
β βββ PublishDataController.java # REST APIs for publishing test data
βββ events/ # Domain model records
β βββ Stocks.java # Stock event
β βββ UserProfile.java # User profile reference data
β βββ UserAlert.java # User alert reference data
β βββ EnrichedStock.java # Enriched result
β βββ Item.java # Nested item in stock
βββ serdes/ # Custom serializers/deserializers
β βββ StocksSerde.java # Stocks JSON serde
β βββ UserProfileSerde.java # UserProfile JSON serde
β βββ UserAlertSerde.java # UserAlert JSON serde
β βββ EnrichedStockSerde.java # EnrichedStock JSON serde
β βββ *Serializer/*Deserializer.java # Individual implementations
βββ streams/
βββ StockStateStream.java # Stream topology with joins & aggregates
src/main/resources/
βββ application.yml # Spring Boot & Kafka configuration
βββ stocks.json # Sample stock data
βββ userProfile.json # Sample user profile data
βββ userAlert.json # Sample user alert data
docker-compose.yml # Zookeeper + 3 Kafka brokers
record Stocks(
String stockId,
String userID, // JOIN KEY
double stockAmount,
String stockName,
String sector, // Used for aggregation
String countryLocation,
List<Item> items
)record UserProfile(
String userId, // JOIN KEY
String riskTolerance, // LOW, MEDIUM, HIGH
String accountType // INDIVIDUAL, CORPORATE
)record UserAlert(
String userId, // JOIN KEY
String alertType, // NONE, THRESHOLD_EXCEEDED, THRESHOLD_CORRECT
double threshold
)record EnrichedStock(
Stocks stock, // Original stock event
String riskTolerance, // From UserProfile join
String accountType, // From UserProfile join
String alertType // From UserAlert join
)1. Stream Creation
ββ Load "stocks" topic as KStream<String, Stocks>
2. Re-keying
ββ Select key by userID (from Stocks.userID)
ββ Creates KStream<String, Stocks> with userID as key
3. Reference Data Loading
ββ Load "user-profiles" as KTable<String, UserProfile>
ββ Load "user-alerts" as KTable<String, UserAlert>
4. First Left Join (Stocks + UserProfile)
ββ Result: KStream<String, EnrichedStock>
Contains: stock + riskTolerance + accountType
5. Second Left Join (EnrichedStock + UserAlert)
ββ Result: KStream<String, EnrichedStock>
Contains: fully enriched stock with alertType
6. Output Enriched Results
ββ Send to "enriched-stocks" topic
7. Aggregation (Parallel Processing)
ββ Group stocks by sector
ββ Aggregate: sum(stockAmount) by sector
ββ Maintain state store "sector-aggregate"
// Join 1: Stock + UserProfile
KStream<String, EnrichedStock> enriched = byUser.leftJoin(
userProfiles,
(stock, profile) -> {
String risk = (profile == null) ? "UNKNOWN" : profile.riskTolerance();
String account = (profile == null) ? "UNKNOWN" : profile.accountType();
return new EnrichedStock(stock, risk, account, "NONE");
}
);
// Join 2: EnrichedStock + UserAlert
KStream<String, EnrichedStock> enrichedWithAlerts = enriched.leftJoin(
userAlerts,
(enr, alert) -> {
String alertType = (alert == null) ? "NONE" : alert.alertType();
return new EnrichedStock(enr.stock(), enr.riskTolerance(),
enr.accountType(), alertType);
}
);- Java 17+
- Maven 3.6+
- Docker & Docker Compose
git clone <repository-url>
cd sample-spring-kafka-joinsEdit src/main/resources/application.yml if needed:
spring:
kafka:
bootstrap-servers: localhost:9092,localhost:9093,localhost:9094
streams:
application-id: stocks-streams
state-dir: target/kafka-stream-logsdocker-compose up -dThis starts:
- Zookeeper: Coordination service
- 3 Kafka Brokers: Distributed fault-tolerant message brokers
Verify containers are running:
docker-compose psTopics are auto-created on first use, or create manually:
# From within a running kafka container
docker exec -it <kafka-container-id> bash
# Create topics
kafka-topics --create --topic stocks --bootstrap-server localhost:9092 --replication-factor 3 --partitions 3
kafka-topics --create --topic user-profiles --bootstrap-server localhost:9092 --replication-factor 3 --partitions 3
kafka-topics --create --topic user-alerts --bootstrap-server localhost:9092 --replication-factor 3 --partitions 3
kafka-topics --create --topic enriched-stocks --bootstrap-server localhost:9092 --replication-factor 3 --partitions 3# Build with Maven
mvn clean package
# Run the application
mvn spring-boot:runThe application starts on http://localhost:9191
Use the REST APIs to publish sample data and trigger stream processing.
All endpoints return response messages confirming data publication.
| Endpoint | Method | Description |
|---|---|---|
/publish |
POST |
Publish 10 stock events to the stocks topic |
/profile |
POST |
Publish all user profiles to the user-profiles topic |
/alert |
POST |
Publish all user alerts to the user-alerts topic |
/all |
POST |
Publish all data (stocks, profiles, alerts) in parallel |
# Publish all reference data first
curl -X POST http://localhost:9191/api/stocks/all
# Or individually:
curl -X POST http://localhost:9191/api/stocks/profile
curl -X POST http://localhost:9191/api/stocks/alert
curl -X POST http://localhost:9191/api/stocks/publish{
"status": "β
Parallel execution completed!",
"stocks": 10,
"profiles": 5,
"alerts": 5
}Access the interactive API documentation at:
http://localhost:9191/swagger-ui.html
API docs JSON:
http://localhost:9191/v3/api-docs
Stock events come with stockId as the key, but we need to join by userID. The selectKey operation re-keys the stream:
KStream<String, Stocks> byUser = stream.selectKey((oldKey, stock) -> stock.userID());This ensures join co-partitioning: records with the same userID key will be processed by the same task and can be reliably matched with KTable partitions.
Kafka Streams joins operate on a per-record basis with KTables (state stores). There's no time window:
- When a stock arrives, it immediately looks up the current value from the UserProfile KTable
- If a newer profile update arrives later, it won't retroactively apply to old stocks (this is join behavior difference from stream-stream joins)
- Every stock record produces an output (even if no profile exists)
- Missing profiles result in
"UNKNOWN"values - Missing alerts result in
"NONE"value - No records are lost due to missing reference data
stream.groupBy((key, stock) -> stock.sector())
.aggregate(
() -> 0.0, // Initializer: start with 0.0
(type, stock, currentSum) -> currentSum + stock.stockAmount(), // Aggregator
Materialized.with(Serdes.String(), Serdes.Double())
)
.toStream()- Group By Key: All stock records are grouped by their sector (e.g., "TECH", "ENERGY")
- Initializer: When a new sector is first encountered, start with value 0.0
- Aggregation Function: For each new stock in that sector, add its amount to the running total
- State Store: Results are stored in a local RocksDB state store at
target/kafka-stream-logs
| Sector | Stock 1 | Stock 2 | Stock 3 | Aggregate |
|---|---|---|---|---|
| TECH | $1000 | $2000 | $500 | $3500 |
| ENERGY | $5000 | - | - | $5000 |
| FINANCE | - | $3000 | $1500 | $4500 |
Aggregation state is persisted locally:
target/kafka-stream-logs/
stocks-streams/
2_1/
KSTREAM-AGGREGATE-STATE-STORE-0000000016/
rocksdb/
State stores enable:
- Fault tolerance (can recover after restart)
- Interactive queries (query current aggregation values)
- Exactly-once semantics
[
{
"stockId": "STOCK001",
"userID": "user1",
"stockAmount": 1500.50,
"stockName": "Apple Inc",
"sector": "TECH",
"countryLocation": "USA",
"items": [...]
},
...
][
{
"userId": "user1",
"riskTolerance": "HIGH",
"accountType": "INDIVIDUAL"
},
...
][
{
"userId": "user1",
"alertType": "THRESHOLD_EXCEEDED",
"threshold": 10000.0
},
...
]The application uses colored console logging. Look for these patterns:
Stock Enriched With π§Ύ User Profiles with Risk: HIGH | Account Type: INDIVIDUAL
Stock Enriched With User Profiles with π¨ Alert Type: THRESHOLD_EXCEEDED
EnrichedWithAlerts: key=user1 value=EnrichedStock(...)
Preserve existing sector aggregate - Stock Sector: TECH | π° Running Total Amount: 3500.0
List all topics:
docker exec -it <kafka-container-id> kafka-topics --list --bootstrap-server localhost:9092Consume enriched output:
docker exec -it <kafka-container-id> kafka-console-consumer \
--bootstrap-server localhost:9092 \
--topic enriched-stocks \
--from-beginningState store files are at:
target/kafka-stream-logs/stocks-streams/[partition]/rocksdb/
Each partition has its own state directory corresponding to the Kafka partition assigned to that instance.
spring:
kafka:
bootstrap-servers: localhost:9092,localhost:9093,localhost:9094
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: com.kodebytes.acasado.serdes.StocksSerializer
streams:
application-id: stocks-streams # Consumer group ID
properties:
default.key.serde: Serdes$StringSerde
default.value.serde: StocksSerde
state-dir: target/kafka-stream-logs # Local state storageservices:
kafka1:
image: confluentinc/cp-kafka:7.5.0
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
...Each domain object has custom Serdes (serializer/deserializer):
- Serializers: Convert Java objects β JSON bytes
- Deserializers: Convert JSON bytes β Java objects
Example from StocksSerde.java:
public class StocksSerde extends Serdes.WrapperSerde<Stocks> {
public StocksSerde() {
super(new StocksSerializer(), new StocksDeserializer());
}
}This allows Kafka Streams to handle our custom domain objects natively.
- Stream-Table Join: Enriching streaming events with reference data
- Stream Repartitioning: Re-keying for join compatibility
- Chained Joins: Multiple sequential joins on a single stream
- Stateful Operations: Aggregations with local state stores
- Spring Boot Integration: Automatic configuration of Kafka Streams
Feel free to fork, modify, and submit pull requests to enhance this sample application.
This project is provided as-is for educational purposes.
# 1. Start Kafka
docker-compose up -d
# 2. Build
mvn clean package
# 3. Run
mvn spring-boot:run
# 4. In another terminal, publish test data
curl -X POST http://localhost:9191/api/stocks/all
# 5. View API documentation
# Open http://localhost:9191/swagger-ui.html| Issue | Solution |
|---|---|
| Connection refused to Kafka | Ensure docker-compose is running: docker-compose ps |
| State store permission errors | Clear target/ directory and rebuild |
| Messages not appearing in enriched topic | Verify Stocks have matching userIDs in profiles/alerts |
| Serialization errors | Check JSON format in sample data files matches domain records |