A guided walkthrough of this codebase for readers who know how to program but know little or nothing about Java. If you can read Python, C++, or JavaScript, you can follow this — every Java- and Spring-specific idea is explained the first time it shows up.
This is long on purpose — it's meant to take you from "what even is this repo" to "I could explain any file in it in an interview." You don't have to read it top to bottom in one sitting. Use the table of contents to jump around, and come back to Part 2 (the Java/Spring primer) whenever a keyword in the code confuses you.
Table of contents
- The Big Picture
- Just Enough Java & Spring to Follow Along
- Project Structure
- The RESP Protocol, Byte by Byte
- Component by Component
- Three End-to-End Walkthroughs
- Concurrency, Explained
- The Deployment Story
- Running This Yourself
- Design Decisions and Why They Were Made
- What's Simplified Compared to Real Redis
- Common Interview Questions About This Project
Redis is a piece of server software that does one job extremely well: it holds data in memory (RAM, not disk) as simple key → value pairs, and lets other programs read and write that data over the network almost instantly. Think of it as a giant, shared HashMap that many different applications can talk to at once, over a socket, instead of each having their own private one in their own process.
Because everything lives in RAM, Redis is very fast — but RAM is also volatile (it's wiped on a restart), which is why real Redis has features this project deliberately leaves out or simplifies: writing snapshots to disk, replaying a log of every write, running multiple copies of itself that stay in sync, and so on.
People use Redis for things like: a cache in front of a slower database, storing user login sessions, rate-limiting counters, message queues, and leaderboards.
This project started life as a CodeCrafters challenge called "Build Your Own Redis." CodeCrafters gives you a skeleton repository (build files, a Main entry point with almost nothing in it, and a test harness) and a series of stages — "make PING work," "make SET/GET work," "add expiry," "add replication," "add transactions" — and you fill in real, working code for each stage. The .codecrafters/ folder and codecrafters.yml in this repo are the remains of that harness; they're what let CodeCrafters' remote graders compile and run your solution.
By the time all the stages were done, this codebase is a real (if intentionally scaled-down) Redis-compatible server that understands the following commands: PING, ECHO, SET (with optional millisecond expiry), GET, INCR, INFO replication, MULTI/EXEC/DISCARD (transactions), and the replication trio REPLCONF/PSYNC/WAIT. It speaks the real wire protocol Redis uses (RESP), so in principle a real redis-cli could talk to it for the commands above.
It also supports running as either a master (the "main" node clients write to) or a replica (informally "slave" in this codebase and in older Redis docs — modern Redis and the wider industry now say "replica"/"primary"; this project's code and this document both use "master/slave" purely because that's the vocabulary baked into the variable and class names you'll actually see), which can connect to a master and receive a live copy of its writes.
Beyond the Java application itself, the repo also contains everything needed to containerize it (Docker), build and publish it automatically (an Azure Pipelines CI pipeline with a self-hosted build agent), and run it on a local Kubernetes cluster (kind + a Helm chart). We'll cover all of that in Part 8.
Skip anything here you already know. Come back to this part any time a keyword trips you up later.
- Classes and objects. A
classis a blueprint (public class Store { ... });new Store()creates one real "object" from that blueprint. A class's variables are called fields, and its functions are called methods. - Packages. Java groups related classes into
packages, which are really just folders.package Components.Repository;at the top of a file means that file lives atsrc/main/java/Components/Repository/. Another file wanting to use that class writesimport Components.Repository.Store;at its own top. If a file has nopackageline at all, it's in what Java calls the "default package" — you'll see this is exactly the case forMain.javain this project. - public / private.
publicmeans any other class can see and use this field/method/class;privatemeans only code inside the same class can. It's the same idea as a leading underscore convention in Python, just enforced by the compiler instead of by convention. - static. A
staticfield or method belongs to the class itself, not to any one object made from it — there's only ever one copy, shared by everyone. You'll seeprivate static final Logger logger = ...at the top of most classes here: one shared logger per class, not one per object. - Generics (the
<...>syntax).ConcurrentHashMap<String, Value>means "a hash map whose keys areStrings and whose values areValueobjects." It's the same idea as Python's type hints (Dict[str, Value]) or C++ templates (map<string, Value>) — it lets the compiler catch "you put the wrong kind of thing in this container" mistakes before the code ever runs. - Exceptions, and specifically checked exceptions. Java has a category of exception (
IOExceptionis the one you'll see constantly here, since it's what network and file operations throw) that the compiler forces you to deal with — every method that might let one escape must eithercatchit or declarethrows IOExceptionin its own signature. You'll see both patterns in this codebase: some methods declarethrows IOExceptionand pass the problem up to their caller; otherscatchit and just log it. - Interfaces and functional interfaces. An
interfacedescribes a shape of behavior without an implementation. Java'sjava.util.functionpackage ships a few generic ones for representing "a chunk of behavior" as a value you can pass around, the same way you'd pass a lambda in Python or a function pointer/lambda in C++. This codebase usesBiFunction<A, B, R>once — "a function that takes anAand aBand returns anR" — to letCommandHandlerhandStorea ready-made "here's how to apply one command" function withoutStoreneeding to know anything about individual Redis commands. We'll see exactly where when we get toCommandHandler. - Annotations (the
@Somethinglines). These are metadata attached to a class, method, or field that some other tool or framework reads and acts on at startup — the annotation itself doesn't run any code. Almost every annotation in this project belongs to the Spring framework, covered next.
Maven is Java's build tool and dependency manager — the rough equivalent of package.json + npm for JavaScript, or pyproject.toml/requirements.txt + pip for Python. pom.xml (at the repo root) declares:
- The project's own name (
<artifactId>redis-java</artifactId>) and Java version (23— a very recent release; Java ships a new version every six months, and this project simply targets the newest one available at the time). - Its dependencies:
spring-boot-starter(the core Spring framework — notably notspring-boot-starter-web, so this app has no built-in web server; it opens its own raw sockets instead, which is the whole point of the exercise) andspring-boot-starter-test(JUnit 5 and assertion helpers, only needed for tests). - A build plugin (
maven-assembly-plugin) that bundles the compiled code and every dependency it needs into one self-contained "fat jar" — so the finished artifact can be run anywhere with justjava -jar redis-java.jar, no separate classpath setup required.
Spring is a framework that manages object creation and wiring for you. The core idea it's built around is called Dependency Injection (DI), and it solves a real annoyance: without it, if class A needs an instance of class B, A's code has to know how to construct a B (new B(...)) — and if B itself needs a C, A transitively needs to know how to build a C too. Multiply that across a real application and every class ends up tangled up in the construction details of every other class it (even indirectly) depends on.
With Spring, a class just declares what it needs, and a central container (Spring calls it the "application context") is responsible for constructing everything and handing out the right instances. Three annotations do almost all of this project's wiring:
@Componenton a class says "Spring, please manage one instance of this for me." You'll see it onStore,RespSerializer,ConnectionPool,RedisConfig,CommandHandler,MasterTcpServer, andSlaveTcpServer.@Autowiredon a field says "please fill this in with the matching@Componentinstance you already built." For example,CommandHandlerhas@Autowired public Store store;— it never callsnew Store()itself; Spring hands it the one sharedStoreobject.@ComponentScan(basePackages = "Components")(found onAppConfig, inConfig/AppConfig.java) tells Spring "go look through theComponentspackage (and everything nested inside it) and register every@Componentyou find."
One honest note worth knowing for later: this project injects dependencies directly into public fields (@Autowired public Store store;), which is called field injection. It's the easiest style to write, and you'll see it everywhere here, but most modern Spring guidance actually prefers constructor injection instead (passing dependencies as constructor parameters). We come back to exactly why in Part 12.
A thread is an independent path of execution inside one running program — a way to do more than one thing "at the same time" (or close to it, depending on CPU core count). This server needs to handle many clients connected at once without one slow client blocking everyone else, so every time a new client connects, the code hands its handling off to run independently, using:
CompletableFuture.runAsync(() -> {
try {
handleClient(client);
} catch (IOException e) {
throw new RuntimeException(e);
}
});CompletableFuture.runAsync(...) schedules the given block of code to run on a background thread (by default, one borrowed from a shared pool called the "common ForkJoinPool") and immediately returns control to whoever called it — the accept loop doesn't wait for handleClient to finish before going back to serverSocket.accept() for the next client. This is why the try/catch wraps the checked IOException in an unchecked RuntimeException: the lambda passed to runAsync isn't allowed to declare throws IOException itself, so wrapping it is the standard way to smuggle a checked exception across that boundary.
redis-java/
├── pom.xml Maven build file (dependencies, Java version, packaging)
├── your_program.sh Build + run the server locally
├── Dockerfile Multi-stage build → a runnable container image
├── .codecrafters/ CodeCrafters' own compile/run harness (see Part 1)
├── codecrafters.yml CodeCrafters challenge config (which Java version to use, etc.)
├── azure-pipelines.yml CI pipeline: build + push the Docker image
├── dind/ A self-hosted CI build agent (Docker-in-Docker)
├── kind/ Local Kubernetes cluster config
├── redis-chart/ Helm chart to deploy this app onto Kubernetes
├── ping.ps1 A tiny manual smoke-test script (PowerShell)
└── src/
├── main/java/
│ ├── Main.java Entry point — parses CLI args, starts a server
│ ├── Config/
│ │ └── AppConfig.java Tells Spring where to find @Component classes
│ └── Components/
│ ├── Server/
│ │ ├── RedisConfig.java Runtime config: role, port, replication state
│ │ ├── MasterTcpServer.java The master server loop
│ │ └── SlaveTcpServer.java The replica server loop + master handshake
│ ├── Service/
│ │ ├── RespSerializer.java Encodes/decodes the RESP wire protocol
│ │ ├── ResponseDto.java A small "text + optional binary" reply wrapper
│ │ └── CommandHandler.java Implements every Redis command
│ ├── Repository/
│ │ ├── Value.java One stored value + its metadata
│ │ └── Store.java The actual in-memory key-value map
│ └── Infra/
│ ├── Client.java One TCP connection + its I/O streams
│ ├── Slave.java A Client that happens to be a replica
│ └── ConnectionPool.java Tracks every connected Client/Slave
└── test/java/Components/
├── Repository/StoreTest.java Tests for the key-value store
└── Service/
├── RespSerializerTest.java Tests for protocol encode/decode
└── CommandHandlerTest.java A disabled/stale test (see Part 5.6)
A quick way to read this layout: Config wires the app together, Server contains the two network loops (master/replica) plus the shared runtime config, Service contains "business logic" (the protocol codec and the command dispatcher), Repository is the actual data storage, and Infra is low-level connection bookkeeping that everything else builds on. This is a fairly standard layered architecture — a common way to organize a mid-sized backend app so that "what does the data layer look like" and "how do I talk to a client" stay in separate, independently-understandable places.
Real Redis clients and servers talk to each other using RESP (the REdis Serialization Protocol) — a small, deliberately simple text-based protocol. Every piece of data on the wire starts with one character that says what kind of thing follows:
| Prefix | Type | Example on the wire | Meaning |
|---|---|---|---|
+ |
Simple string | +OK\r\n |
The literal string OK |
- |
Error | -ERR bad thing\r\n |
An error message |
: |
Integer | :42\r\n |
The number 42 |
$ |
Bulk string | $3\r\nfoo\r\n |
A 3-byte string, foo |
$-1\r\n |
Null bulk string | — | "No value" (like null/None) |
* |
Array | *2\r\n... |
An array of 2 more RESP values, each following right after |
Every message ends its "header" part with \r\n (carriage return + line feed — the same line-ending convention as HTTP headers). The key trick that makes RESP fast to parse is that you always know how many bytes to read next before you read them — a bulk string tells you its length up front, and an array tells you its element count up front, so a parser never has to guess where something ends or backtrack.
A command a client sends is always encoded as an array of bulk strings. Here's exactly how redis-cli's SET foo bar looks on the wire:
*3\r\n <- an array of 3 elements
$3\r\nSET\r\n <- element 1: a 3-byte bulk string, "SET"
$3\r\nfoo\r\n <- element 2: a 3-byte bulk string, "foo"
$3\r\nbar\r\n <- element 3: a 3-byte bulk string, "bar"
This project's RespSerializer.deseralize(byte[] command) (in Components/Service/RespSerializer.java) is the code that turns exactly that kind of byte sequence back into a Java String[] like {"SET", "foo", "bar"}. Walking through it: it scans the incoming bytes looking for a *; once found, it reads the digits that follow to get the element count; then a helper method, getParts, walks forward reading $<length>\r\n<data>\r\n chunks one at a time until it's collected that many strings. The outer loop keeps going until it runs out of bytes, so if a client "pipelines" several commands back-to-back in one write (*3\r\n...*2\r\n...), all of them come back as separate entries in the returned list.
One quirk worth knowing about if you read the code closely: deseralize also has a special branch for when a * is immediately followed by another * (instead of a $). That shape — an outer *N directly wrapping N inner arrays with no $ in between — isn't how real RESP pipelining looks (real pipelining is just independent arrays back-to-back, which the normal branch already handles correctly); it matches, byte for byte, the input used in RespSerializerTest.testMultipleCommands(). It's not obviously something a real Redis client or the CodeCrafters grader would ever actually send, so treat it as a documented curiosity rather than a protocol feature to rely on.
For sending replies and re-encoding outgoing commands, RespSerializer has the mirror-image methods: serializeBulkString, respInteger, and two overloads of respArray — one for a String[] of raw values (used to encode a command like ["SET","foo","bar"] for sending to a replica, wrapping each value in its own $len\r\n...\r\n), and one for a List<String> of already-RESP-encoded replies (used to build the array of results EXEC sends back, where each element is already something like "+OK\r\n" and just needs concatenating under one outer *N\r\n header, with no extra wrapping). They look similar but serve genuinely different purposes — worth remembering which is which if you're extending this code.
Here's how the pieces depend on each other (an arrow means "uses/calls"):
graph TD
Main[Main] --> AppConfig
Main --> MasterTcpServer
Main --> SlaveTcpServer
Main --> RedisConfig
MasterTcpServer --> CommandHandler
MasterTcpServer --> ConnectionPool
MasterTcpServer --> Store
SlaveTcpServer --> CommandHandler
SlaveTcpServer --> ConnectionPool
CommandHandler --> Store
CommandHandler --> RespSerializer
CommandHandler --> RedisConfig
CommandHandler --> ConnectionPool
Store --> RespSerializer
ConnectionPool --> Client
ConnectionPool --> Slave
Slave --> Client
We'll go through these in roughly dependency order — the same order the project's git history builds them up in — so nothing references something you haven't seen yet.
File: Components/Server/RedisConfig.java
A plain data holder, @Component-registered so Spring hands the same one instance to everything that asks for it — this is what makes it possible for Main to set the port once and have MasterTcpServer, SlaveTcpServer, and CommandHandler all see that same value. It holds:
role—"master"or"slave".port— which TCP port this instance listens on.masterHost/masterPort— only meaningful for a replica: where to find its master.masterReplId— a random-looking replication ID, generated lazily the first time it's asked for:(This "generate on first access, cache after that" pattern is a simple form of lazy initialization.)public String getMasterReplId() { if(masterReplId == null){ masterReplId = UUID.randomUUID().toString().replace("-", "") + UUID.randomUUID().toString().replace("-", "").substring(0, 8); } return masterReplId; }
masterReplOffset— a running byte counter used by replication bookkeeping; also lazily defaults to0.
Files: Components/Repository/Value.java, Components/Service/ResponseDto.java
Two small, unrelated data classes that are easy to confuse by name, so it's worth being clear on both up front.
Value is what actually sits inside the store for one key: the string val itself, when it was created, when it will expiry, and a boolean isDeletedInTransaction used only during transaction commits (more in 5.5 and 5.6).
ResponseDto ("Dto" = Data Transfer Object, a common name for "a class whose only job is to carry a bundle of related values from one place to another") pairs a text response with an optional raw byte[] data. It exists because most replies are pure text ("+OK\r\n"), but a couple — most notably the reply to PSYNC, which needs to send an actual binary RDB payload right after its text header — need to send raw bytes too.
Covered in full in Part 4 — this class is the protocol codec, and doesn't depend on anything else in the project.
Files under Components/Infra/
Client wraps one TCP connection: the raw Socket, its InputStream/OutputStream, a small integer id (assigned in connection order, mostly for logging), and the state needed for MULTI/EXEC transactions — a commandQueue of not-yet-executed commands and a transactionResponse list collecting their eventual results. It also has four overloaded send(...) methods (for a plain byte array, a plain string, a string+bytes pair, or a whole ResponseDto) — all of Java's method overloading at work: same method name, different parameter types, and Java picks the right one based on what you pass.
Slave is deliberately thin — it just wraps a Client (public Client connection;) and adds a capabilities list the master fills in from that replica's REPLCONF capa ... messages during the handshake. The relationship is: every Slave has a Client underneath it, but not every Client is a Slave — a plain read/write client connection is just a Client; the moment it identifies itself via REPLCONF listening-port, the server wraps that same Client in a new Slave and starts tracking it separately (see CommandHandler.replconf, in 5.6).
ConnectionPool is the shared registry both TCP servers and CommandHandler reach into: a Set<Client> of everyone currently connected, a Set<Slave> of everyone that's a replica, and two counters used by the WAIT command — bytesSentToSlaves (how many replication bytes the master has sent, in total, over its lifetime) and slavesThatAreCaughtUp (how many replicas have acknowledged catching up to that count, since the last time it was reset).
File: Components/Repository/Store.java
This is the actual key-value database — everything else is scaffolding around this one class. Internally it's just:
public ConcurrentHashMap<String, Value> map;
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();Two things are worth understanding well here, because they come up again and again (including in the interview section):
Why both a ConcurrentHashMap and a lock? A ConcurrentHashMap already guarantees that individual operations — one get, one put — are safe to call from multiple threads at once without corrupting the map's internals. What it does not give you is atomicity across several related operations. This project needs exactly that for transactions: EXEC has to apply a whole batch of queued commands and have the result look, to every other thread, like it happened all at once — either nobody sees any of it yet, or everybody sees all of it. That's what the ReentrantReadWriteLock is for: set/get/getValue take the shorter-lived read lock (multiple readers can hold it at the same time, since reads don't conflict with each other), while executeTransaction takes the write lock for its entire batch-apply-and-commit sequence, guaranteeing nothing else can observe — or interleave with — a transaction that's only half-applied.
Expiry is lazy, not active. There's no background thread anywhere sweeping the map for expired keys. Instead, every get/getValue call checks the key's expiry timestamp at read time, and only removes it then, if it's already in the past:
public Value getValue(String key) {
rwLock.readLock().lock();
try{
LocalDateTime now = LocalDateTime.now();
Value value = map.getOrDefault(key, null);
if(value!=null && value.expiry.isBefore(now)){
map.remove(key);
return null;
}
return value;
} finally{
rwLock.readLock().unlock();
}
}This is simple and correct from the caller's point of view — you will never be handed an expired value — but it means a key that expires and is never read again just sits in memory forever, unless something else eventually reads (and evicts) it. Real Redis actually does both: this same lazy check on access, plus a periodic background sweep that randomly samples keys and proactively deletes expired ones, specifically so idle expired keys don't pile up.
(You may also notice that map.remove(key) above runs while only the read lock is held, not the write lock — a small inconsistency in the locking discipline that happens to be harmless here only because ConcurrentHashMap.remove is itself safe to call concurrently. We call this out properly as a limitation in Part 11.)
There's a second, more interesting quirk sitting right next to that one, in the plain get method (as distinct from getValue, used by INCR):
public String get(String key){
rwLock.readLock().lock();
try{
LocalDateTime now = LocalDateTime.now();
Value value = map.get(key);
if(value!=null && value.expiry.isBefore(now)){
map.remove(key);
return "$-1\r\n";
}
return respSerializer.serializeBulkString(value.val);
} catch (Exception e) {
logger.log(Level.SEVERE, e.getMessage());
return "$-1\r\n";
}finally{
rwLock.readLock().unlock();
}
}When a key has never been set at all, map.get(key) returns null; the expiry check short-circuits on value != null and is skipped entirely; execution falls straight through to return respSerializer.serializeBulkString(value.val) — a NullPointerException, since value is null. This happens on every single lookup of a key that doesn't exist, and it is genuinely caught: the surrounding catch (Exception e) logs it at SEVERE and returns "$-1\r\n", which happens to be exactly the correct RESP reply for "not found." So a client sees the right answer — but it's arriving via exception-driven control flow for one of the most routine, non-exceptional operations a key-value store performs, and it means any cache-miss-heavy workload will flood the logs with SEVERE-level entries for completely ordinary behavior. This was confirmed while testing this project (see Part 9): a plain GET on a missing key logs a SEVERE "Cannot read field \"val\" because ... is null" entry every time, despite replying correctly.
File: Components/Service/CommandHandler.java
This is where every supported Redis command actually gets implemented. It's a fairly flat class: one public method per command (ping, echo, set, get, info, replconf, psync, wait, incr), each taking the parsed String[] command (and occasionally the Client that sent it) and returning the RESP-formatted reply string (or a ResponseDto, for the two commands — psync — that need to send raw bytes too).
A few of these deserve a closer look:
set checks whether a "px" flag (millisecond expiry) appears anywhere in the command array, and calls the matching Store.set overload:
int pxFlag = Arrays.stream(command).toList().indexOf("px");
if(pxFlag > -1){
int delta = Integer.parseInt( command[ pxFlag + 1 ] );
return store.set(key, value, delta);
}else{
return store.set(key, value);
}incr is worth reading carefully, because — unlike the transactional version of the same operation, described below — it's a genuine example of a read-modify-write race condition:
public String incr(String[] command) {
String key = command[1];
Value value = store.getValue(key);
if(value == null){
store.set(key, "0");
value = store.getValue(key);
}
int val = Integer.parseInt(value.val);
val++;
value.val = val+"";
res = respSerializer.respInteger(val);
...
}getValue takes and releases Store's read lock just for the lookup — by the time control returns here, the lock is already gone. Reading the current value, parsing it, incrementing it, and writing value.val back all happen with no lock held at all. If two clients call INCR on the same key at close to the same instant, both can read the same starting number, both compute "that plus one," and whichever write happens last simply overwrites the other — one of the two increments is silently lost. We'll come back to exactly how you'd fix this in Part 12.
replconf is called both when a would-be replica is doing its handshake (see 5.8) and later, when the master pings a connected replica for its offset. Its "listening-port" branch is the exact moment a plain Client connection formally becomes tracked as a Slave:
case "listening-port":
connectionPool.removeClient(client);
Slave s = new Slave(client);
connectionPool.addSlave(s);
return "+OK\r\n";psync handles the replica's full-resync request. If the replica says "I have no previous state" (replid == "?" and offset == "-1"), the master replies with a +FULLRESYNC <replid> <offset> line immediately followed by an RDB (Redis's binary snapshot format) payload — but that payload is a hardcoded, constant, essentially-empty RDB file (emptyRdbFile, a fixed Base64 blob decoded on every call), not an actual dump of whatever's currently in the store:
byte[] rdbFileData = Base64.getDecoder().decode(emptyRdbFile);This is the single most important thing to understand about replication in this project, so it's worth saying plainly: a replica that joins an already-populated master does not receive that master's existing data. It only starts receiving keys from the moment its handshake finishes onward, via the live command stream. If a real PSYNC <replid> <offset> (a partial resync request, from a replica that already has some history) comes in instead, the else branch just returns the plain string "Options not supported yet." — notably, not even wrapped as a proper RESP error (a real error should start with -), so a strict client parser would likely choke on it.
getTransactionCommandCacheApplier returns a BiFunction<String[], Map<String, Value>, String> — using the "function as a value" idea from Part 2 — that Store.executeTransaction calls once per queued command during EXEC. This is what actually applies SET/GET/INCR/DEL transactionally: each of these has its own handle...Transactional private method that reads from — and writes to — a local scratch Map passed in by Store, falling back to the real store only for keys the transaction hasn't touched yet. This is why the transactional INCR (handleIncrCommandTransactional) does not have the same race condition as the standalone one above: it only ever runs while Store is holding the write lock for the whole transaction, so there's no window for another thread to interleave.
Finally: CommandHandlerTest.java in the test folder is entirely commented out. It calls app.startServer(6379) with a port argument, but the current MasterTcpServer.startServer() takes no arguments (the port comes from RedisConfig instead) — so this test predates a later refactor and was left disabled rather than rewritten or deleted.
File: Components/Server/MasterTcpServer.java
The main loop, in outline:
ServerSocket serverSocket = new ServerSocket(port);
while (true) {
Socket clientSocket = serverSocket.accept(); // blocks until someone connects
Client client = new Client(clientSocket, ...);
CompletableFuture.runAsync(() -> handleClient(client)); // handle them in the background
}accept() blocks (pauses the calling thread) until a new TCP connection arrives; the moment one does, its handling is handed off asynchronously (see Part 2's note on CompletableFuture) so the loop can immediately go back to waiting for the next connection. handleClient then loops for the lifetime of that one connection, reading raw bytes, handing them to RespSerializer.deseralize, and calling handleCommand once per parsed command.
handleCommand is where transaction state gets checked before anything else: if the client isn't inside MULTI, the command runs immediately via caseHandler (the big command-name switch statement) and the reply goes straight back; if the client is inside MULTI and the command isn't EXEC/DISCARD, it just gets queued instead of executed. caseHandler's SET branch shows the replication trigger in context:
case "SET":
res = commandHandler.set(command);
...
CompletableFuture.runAsync(()->propagate(command));
break;The write is applied to the local store synchronously (so the reply to the client is always consistent with what's actually stored), and propagating that same command out to every connected replica happens afterwards, on another background task — the client doesn't wait for any replica to receive or apply it. That's what makes this asynchronous replication by default (more on this trade-off in Part 10).
File: Components/Server/SlaveTcpServer.java
A replica does two jobs at once, both started from the same startServer(): it runs its own accept-loop (identical in shape to the master's, so a replica can also serve read-only client connections), and it kicks off initiateSlavery() in the background to connect out to its configured master and perform the replication handshake.
The handshake, faithfully modeled on real Redis's actual protocol, is four steps: PING (basic connectivity check), REPLCONF listening-port <port> (tell the master where this replica itself listens), REPLCONF capa psync2 (declare capabilities), and finally PSYNC ? -1 (request a full resync, since this replica has no prior history). After that handshake, the replica sits in a loop reading raw bytes off the master's socket, reconstructing each propagated command, and applying it locally via handleCommandFromMaster — which, interestingly, also calls its own propagate(command) afterward. Because every server (master or replica) shares the same ConnectionPool/propagate machinery, a replica that itself has sub-replicas connected will automatically forward what it receives — this codebase supports chained replication as a natural side effect of that shared design, not as a special case anyone had to write extra code for.
A client that connects directly to a replica and tries to write gets turned away explicitly, without touching the store at all:
case "SET":
res = "-READONLY You can't write against a replica.\r\n";
break;Files: Main.java (default package), Config/AppConfig.java
AppConfig is three lines of actual code — @Configuration plus @ComponentScan(basePackages = "Components") — and its entire job is telling Spring where to look for @Component classes, as covered in Part 2.
Main is the composition root: it builds the Spring context, pulls out the beans it needs (MasterTcpServer, SlaveTcpServer, RedisConfig), defaults to port 6379 and role "master" (matching real Redis's default port), then walks the command-line arguments looking for --port <n> and --replicaof "<host> <port>":
flowchart TD
Start([Main starts]) --> Parse[Parse CLI args]
Parse -->|--replicaof present| SlaveRole[role = slave]
Parse -->|no --replicaof| MasterRole[role = master]
SlaveRole --> RunSlave[SlaveTcpServer.startServer]
MasterRole --> RunMaster[MasterTcpServer.startServer]
Whichever server actually gets started (slave.startServer() or master.startServer()) then runs forever — this call is the last thing main does, and the JVM stays alive as long as that method doesn't return, which for these two is "until the process is killed," since both contain infinite while(true) accept loops.
A client connects to a lone master (no replicas) and runs SET foo bar.
sequenceDiagram
participant C as redis-cli
participant M as MasterTcpServer
participant CH as CommandHandler
participant S as Store
C->>M: SET foo bar (RESP bytes)
M->>M: deserialize into ["SET","foo","bar"]
M->>CH: set(command)
CH->>S: set("foo", "bar")
S->>S: acquire write lock, map.put
S-->>CH: "+OK"
CH-->>M: "+OK"
M-->>C: +OK
M->>M: propagate(command) to replicas (async)
Step by step: the raw bytes arrive on MasterTcpServer.handleClient's read loop → RespSerializer.deseralize turns them into {"SET","foo","bar"} → handleCommand sees the client isn't in a transaction, so it calls caseHandler → that dispatches to commandHandler.set(...) → which calls store.set("foo","bar") → Store takes its write lock, wraps the value in a new Value object with expiry = LocalDateTime.MAX (meaning "never"), and puts it in the map → "+OK\r\n" travels back up through every layer and out onto the socket. Only after replying to the client does the master fire off propagate(command) in the background to tell any connected replicas about this write.
sequenceDiagram
participant Slave as SlaveTcpServer
participant Master as MasterTcpServer
Slave->>Master: PING
Master-->>Slave: +PONG
Slave->>Master: REPLCONF listening-port port
Master-->>Slave: +OK
Slave->>Master: REPLCONF capa psync2
Master-->>Slave: +OK
Slave->>Master: PSYNC ? -1
Master-->>Slave: +FULLRESYNC replid offset
Master-->>Slave: RDB payload (empty snapshot)
loop every future write
Master->>Slave: propagated command
end
This is a direct, byte-for-byte implementation of real Redis's handshake sequence — right up until the RDB payload, which (as covered in 5.6) is always an empty placeholder here rather than an actual snapshot of the master's current data. So: everything the master writes after this handshake completes reaches the replica; anything that was already there before the replica connected does not.
A client runs MULTI, then queues SET a 1 and INCR a, then EXECs them.
sequenceDiagram
participant C as Client
participant M as MasterTcpServer
participant S as Store
C->>M: MULTI
M-->>C: +OK
C->>M: SET a 1
M-->>C: +QUEUED
C->>M: INCR a
M-->>C: +QUEUED
C->>M: EXEC
M->>S: executeTransaction(queue)
S->>S: acquire write lock once
S->>S: apply queued commands to a local scratch copy
S->>S: commit scratch copy into the real map, release lock
S-->>M: per-command responses
M-->>C: array of responses
M->>M: propagate each queued command (async)
Notice that nothing is applied to the real store between MULTI and EXEC — SET a 1 and INCR a are only ever pushed onto client.commandQueue, and the +QUEUED reply for each is a fixed string, not a result of actually running anything (this matches real Redis's wire behavior for queued commands exactly). The real work all happens inside Store.executeTransaction, which takes the write lock once for the whole batch, replays every queued command against a private, temporary HashMap (falling back to the real store only for keys not yet touched this transaction), and only copies that scratch map into the real one once every command has succeeded — meaning no other thread can ever observe the transaction half-applied. After the lock is released, each of the two original commands is separately re-encoded and propagated to replicas — so a replica sees SET a 1 and INCR a arrive as two ordinary, independent commands, with no explicit signal that they were ever part of one transaction on the master.
A few ideas from this codebase are worth understanding on their own, since they come up in some form in almost every backend system you'll ever work on:
Thread-per-connection. Every accepted socket gets its own background task (Part 2's CompletableFuture.runAsync) that lives for as long as that connection does. It's simple to reason about — you can read handleClient top to bottom as "what happens to one connection" without thinking about any other connection — but it doesn't scale indefinitely: each connection ties up a thread (or a task competing for the shared pool's threads) even while it's just sitting idle waiting for the next byte to arrive. Real Redis takes the opposite approach: a single thread runs an event loop that services many sockets by only ever touching a connection when it actually has data ready, using non-blocking I/O — much more scalable to huge numbers of idle-but-open connections, at the cost of being considerably harder to write and reason about.
Race conditions. A race condition is exactly what it sounds like: the correctness of the result depends on the timing ("who gets there first") of two or more threads, instead of being guaranteed regardless of timing. The standalone INCR command (5.6) is a real one: read, then modify, then write, with no lock held across all three steps, so two concurrent increments on the same key can step on each other and lose an update.
Why a ReentrantReadWriteLock instead of a plain mutex? A plain lock (ReentrantLock, or Java's built-in synchronized) only ever lets one thread in at a time, full stop — even two threads that only want to read have to take turns. A read-write lock instead lets any number of readers hold the read lock simultaneously (since two reads can never conflict with each other), and only forces exclusivity when a writer needs to get in. For a key-value store — where reads (GET) are typically far more frequent than writes (SET) — that difference can matter a lot for throughput under concurrent load.
ConcurrentHashMap and the lock are solving different problems, not duplicating each other. The map guarantees any single get/put call is internally safe. The lock guarantees a whole sequence of operations — specifically, a transaction's batch-apply-then-commit — looks atomic to everyone else. You genuinely need both: dropping the map in favor of a plain HashMap would make even single get/put calls unsafe; dropping the lock would remove the "all or nothing" guarantee transactions depend on.
The application code is only about half of this repository. The rest is the infrastructure needed to build, ship, and run it — worth understanding even briefly if you've never touched Docker/CI/Kubernetes before, since these tools show up in almost every real backend job.
Docker. A container packages an application together with everything it needs to run (a specific Java runtime, in this case) into one portable image, so it behaves the same on any machine that can run Docker. This project's Dockerfile uses a multi-stage build:
FROM eclipse-temurin:23-jdk-alpine AS build
...
RUN mvn -B package -DskipTests -Ddir=/tmp/codecrafters-build-redis-java
FROM eclipse-temurin:23-jre-alpine
COPY --from=build /tmp/codecrafters-build-redis-java/redis-java.jar .
ENTRYPOINT ["java", "-jar", "redis-java.jar"]The first stage uses a full JDK (Java Development Kit — needed to compile) plus Maven to build the fat jar; the second stage starts fresh from a much smaller JRE-only (Java Runtime Environment — enough to run compiled Java, but no compiler) base image and copies in just the finished jar. The build tools, source code, and Maven's dependency cache never make it into the final image — only the one file actually needed to run the app does, keeping the shipped image considerably smaller.
Azure Pipelines (CI). azure-pipelines.yml defines a pipeline that triggers on pushes to master, builds the Docker image above, and pushes it to Docker Hub under beelzekamibub/redis-java, tagged both with a unique build ID and with latest. It runs on a self-hosted agent pool named agent rather than one of Azure's own hosted runners — that's what the dind/ folder (Docker-in-Docker) is for: it's a small Alpine-based image that, when run, downloads and configures the actual Azure Pipelines agent software for whatever machine it's running on and starts it listening for jobs, giving the pipeline somewhere with real Docker build/push access to execute on.
kind + Helm (local Kubernetes). Kubernetes runs and manages containers at scale — restarting ones that crash, and giving you a declarative way to describe "I want N copies of this container running." kind ("Kubernetes IN Docker") runs a real, small Kubernetes cluster entirely inside Docker containers on your own machine, so you can test Kubernetes deployments without needing an actual cloud account. kind/k.yml configures one such cluster with port 30007 on your machine mapped through to the same port inside the cluster. Helm is a templating and packaging tool for Kubernetes manifests — instead of hand-editing raw YAML files for every environment, you write one templated chart (this project's redis-chart/) with the differences pulled out into a values.yaml file. The chart here defines a Pod (one running instance of the container) and a Service of type NodePort (exposes that pod's port 6379 on the cluster node's port 30007, which is why it lines up with the port kind/k.yml maps through). Note imagePullPolicy: Never in values.yaml — this tells Kubernetes "don't try to download this image from a registry, it's already been loaded locally" (via kind load docker-image ..., documented in the README), which is the standard way to test a locally-built image on a kind cluster without needing to actually publish it anywhere first.
ping.ps1. A minimal manual smoke test: it opens a raw TCP socket straight to localhost:30007 (the exact NodePort the kind/Helm setup above exposes) and sends a hand-encoded RESP PING, printing whatever comes back. It's a good example of testing a network service at the lowest possible level — no Redis client library, just a socket and the protocol bytes from Part 4. (It also defines — but never actually sends — a SET/GET pair of commands, so it currently only exercises PING; extending it to send those too would just mean adding two more $writer.Write(...) calls.)
Locally, with Maven installed:
./your_program.sh # builds, then starts a master on port 6379
./your_program.sh --port 6380 # start on a different port
./your_program.sh --port 6380 --replicaof "localhost 6379" # start as a replica of the aboveOnce it's running, you can talk to it with a real redis-cli, or by hand with netcat:
printf '*1\r\n$4\r\nPING\r\n' | nc localhost 6379redis-cli -p 6379 PING # -> PONG
redis-cli -p 6379 SET foo bar # -> OK
redis-cli -p 6379 GET foo # -> "bar"
redis-cli -p 6379 SET temp value1 PX 200 # expires in 200ms
redis-cli -p 6379 GET temp # -> "value1" if you're quick, (nil) after ~200ms
redis-cli -p 6379 INCR counter # -> (integer) 1MULTI/EXEC need one continuous connection rather than separate redis-cli invocations, since transaction state lives on the Client object behind that one socket — use interactive mode:
redis-cli -p 6379
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET a 1
QUEUED
127.0.0.1:6379> INCR a
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (integer) 2To see replication work, start a second instance as a replica in another terminal and watch a master-side write show up on it:
./your_program.sh --port 6380 --replicaof "localhost 6379"
redis-cli -p 6379 SET shared hello # write on the master
redis-cli -p 6380 GET shared # -> "hello" on the replica, shortly after
redis-cli -p 6380 SET shared nope # -> (error) READONLY You can't write against a replica.Remember from Part 5.6/Part 11: this only shows a write made after the replica connects — start the replica first if you want to see it catch a specific write, since anything already on the master before that won't transfer.
To see the documented INCR race condition for yourself, hit the same key with many concurrent clients — a quick backgrounded loop is enough:
redis-cli -p 6379 SET race 0
for i in $(seq 1 300); do redis-cli -p 6379 INCR race > /dev/null & done; wait
redis-cli -p 6379 GET race # may come back a little short of 300 -- rerun a few times if not(Each redis-cli call here is a whole separate OS process, so keep this number modest — a few hundred concurrent connections is plenty to occasionally expose the race without straining your machine. It won't necessarily lose an update on every run, since races are timing-dependent.)
With Docker:
docker build -t redis-java .
docker run -p 6379:6379 redis-javaRunning the tests:
mvn testStoreTest and RespSerializerTest will run; CommandHandlerTest is skipped automatically, since it's entirely commented out (5.6).
| Decision | Why it was (reasonably) made this way | The trade-off being accepted |
|---|---|---|
| Spring for dependency injection | Familiar, well-documented pattern for wiring a handful of long-lived, shared objects (Store, ConnectionPool, RedisConfig) without every class constructing its own dependencies |
Spring adds real startup overhead and a layer of "magic" (fields get filled in by something you never see call a constructor) that a project this size doesn't strictly need — a few classes and manual wiring in Main would also have worked |
Thread-per-connection (CompletableFuture.runAsync) |
Simple to write and reason about; each connection's logic reads top-to-bottom with no interleaving to track mentally | Doesn't scale as well to very large numbers of concurrent (mostly idle) connections as a non-blocking event-loop model would — which is exactly what real Redis uses instead |
| Lazy-only key expiry | Correct from the caller's perspective with very little code; no background thread to manage | Expired keys that are never read again just sit in memory forever — real Redis backs this up with an active background sweep too |
| Hand-rolled RESP parser (no library) | The entire point of the CodeCrafters challenge is understanding the protocol at the byte level | More code to get right than pulling in an existing client/protocol library would have needed |
Asynchronous replication by default, with WAIT as an opt-in stronger guarantee |
Keeps normal writes fast — the client doesn't pay the cost of round-tripping to every replica on every single write | Without calling WAIT, a client has no guarantee a replica has actually received a write it just got +OK for |
| Full resync always sends an empty RDB snapshot | Implementing a real point-in-time snapshot serializer is a substantial extra piece of work, and the empty payload is enough to satisfy the wire protocol for the CodeCrafters stages this targets | A replica joining a master that already has data does not receive that existing data — only new writes made after it joins |
Being upfront about what this project doesn't do is just as useful as understanding what it does — both for your own understanding, and because "what would you add next" is an extremely common interview follow-up once you've walked someone through a project like this.
- No on-disk persistence at all. Real Redis can write periodic RDB snapshots and/or an append-only log (AOF) of every write, either of which lets it reload its dataset after a restart. This project loses everything the instant the process stops — the "RDB" it sends over the wire during replication isn't even a real snapshot, just a fixed placeholder (5.6).
- Full resync doesn't transfer existing data, as covered above — replication only carries writes made after a replica connects.
- Partial resync isn't implemented. A reconnecting replica that already has some history can't ask "just give me what I missed" —
PSYNCwith a real replication ID and offset just gets back a plain, non-RESP-framed"Options not supported yet."string. - No automatic failover. There's no heartbeat/failure-detection between master and replicas, and nothing here would promote a replica if its master disappeared. Real deployments typically add a separate component for this — Redis Sentinel (dedicated monitor processes that vote on whether a master is actually down) or Redis Cluster (built-in gossip and automatic failover).
- A real, if narrow, concurrency bug in standalone
INCR(5.6/Part 7) — a lost-update race that the transactional version of the same command doesn't share, purely because of which locking path each one goes through. This isn't just theoretical: running 2,000INCRs on a single key from 10 concurrent connections (see Part 9) produced a final stored value of 1,999 in testing — one update silently lost. Store.get()returns the right answer for a missing key by way of a caughtNullPointerException, not an explicit check (5.5) — harmless to correctness, but it means every ordinary cache miss logs aSEVERE-level error, which would bury real problems in log noise under any real workload.WAITbusy-waits. Its polling loop (while(true){ ...; res = connectionPool.slavesThatAreCaughtUp; }) has no sleep or blocking wait at all — it spins as fast as the CPU allows until either enough replicas ack or the timeout passes, burning a full core the whole time instead of parking the thread until there's actually something new to check.- A handful of smaller protocol gaps:
MULTIcalled while already inside a transaction just silently returns+OKagain (real Redis returns an error refusing the nestedMULTI); aPINGqueued inside a transaction fails with "unknown command" atEXECtime, since the transactional command applier only recognizesSET/GET/INCR/DEL; andINFOwith noreplicationargument returns an empty string that never even gets written to the socket (Client.sendskips writing when the response is empty), rather than a valid (if empty) RESP reply. - Only a handful of data types and commands. Real Redis has Lists, Hashes, Sets, Sorted Sets, Streams, pub/sub, Lua scripting, and hundreds of commands; this project only implements the single string-keyed, string-valued command set listed in Part 1.
- Single machine, single process. Everything lives in one
ConcurrentHashMapon one JVM — there's no sharding/partitioning story for spreading keys across multiple independent nodes the way Redis Cluster does.
None of this is a criticism of the project for what it is — a from-scratch learning exercise in exactly the areas (protocol design, concurrency, replication) these gaps sit next to. It's exactly the kind of list worth having ready if someone asks "what would you build next."
These are grouped so you can practice one area at a time. Every answer here is grounded in something actually in this codebase — if an interviewer pushes further, you can always point at the specific file and line of behavior.
Q: In one or two sentences, what does this project do? It's a from-scratch, Redis-protocol-compatible server written in Java, built around CodeCrafters' "Build Your Own Redis" challenge — it supports basic string commands, expiry, transactions, and master/replica replication over a hand-implemented version of Redis's real wire protocol.
Q: Walk me through exactly what happens when a client sends SET key value.
The bytes arrive on the accept-loop's per-connection background task and get turned into a String[] by RespSerializer.deseralize; MasterTcpServer.handleCommand checks the client isn't mid-transaction and dispatches to caseHandler, which calls CommandHandler.set; that resolves any px expiry flag and calls the matching Store.set overload, which takes a write lock, wraps the value and its expiry in a Value object, and puts it in the underlying ConcurrentHashMap; "+OK\r\n" is returned back up the call stack and written to the client's socket; only after that does the master asynchronously propagate the same command to any connected replicas.
Q: How does the server handle many clients at once?
Thread-per-connection: the accept loop hands each newly-accepted socket off to CompletableFuture.runAsync(...), so each connection's read-parse-dispatch-reply loop runs independently in the background while the main loop immediately goes back to accepting the next connection.
Q: What's different about how a master and a replica handle the same SET command from a client?
A master applies it to its own store and then propagates it onward; a replica flatly refuses it — SlaveTcpServer.handleCommand's SET branch returns "-READONLY You can't write against a replica.\r\n" without touching the store at all. A replica only ever gets new data by receiving propagated commands from its own master, never by accepting direct writes from clients.
Q: Why does Main.java have no package declaration?
It intentionally sits in Java's "default" (unnamed) package, which matches pom.xml's <mainClass>Main</mainClass> entry (no package prefix needed). It's fine for a single, top-level entry-point class like this, though putting classes in the default package is generally discouraged in larger projects, since classes in named packages can't import anything from it.
Q: What is a race condition, and can you point to a real one in this codebase?
A race condition is a bug whose presence (or absence) depends on the relative timing of two or more threads rather than being guaranteed regardless of timing. The standalone INCR command is a real one here: it reads the current value, releases the lock it used only for that read, then computes and writes the new value with no lock held at all — so two concurrent INCRs on the same key can both read the same starting number and one increment gets silently lost. The transactional version of INCR doesn't have this problem, because it only ever runs while Store's write lock is already held for the whole transaction.
Q: How would you fix the INCR race condition?
Simplest fix: take the write lock for the whole read-modify-write sequence, the same way set/get do, instead of only around the initial lookup. A more Java-idiomatic alternative would be to back numeric values with something like an AtomicLong and use its incrementAndGet(), or use ConcurrentHashMap.compute(key, ...), which guarantees the whole read-modify-write happens atomically for that one key without needing a lock covering the entire map.
Q: Why use both a ConcurrentHashMap and a ReentrantReadWriteLock? Isn't the lock redundant?
No — they solve different problems. ConcurrentHashMap guarantees any single get/put is safe to call concurrently without corrupting the map. It does not give you atomicity across a sequence of related operations. The lock exists specifically so a whole transaction (many queued commands, applied one after another, then committed) looks atomic to every other thread — nobody can observe it half-done. You need both: without the map, even single operations would be unsafe; without the lock, transactions could interleave with other clients' reads and writes.
Q: What's the difference between a plain mutex and a read-write lock, and why pick the latter here?
A plain mutex (synchronized, or ReentrantLock) allows only one thread in at a time, period — even two threads that both only want to read have to take turns. A ReentrantReadWriteLock allows any number of readers to hold the read lock at once (since reads can't conflict with each other) and only forces exclusivity for a writer. Since a key-value store is typically read far more often than it's written, this lets concurrent GETs proceed in parallel instead of needlessly serializing them.
Q: Store.getValue removes an expired key while only holding the read lock. Is that a bug?
It's an inconsistency in locking discipline rather than a data-corrupting bug — ConcurrentHashMap.remove is itself safe to call from multiple threads concurrently, so it won't corrupt the map. But it does mean a "read" operation is quietly also a "write" in this one case, which undermines the read lock's implied guarantee that nothing changes while it's held. The clean fix would be to upgrade to the write lock (or use a lock-free compute-based removal) before mutating.
Q: What does CompletableFuture.runAsync(...) actually do, and what thread does the work run on?
It schedules the given block of code to run asynchronously — by default, on a thread borrowed from the JVM-wide "common ForkJoinPool" — and returns immediately without waiting for it to finish. It's being used here purely as a "fire off a background task" mechanism (accepting the next connection without waiting for the current one's handler to finish, or propagating to replicas without blocking the client's reply) rather than for its more common use case of composing chains of asynchronous results.
Q: What is RESP, and why wouldn't Redis just use JSON?
RESP (REdis Serialization Protocol) is Redis's own wire protocol: every value is prefixed by a character announcing its type, and lengths are always given up front, so a parser never has to guess where something ends or backtrack. That makes it both very cheap to parse and simple enough to type by hand over a raw TCP connection (with netcat, for instance) for debugging. JSON would add real per-message parsing overhead (a full recursive-descent parse, string escaping) for what are, in practice, almost always short, flat, textual commands over a long-lived connection — overhead RESP was specifically designed to avoid.
Q: Walk me through parsing *2\r\n$4\r\nECHO\r\n$2\r\nhi\r\n by hand.
*2 says "an array of 2 elements follows." The first element, $4\r\nECHO\r\n, is a 4-byte bulk string, "ECHO". The second, $2\r\nhi\r\n, is a 2-byte bulk string, "hi". So the parsed command is ["ECHO", "hi"] — exactly what RespSerializer.deseralize produces by scanning for the *, reading its count, then calling getParts twice to pull out each $-prefixed bulk string in turn.
Q: Why TCP instead of UDP here?
TCP gives reliable, in-order delivery over a persistent connection — both properties this protocol depends on. Redis commands need to arrive in the order they were sent (imagine a SET and a subsequent GET on the same key arriving out of order), and UDP gives you neither ordering nor guaranteed delivery; you'd have to reimplement both at the application layer to get equivalent behavior, which isn't worth it for what's fundamentally a request/response and streaming-command use case.
Q: Is this implementation using blocking or non-blocking I/O? What's the trade-off?
Blocking — InputStream.read() pauses the calling task until bytes actually arrive, and each connection gets its own task (thread-per-connection, as covered above). It's much simpler to write and reason about than a non-blocking/event-loop model, but it doesn't scale as well to very large numbers of mostly-idle open connections, since each one ties up a task/thread just waiting. Real Redis is built around a single-threaded event loop with non-blocking sockets for exactly this reason.
Q: Is replication here synchronous or asynchronous?
Asynchronous by default: the master applies a write and replies to the client immediately, then propagates it to replicas afterward on a separate background task — the client never waits for any replica to acknowledge. WAIT <numreplicas> <timeout> exists as an opt-in way to get a stronger guarantee for a specific write, by having the client explicitly block until enough replicas confirm they've caught up (or the timeout passes) — mirroring how real Redis's WAIT command works.
Q: What's the difference between full resync and partial resync? Which does this implement?
Full resync means a replica with no relevant prior state gets the master's entire current dataset before switching to a live stream of new writes. Partial resync lets a replica that already has some history (identified by a replication ID and offset) ask for just what it's missing, avoiding a full retransfer. This project only implements full resync (PSYNC ? -1) — a request with a real ID/offset instead falls through to a stub "Options not supported yet." reply.
Q: What actually happens to data a master already has when a new replica connects?
It's not transferred. psync()'s full-resync path always sends a fixed, hardcoded, essentially-empty RDB payload regardless of what's actually in the store at that moment — so a joining replica starts genuinely empty, and only receives keys created by commands issued after its handshake completes.
Q: How would you detect a master failure and fail over to a replica? Does this project support that? It doesn't — there's no heartbeat or failure-detection between master and replicas at all here, and nothing would automatically promote a replica if its master went away. In real systems this is normally a separate concern: Redis Sentinel runs independent monitor processes that vote (a quorum) on whether a master is actually down before orchestrating a promotion; Redis Cluster instead builds gossip-based failure detection and automatic failover directly into the cluster nodes themselves.
Q: What is a "replication offset," and how is it tracked here?
It's a running count of bytes of replication stream a replica has processed, used to answer "how caught up is this replica?" On the replica side, RedisConfig.masterReplOffset accumulates as bytes are consumed from the master's stream in initiateSlavery; on the master side, ConnectionPool.bytesSentToSlaves tracks how many bytes the master has sent in total. WAIT compares the two — it asks each replica to report its offset (REPLCONF GETACK *) and considers a replica "caught up" only once its reported offset matches what the master has actually sent.
Q: How would you add real persistence, so data survives a restart? Two standard approaches, which real Redis actually combines: periodic RDB snapshots (serialize the whole dataset to disk on an interval, or on shutdown, and reload it on startup) and an append-only file (AOF) log of every write command, replayed in order on startup to rebuild state. This project currently has neither — the "RDB" it sends during replication is a fixed placeholder, not a real snapshot, and nothing is ever written to disk.
Q: How would you add more data types, like Lists or Hashes?
Value would need to hold something more general than a single String val — a discriminated union or a generic payload type — and CommandHandler would need new command methods (LPUSH/RPUSH, HSET/HGET, and so on), each also wired into the transactional command applier if they need to work inside MULTI/EXEC. RespSerializer would likely need extending too, since some of these commands' replies are nested arrays rather than the flat bulk-string/integer replies this project currently produces.
Q: How would this scale horizontally, the way Redis Cluster does?
Redis Cluster partitions the whole keyspace into a fixed number of hash slots (16,384) spread across independent master nodes (each optionally with its own replicas), and a client-side or proxy layer routes each command to the node that owns the relevant slot for its key. This project has no such story — everything lives in a single ConcurrentHashMap on one JVM, so it's fundamentally capped by one machine's available RAM and network throughput.
Q: What would you change about the WAIT implementation?
It currently busy-waits — spinning in a tight while(true) loop with no sleep, checking a shared counter as fast as the CPU allows until enough replicas ack or the timeout passes. That burns a full core the whole time it's waiting. A cleaner approach would use a real concurrency primitive — a CountDownLatch (or a CompletableFuture) created per pending WAIT call and completed the moment enough acks arrive — or, at minimum, a short sleep inside the polling loop so it isn't spinning at full speed.
Q: What problem does Dependency Injection actually solve? Without it, if class A needs a B, A's own code has to know how to construct one — and if B itself needs a C, that knowledge of "how to build things" spreads transitively through the whole codebase. With DI, a class just declares what it needs, and a container (here, Spring's application context) is responsible for constructing everything and wiring the right instances together. It also makes testing easier, since you can substitute a fake/mock dependency without changing the class under test at all.
Q: What do @Component, @Autowired, and @ComponentScan each do?
@Component marks a class as something Spring should manage one shared instance of (a "bean"). @Autowired on a field or constructor tells Spring "inject the matching bean here," resolved by type when the application context starts up. @ComponentScan(basePackages = "Components"), on AppConfig, tells Spring which package (and its subpackages) to search for @Component-annotated classes in the first place.
Q: This project uses field injection (@Autowired directly on public fields). Is that good practice?
It's common and quick to write, but generally considered weaker than constructor injection for a few concrete reasons: it hides a class's real dependencies (you have to scan every field instead of reading one constructor signature), it prevents making those fields final/immutable, and it makes the class much harder to construct outside of Spring — which is likely part of why StoreTest reaches for a full @SpringBootTest context instead of just calling new Store() directly in a plain unit test. Constructor injection is generally the preferred modern style for these reasons.
Q: What is BiFunction<String[], Map<String, Value>, String> doing in CommandHandler?
It's Java's built-in generic type for "a function that takes two arguments (here, a parsed command and a transaction's local scratch map) and returns one result (the RESP-encoded reply)." getTransactionCommandCacheApplier() builds and returns one of these so that Store.executeTransaction can apply arbitrary queued commands without needing to know anything about individual command names itself — it's a lightweight version of the strategy pattern, implemented with a lambda instead of a full interface-plus-implementing-class.
Q: Where do you see checked exceptions being handled in this codebase, and how?
IOException (from socket/stream operations) is the recurring one — methods like handleClient declare throws IOException and let it propagate to their caller, while others catch it and log it. A specific pattern shows up in both TCP servers: because the lambda passed to CompletableFuture.runAsync isn't allowed to declare a checked exception itself, an IOException caught inside it gets re-thrown wrapped in an unchecked RuntimeException instead — the standard way to carry a checked exception across a boundary (like a functional-interface lambda) that doesn't support declaring one.
Q: What does Maven's pom.xml actually configure here?
The Java version (23), the two Spring Boot dependencies (spring-boot-starter for core DI — notably not spring-boot-starter-web, since this app opens its own raw sockets instead of using an embedded web server — and spring-boot-starter-test for JUnit 5, scoped to tests only), and a Maven Assembly Plugin configuration that bundles the compiled classes and all dependencies into one self-contained "fat jar," so the final artifact can run anywhere with a plain java -jar.
If you're reading this alongside the repo's git history: the commits are ordered to match the same progression this document follows — config and data models first, then the RESP protocol, then the connection layer, then the store, then the command handler, then the two TCP servers, then the entry point wiring it all together, and finally the deployment tooling.