KafkaIQ

7.1 Topology Design & Internal Topics

Module Overview

Goal: Understand Kafka Streams topology structure and internal topics (repartition, changelog).

What You'll Learn:

  • What a topology is
  • Sub-topologies and parallelism
  • Repartition topics (when/why created)
  • Internal topic naming and configuration
  • Topology visualization and debugging
  • Best practices for topology design

What's NOT Covered Here:

  • Changelog topics (covered in Module 6.2)
  • Specific operations (covered in Modules 4, 5, 8)
  • EOS internals (covered in Module 9.1)

Key Insight: The topology is the computational graph — understanding its structure is key to performance, parallelism, and troubleshooting.


1. What is a Topology?

Definition

Topology = Directed Acyclic Graph (DAG) of processing nodes

Components:
├─ Source nodes (read from Kafka topics)
├─ Processor nodes (transform data)
└─ Sink nodes (write to Kafka topics)

Edges represent data flow

Simple Example

// Code
StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> input = builder.stream("input-topic");
input.filter((k, v) -> v.length() > 5)
     .to("output-topic");

// Topology structure
Source (input-topic)
   ↓
Processor (filter)
   ↓
Sink (output-topic)

Complex Example

// Code
KStream<String, String> input = builder.stream("orders");
KTable<String, Long> counts = input
    .groupByKey()
    .count();

input.join(counts, ...)
     .to("enriched-orders");

// Topology structure (simplified)
Source (orders)
   ↓
   ├──→ Group → Count → KTable (counts)
   │                        ↓
   └──→ Join ←──────────────┘
        ↓
     Sink (enriched-orders)

2. Sub-Topologies

What are Sub-Topologies?

Sub-topology = independent processing sub-graph

Created when:
├─ No direct data flow between parts
└─ Reading from different source topics

Each sub-topology can scale independently

Example: Two Independent Streams

// Stream 1: orders → process → output1
builder.stream("orders")
    .mapValues(v -> v.toUpperCase())
    .to("output1");

// Stream 2: payments → process → output2
builder.stream("payments")
    .filter((k, v) -> v.contains("USD"))
    .to("output2");

// Result: 2 sub-topologies
// They share no data flow

Visualizing Sub-Topologies

Sub-topology 0:
  Source (orders) → Map → Sink (output1)

Sub-topology 1:
  Source (payments) → Filter → Sink (output2)

Each can have different parallelism

Why Sub-Topologies Matter

PARALLELISM:
├─ Each sub-topology processes independently
├─ Can scale sub-topologies separately
└─ Max parallelism = partitions per sub-topology

RESOURCE ISOLATION:
├─ One sub-topology slow ≠ other slow
└─ Independent processing rates

3. Topology Inspection

Describing the Topology

Topology topology = builder.build();
System.out.println(topology.describe());

Example Output

Topologies:
   Sub-topology: 0
    Source: KSTREAM-SOURCE-0000000000 (topics: [input-topic])
      --> KSTREAM-FILTER-0000000001
    Processor: KSTREAM-FILTER-0000000001 (stores: [])
      --> KSTREAM-SINK-0000000002
      <-- KSTREAM-SOURCE-0000000000
    Sink: KSTREAM-SINK-0000000002 (topic: output-topic)
      <-- KSTREAM-FILTER-0000000001

Interpreting Topology Description

Each node shows:
├─ Type (Source, Processor, Sink)
├─ Name (auto-generated)
├─ Inputs (arrows: -->)
├─ Outputs (arrows: <--)
└─ State stores (if any)

Useful for:
├─ Understanding data flow
├─ Debugging processing issues
└─ Identifying repartitions

4. Internal Topics

Overview

Internal topics = topics created/managed by Kafka Streams

Two types:
├─ Repartition topics
└─ Changelog topics

Naming pattern:
{application.id}-{operation}-{type}

Repartition Topics

Purpose: Re-distribute data by new key

Created when:
├─ groupBy() changes key
├─ Join requires co-partitioning
└─ Aggregation after key change

Example:
my-app-KSTREAM-AGGREGATE-STATE-STORE-0000000003-repartition

Changelog Topics

Purpose: Backup state stores

Created for:
├─ Every stateful operation (count, aggregate, etc.)
└─ Ensures state durability

Example:
my-app-user-counts-changelog

(Covered in detail in Module 6.2)

5. Repartition Topics Deep Dive

When Repartitioning Happens

// DOES NOT require repartition (key unchanged)
stream.groupByKey()
      .count();

// REQUIRES repartition (key changed)
stream.groupBy((k, v) -> v.userId())  // New key!
      .count();

// REQUIRES repartition (key changed)
stream.selectKey((k, v) -> v.customerId())
      .groupByKey()
      .count();

Why Repartitioning is Needed

Problem:
├─ Kafka guarantees order within partition
├─ Stateful operations need same-key records together
└─ Key change → records may be on wrong partition

Solution:
├─ Write to repartition topic with new key
├─ Kafka partitions by new key
└─ Read from repartition topic for processing

This ensures co-partitioning

Repartition Flow

Original stream:
Key: orderId, Value: Order (contains customerId)

After groupBy(customerId):
┌────────────────────────────────────────┐
│ Source (orders)                        │
│   ↓                                    │
│ SelectKey (customerId)                 │
│   ↓                                    │
│ Sink (repartition topic)               │  ← Write with new key
└────────────────────────────────────────┘
           ↓ (Kafka re-partitions)
┌────────────────────────────────────────┐
│ Source (repartition topic)             │  ← Read from repartition
│   ↓                                    │
│ GroupBy → Count                        │
│   ↓                                    │
│ Sink (output)                          │
└────────────────────────────────────────┘

Repartition Topic Configuration

// Control repartition behavior
stream.groupBy((k, v) -> v.userId())
      .count(Materialized.as("user-counts"));

// Repartition topic created automatically with:
├─ Partitions: same as input topic (default)
├─ Replication factor: same as input
├─ Cleanup policy: delete (not compacted)
├─ Retention: 1 day (default)

Custom Repartition Config

// Use `repartition()` for explicit control
stream.repartition(Repartitioned.<String, Order>as("custom-repartition")
          .withNumberOfPartitions(10)
          .withKeySerde(Serdes.String())
          .withValueSerde(orderSerde))
      .groupByKey()
      .count();

6. Co-Partitioning

What is Co-Partitioning?

Co-partitioning = records with same key on same partition

Required for:
├─ Joins (stream-stream, stream-table)
├─ Stateful operations
└─ Windowed operations

Ensures same-key records processed together

Co-Partitioning Requirements

For two topics to be co-partitioned:

1. Same number of partitions
2. Same partitioning strategy
3. Same key type

If not co-partitioned:
└─ Kafka Streams automatically repartitions

Example: Join Without Repartition

// orders and customers both have 10 partitions
// Both partitioned by customerId

KStream<String, Order> orders = builder.stream("orders");
KTable<String, Customer> customers = builder.table("customers");

orders.join(customers, ...)  // No repartition needed

Example: Join With Repartition

// orders: 10 partitions, key = orderId
// customers: 10 partitions, key = customerId

// Must change orders key to customerId
orders.selectKey((k, v) -> v.customerId())  // Key changed
      .join(customers, ...)  // Repartition happens automatically

7. Parallelism and Tasks

Tasks

Task = unit of parallelism in Kafka Streams

One task per input partition:
├─ 10 partitions → 10 tasks
└─ Each task processes one partition

Tasks distributed across instances

Example: 3 Instances, 9 Partitions

Instance 1: Tasks 0, 1, 2
Instance 2: Tasks 3, 4, 5
Instance 3: Tasks 6, 7, 8

Max parallelism = 9 (number of partitions)

Sub-Topology Parallelism

Each sub-topology has independent tasks:

Sub-topology 0 (input: orders, 10 partitions):
├─ 10 tasks

Sub-topology 1 (input: payments, 5 partitions):
├─ 5 tasks

Total: 15 tasks across 2 sub-topologies

Over-Partitioning for Future Scale

Strategy: Start with more partitions than current load

Example:
├─ Current load: 3 instances
├─ Partitions: 30 (10× instances)
└─ Future: Can scale to 30 instances without repartition

Trade-off:
✅ Easy scaling
❌ More tasks per instance initially
❌ More metadata overhead

8. Topology Design Patterns

Pattern 1: Linear Pipeline

// Simple linear flow
builder.stream("input")
    .filter(...)
    .map(...)
    .to("output");

Characteristics:
├─ One sub-topology
├─ Easy to understand
└─ Limited parallelism

Pattern 2: Branching

// Split stream into multiple outputs
KStream<String, Order> orders = builder.stream("orders");

orders.filter((k, v) -> v.amount() > 1000)
      .to("large-orders");

orders.filter((k, v) -> v.amount() <= 1000)
      .to("small-orders");

Characteristics:
├─ One source, multiple sinks
├─ No repartition needed
└─ Still one sub-topology

Pattern 3: Join and Enrich

// Join stream with table
KStream<String, Order> orders = builder.stream("orders");
KTable<String, Customer> customers = builder.table("customers");

orders.selectKey((k, v) -> v.customerId())
      .join(customers, ...)
      .to("enriched-orders");

Characteristics:
├─ Repartition likely needed
├─ State for table
└─ Two sub-topologies (orders, customers)

Pattern 4: Fan-In

// Merge multiple streams
KStream<String, Event> stream1 = builder.stream("topic1");
KStream<String, Event> stream2 = builder.stream("topic2");

KStream<String, Event> merged = stream1.merge(stream2);
merged.to("output");

Characteristics:
├─ Multiple sources, one output
├─ No repartition needed (if keys compatible)
└─ One or multiple sub-topologies

Pattern 5: Aggregation Pipeline

// Multi-stage aggregation
builder.stream("events")
    .groupByKey()
    .windowedBy(TimeWindows.of(Duration.ofMinutes(5)))
    .count()
    .toStream()
    .to("windowed-counts");

Characteristics:
├─ Stateful (state store + changelog)
├─ Repartition if key changed
└─ One sub-topology

9. Internal Topic Naming

Naming Convention

Repartition topic:
{application.id}-{operation}-repartition

Changelog topic:
{application.id}-{store-name}-changelog

Example:
Application ID: my-streaming-app

Topics created:
├─ my-streaming-app-user-counts-changelog
├─ my-streaming-app-KSTREAM-AGGREGATE-0000000003-repartition
└─ my-streaming-app-windowed-store-changelog

Why Naming Matters

DEBUGGING:
├─ Identify which app created topic
└─ Understand purpose (repartition vs changelog)

CLEANUP:
├─ Delete app-specific topics on reset
└─ Avoid orphaned topics

MONITORING:
├─ Track internal topic lag
└─ Measure repartition overhead

10. Managing Internal Topics

Auto-Created Topics

By default:
├─ Kafka Streams creates internal topics automatically
├─ Partitions: match input topic
├─ Replication factor: match input topic
└─ Retention: varies (repartition: delete, changelog: infinite)

Custom Configuration

// Configure internal topics
props.put(StreamsConfig.REPLICATION_FACTOR_CONFIG, 3);
props.put(StreamsConfig.NUM_STREAM_THREADS_CONFIG, 4);

// All internal topics will use replication factor 3

Explicit Topic Configuration

// Configure specific internal topic
Map<String, String> topicConfig = new HashMap<>();
topicConfig.put("retention.ms", "86400000");  // 1 day

// Apply to repartition topic
stream.repartition(Repartitioned.<String, Order>as("my-repartition")
          .withNumberOfPartitions(10)
          .withStreamPartitioner(...)
      );

Deleting Internal Topics

# List internal topics
kafka-topics.sh --bootstrap-server localhost:9092 --list | grep my-app

# Delete repartition topic (safe, recreated on restart)
kafka-topics.sh --bootstrap-server localhost:9092 \
  --delete --topic my-app-...-repartition

# ⚠️ Delete changelog topic (NOT RECOMMENDED)
# State lost if no local backup
kafka-topics.sh --bootstrap-server localhost:9092 \
  --delete --topic my-app-user-counts-changelog

11. Topology Optimization

Minimize Repartitions

// BAD: Multiple repartitions
stream.groupBy((k, v) -> v.field1()).count()
      .toStream()
      .groupBy((k, v) -> k.field2()).count();  // 2 repartitions

// GOOD: Single repartition
stream.selectKey((k, v) -> v.field1())
      .groupByKey().count();  // 1 repartition

Use groupByKey() When Possible

// BAD: Unnecessary repartition
stream.groupBy((k, v) -> k).count();  // Repartitions even though key unchanged

// GOOD: No repartition
stream.groupByKey().count();  // Recognizes key unchanged

Pre-Partition Input Topics

Strategy: Partition input topics by processing key

Example:
├─ Input: orders topic, key = customerId
├─ Processing: aggregate by customerId
└─ No repartition needed (already partitioned correctly)

Pre-partitioning reduces overhead

Reduce State Store Count

// BAD: Many small state stores
stream.groupByKey().count(Materialized.as("count1"));
stream.groupByKey().aggregate(..., Materialized.as("agg1"));
stream.groupByKey().reduce(..., Materialized.as("reduce1"));
// 3 state stores, 3 changelogs

// GOOD: Combine operations
stream.groupByKey()
      .aggregate(
          () -> new Stats(),
          (k, v, agg) -> {
              agg.count++;
              agg.sum += v;
              return agg;
          },
          Materialized.as("combined-stats")
      );
// 1 state store, 1 changelog

12. Debugging Topologies

Enable Topology Logging

// Print topology on startup
Topology topology = builder.build();
System.out.println(topology.describe());

KafkaStreams streams = new KafkaStreams(topology, props);
streams.start();

Visualize Topology

# Use Kafka Streams Topology Visualizer
# https://zz85.github.io/kafka-streams-viz/

# Paste topology.describe() output
# Get visual graph representation

Check Internal Topics

# List all topics for your app
kafka-topics.sh --bootstrap-server localhost:9092 --list | grep my-app

# Identify internal topics
# Repartition: *-repartition
# Changelog: *-changelog

Monitor Repartition Lag

# Check consumer group lag
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group my-app --describe

# Look for repartition topics
# High lag = repartition bottleneck

Inspect Internal Topic Data

# Peek at repartition topic
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic my-app-...-repartition \
  --from-beginning \
  --max-messages 10

# Verify keys/values correct

13. Topology Best Practices

1. Design Before Coding

Planning checklist:
├─ What are the inputs (topics)?
├─ What are the outputs?
├─ What transformations needed?
├─ Where is state required?
├─ Where will repartitioning occur?
└─ How many sub-topologies?

Sketch topology on paper first

2. Name State Stores Explicitly

// BAD: Auto-generated names
stream.groupByKey().count();

// GOOD: Explicit names
stream.groupByKey().count(Materialized.as("user-counts"));

Benefits:
├─ Easier debugging
├─ Predictable changelog topic names
└─ Interactive queries (Module 11.1)

3. Understand Repartition Cost

Repartition overhead:
├─ Network I/O (write + read)
├─ Disk I/O (broker storage)
├─ Latency (additional hop)
└─ Topic management

Minimize when possible

4. Use Appropriate Partition Count

Too few partitions:
├─ Limited parallelism
└─ Can't scale

Too many partitions:
├─ High metadata overhead
├─ More tasks per instance
└─ Slower rebalance

Rule of thumb: 2-10× current instance count

5. Monitor Internal Topics

Watch for:
├─ Repartition topic lag (bottleneck)
├─ Changelog topic growth (state size)
├─ Orphaned topics (app deleted but topics remain)
└─ Disk usage on brokers

14. Common Topology Pitfalls

Pitfall 1: Unexpected Repartition

// Looks innocent but repartitions
stream.groupBy((k, v) -> k).count();  // Repartitions!

// Use this instead
stream.groupByKey().count();  // No repartition

Pitfall 2: Too Many State Stores

// BAD: Separate aggregations
stream.groupByKey().count();
stream.groupByKey().reduce(...);
stream.groupByKey().aggregate(...);
// 3 state stores, 3 changelogs

// GOOD: Combined aggregation
stream.groupByKey().aggregate(...);  // 1 state store

Pitfall 3: Ignoring Co-Partitioning

// BAD: Join without checking partitions
// orders: 10 partitions
// customers: 8 partitions

orders.join(customers, ...);  // Fails! Not co-partitioned

// GOOD: Ensure same partition count
// Or let Kafka Streams repartition

Pitfall 4: Over-Partitioning Repartition Topics

// BAD: Too many partitions for repartition
stream.repartition(Repartitioned.as("repartition")
          .withNumberOfPartitions(1000))  // Overkill
      .groupByKey().count();

// GOOD: Match input topic or slightly higher
stream.repartition(Repartitioned.as("repartition")
          .withNumberOfPartitions(10))
      .groupByKey().count();

15. Mental Model Summary

Topology as a Graph

┌────────────────────────────────────────┐
│  Topology = Processing Graph           │
│                                        │
│  Nodes:                                │
│  ├─ Source (read Kafka)                │
│  ├─ Processor (transform)              │
│  └─ Sink (write Kafka)                 │
│                                        │
│  Edges:                                │
│  └─ Data flow between nodes            │
└────────────────────────────────────────┘

Internal Topics Purpose

REPARTITION TOPICS:
├─ Re-distribute data by new key
├─ Ensure co-partitioning
└─ Created automatically when needed

CHANGELOG TOPICS:
├─ Backup state stores
├─ Enable fault tolerance
└─ One per state store

Both managed by Kafka Streams

The Complete Flow

Input Topic
   ↓
[Source Node]
   ↓
[Processor: change key]
   ↓
[Sink → Repartition Topic] ← Kafka re-partitions
   ↓
[Source ← Repartition Topic]
   ↓
[Processor: stateful operation]
   ├─ State Store (local)
   └─ Changelog Topic (Kafka) ← Durability
   ↓
[Sink → Output Topic]

16. Questions You Should Be Able to Answer

After this module, you should be able to answer:

✅ What is a Kafka Streams topology?
✅ What are sub-topologies?
✅ When are repartition topics created?
✅ What's the difference between repartition and changelog topics?
✅ How do I inspect my topology?
✅ What is co-partitioning and why does it matter?
✅ How can I minimize repartitions?
✅ How are tasks and parallelism related?


17. What's Next?

Now that you understand topology design, the next module will cover:

  • Module 8.1: Joins in Kafka Streams — stream-stream, stream-table, table-table joins
  • Module 5.2: Windowing — how windows interact with topology
  • Module 12.1: Monitoring — tracking topology performance

Summary

You learned:

  • ✅ Topology = DAG of source, processor, and sink nodes
  • ✅ Sub-topologies = independent sub-graphs with separate parallelism
  • ✅ Repartition topics created when key changes (groupBy, joins)
  • ✅ Changelog topics back up state stores (covered in Module 6.2)
  • ✅ Co-partitioning required for joins and stateful operations
  • ✅ Tasks = one per input partition (unit of parallelism)
  • ✅ Use groupByKey() to avoid unnecessary repartitions
  • ✅ Name state stores explicitly for better debugging
  • ✅ Monitor internal topics for performance bottlenecks

Next up: Module 8.1 — Joins in Kafka Streams — Learn how to combine multiple streams and tables with different join types.