Skip to content
Go back

Building a Mini-Kafka from Scratch in Java: Step-by-Step Learning Notes

Table of contents

Open Table of contents

Designing a Distributed Commit Log

When developers transition from traditional message queues (like RabbitMQ or ActiveMQ) to Apache Kafka, they often struggle with a fundamental paradigm shift: Queue vs. Commit Log.

In a traditional queue, messages are transient. The broker tracks consumption state, and as soon as a consumer acknowledges a message, it is deleted from the server. This makes multi-consumer replay or back-dating consumption pointers incredibly expensive or impossible.

Kafka flips this model on its head. It is a distributed, append-only commit log. The broker is essentially a high-performance disk-writer that stores events sequentially. The consumers maintain their own read offsets (pointers) and pull data at their own pace. Because the data is persistent, multiple consumers can read the same partition from different offsets, and messages can be replayed infinitely within the retention window.

To understand how this operates under the hood, we built a fully functional Mini-Kafka in Java. In this post, we break down the implementation step-by-step, analyzing why each component was designed the way it was.


The System Architecture

Here is the high-level layout of our lightweight message broker:

                           +------------------------+
                           |  Producer Application  |
                           +-----------+------------+
                                       | 
                                       | 1. Sends: PRODUCE topic partition key value
                                       v
                               +---------------+
                               |  Mini-Kafka   | (Socket listener on 9092)
                               |    Broker     | (ThreadPool worker handles connection)
                               +-------+-------+
                                       |
                                       | 2. Appends to disk sequentially
                                       v
                              +-----------------+
                              | Storage Engine  | -> Writes to data/topics/<topic>/partition-<id>/
                              +-----------------+
                                       ^
                                       | 3. Pulls: FETCH topic partition offset
                                       |
                           +-----------+------------+
                           |  Consumer Application  | (Maintains local read pointers)
                           +------------------------+

Our implementation is divided into 6 distinct steps, mirroring Kafka’s architectural pillars:

  1. The Data Model (Record): Serialization and formatting.
  2. The Storage Node (StorageEngine): Partitioning, log writing, thread safety, and crash recovery.
  3. The Messaging Protocol (Protocol): Custom text-based command parser.
  4. The TCP Server (Broker): Concurrency via thread pooling.
  5. The Publisher Client (Producer): Client-side metadata discovery and semantic hashing.
  6. The Poller Client (Consumer): Pull-based loop and local offset pointers.

Step 1: The Core Data Model (Record.java)

Before writing data to disk or transferring it over the wire, we need a standard representation for a message. In Record.java, we define a Record:

public class Record implements Serializable {
    private long offset;
    private long timestamp;
    private String key;
    private String value;
}

Why this design?


Step 2: Append-Only Partition Persistence (StorageEngine.java)

Kafka’s performance is largely credited to sequential I/O. Modern operating systems optimize sequential disk access heavily via read-ahead page caches, making linear writes almost as fast as memory access.

In StorageEngine.java, we simulate this behavior by writing messages to append-only log files structured around partition directories:

data/
└── topics/
    └── orders/
        ├── partition-0/
        │   └── 0000000000.log  <-- Append-only log file
        ├── partition-1/
        │   └── 0000000000.log
        └── partition-2/
            └── 0000000000.log

Why this design?


Step 3: Custom TCP Protocol (Protocol.java)

Communication between Kafka clients and brokers occurs over raw TCP sockets.

In Protocol.java, we implement a raw text-based protocol. The command parser processes simple string messages from the wire:

Why this design?


Step 4: Parallel Connection Handling (Broker.java)

The central component is the TCP server defined in Broker.java.

public class Broker {
    private static final int PORT = 9092;
    private final ExecutorService clientThreadPool;

    public Broker(String dataDir) {
        this.clientThreadPool = Executors.newCachedThreadPool();
    }
}

Why this design?


Step 5: Client-Side Partition Routing (Producer.java)

A major design decision in Apache Kafka is Dumb Broker, Smart Client. In legacy brokers, the server is responsible for routing messages, clustering, and consumer tracking. In Kafka, the client does the heavy lifting.

In Producer.java, the producer initiates connection and queries partition info before sending any event payloads:

// 1. Fetch metadata to discover topic partitions
String metadataRequest = "METADATA " + topic + "\n";
out.write(metadataRequest.getBytes(StandardCharsets.UTF_8));
out.flush();
String metadataResponse = in.readLine();
int numPartitions = parsePartitionCount(metadataResponse);

Why this design?


Step 6: Pull-Based Consuming & Client Offsets (Consumer.java)

In Consumer.java, we implement the consumer client. The consumer runs a polling loop, querying the broker at regular intervals:

while (true) {
    for (int partition = 0; partition < numPartitions; partition++) {
        long currentOffset = partitionOffsets.get(partition);
        String request = String.format("FETCH %s %d %d\n", topic, partition, currentOffset);
        out.write(request.getBytes(StandardCharsets.UTF_8));
        out.flush();
        // Read response & parse messages ...
        partitionOffsets.put(partition, record.getOffset() + 1);
    }
    Thread.sleep(POLL_INTERVAL_MS);
}

Why this design?


Summary of Learnings

By recreating Kafka’s architecture in Java, we uncovered the fundamental reasons behind its design:

PatternTraditional Broker DesignKafka / Mini-Kafka DesignWhy?
Broker StateStateful (Broker tracks read offsets)Stateless (Client tracks read offsets)Reduces broker memory footprint; simplifies scaling.
Data LifecycleDelete on acknowledgmentPersistent (Bounded append-only log)Allows multi-consumer streams and replayability.
Partition RoutingServer-side routing rulesClient-side semantic hashingOffloads CPU cycles from broker to clients.
Data DeliveryPush-basedPull-based (Polling)Prevents overwhelming slow consumers.
Thread SafetyGlobal synchronizationPartition-level ReadWriteLocksAllows concurrent writes/reads across partitions.

Building systems from scratch is the ultimate way to demystify complex enterprise tools. With just six classes and Java’s raw socket API, we replicated the core data flows of one of the world’s most robust data systems.

The full source code of this project can be found in the mini-kafka directory of this workspace.


Share this post on:

Previous Post
How Kubernetes Probes Actually Work: My Takeaways and Mental Models
Next Post
Why Pgpool-II HA Mode Breaks Camunda 7 in Kubernetes: A Systems Post-Mortem