Milestone 3 Tutorial: Building the Modular RESP Parser & Server

October 16, 2026

AI Agent

🏗️ The 4-File Project Layout

We will create:

  1. RespParser.java: Handles protocol serialization and deserialization.
  2. Database.java: Encapsulates the in-memory map and command routing.
  3. RedisServer.java: Manages the socket server, thread pool, and client read loops.
  4. Main.java: Bootstraps the application.

📦 Module 1: Building the Protocol Parser (RespParser.java)

Create a file named RespParser.java in your root directory. We will build it inside the public class RespParser { ... } block step-by-step.

Step 1.1: File Shell & Imports

Create the file and add the required standard Java imports:

import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; public class RespParser { // We will build all parser components inside this class block }

Step 1.2: Defining RESP Data Types (The Type Enum)

Under the RESP2 protocol, there are 5 core data types. We define an Enum to represent them.

Add this enum inside the RespParser class:

public enum Type { SIMPLE_STRING, ERROR, INTEGER, BULK_STRING, ARRAY }

Step 1.3: Creating the Data Holder (RespValue Class)

We need a class to hold parsed values and their type.

Add this static inner class skeleton inside the RespParser class:

public static class RespValue { public final Type type; public final Object value; // Can hold String, Long, or List<RespValue> public RespValue(Type type, Object value) { this.type = type; this.value = value; } // We will write the serialize() method next inside this RespValue class }

Step 1.4: Serialization — Simple String (+)

  • RESP Protocol Rule: Prefix with +, follow with string, and terminate with \r\n (CRLF).
  • Example: "OK" $\rightarrow$ "+OK\r\n"

We start implementing the serialize() method inside the RespValue class:

public byte[] serialize() { switch (type) { case SIMPLE_STRING: return ("+" + value + "\r\n").getBytes(StandardCharsets.UTF_8);

Step 1.5: Serialization — Error (-)

  • RESP Protocol Rule: Prefix with -, follow with error message, and terminate with \r\n.
  • Example: "ERR" $\rightarrow$ "-ERR\r\n"

Add this case to the switch statement in the serialize() method:

case ERROR: return ("-" + value + "\r\n").getBytes(StandardCharsets.UTF_8);

Step 1.6: Serialization — Integer (:)

  • RESP Protocol Rule: Prefix with :, follow with numeric digits, and terminate with \r\n.
  • Example: 1000 $\rightarrow$ ":1000\r\n"

Add this case to the switch statement in the serialize() method:

case INTEGER: return (":" + value + "\r\n").getBytes(StandardCharsets.UTF_8);

Step 1.7: Serialization — Bulk String ($)

  • RESP Protocol Rule: Prefix with $, follow with the length of the string, terminate with \r\n, follow with the raw string contents, and terminate with a final \r\n.
  • Null Rule: If value is null, return $-1\r\n.
  • Example: "hello" $\rightarrow$ "$5\r\nhello\r\n"

Add this case to the switch statement in the serialize() method:

case BULK_STRING: if (value == null) { return "$-1\r\n".getBytes(StandardCharsets.UTF_8); } String str = (String) value; return ("$" + str.length() + "\r\n" + str + "\r\n").getBytes(StandardCharsets.UTF_8);

Step 1.8: Serialization — Array (*)

  • RESP Protocol Rule: Prefix with *, follow with the array size, terminate with \r\n, and then serialize each element in the array sequentially.
  • Null Rule: If list is null, return *-1\r\n.

Complete the serialize() method by adding the array case and closing the method block:

case ARRAY: if (value == null) { return "*-1\r\n".getBytes(StandardCharsets.UTF_8); } @SuppressWarnings("unchecked") List<RespValue> list = (List<RespValue>) value; ByteArrayOutputStream bos = new ByteArrayOutputStream(); try { bos.write(("*" + list.size() + "\r\n").getBytes(StandardCharsets.UTF_8)); for (RespValue val : list) { bos.write(val.serialize()); // Recursive serialization } } catch (IOException ignored) {} return bos.toByteArray(); default: throw new IllegalArgumentException("Unknown RESP type"); } }

Step 1.9: Parser Reader Setup (RespReader Class)

Now we build the class that reads from the network input stream and decodes it. We wrap the InputStream in a BufferedInputStream to avoid making slow, individual read system calls.

Add this static inner class skeleton inside the RespParser class (below RespValue class):

public static class RespReader { private final BufferedInputStream in; public RespReader(InputStream in) { this.in = new BufferedInputStream(in); } // We will add the reading helper methods here next }

Step 1.10: Reading Lines (readLine)

Most basic RESP lines terminate with \r\n (CRLF). We read bytes one-by-one until we hit \r\n.

Add this helper method inside the RespReader class:

private String readLine() throws IOException { StringBuilder sb = new StringBuilder(); int c; while ((c = in.read()) != -1) { if (c == '\r') { int next = in.read(); if (next == '\n') { break; // End of line reached successfully } sb.append('\r'); if (next != -1) { sb.append((char) next); } } else { sb.append((char) c); } } return sb.toString(); }

Step 1.11: Reading Bulk Strings (readBulkString)

To parse a bulk string:

  1. Parse the length integer (terminated by \r\n).
  2. If length is -1, return a null bulk string.
  3. Allocate a byte array. Read exactly length bytes in a loop.
  4. Read and consume the trailing \r and \n bytes.

Add this helper method inside the RespReader class:

private RespValue readBulkString() throws IOException { String lenStr = readLine(); int length = Integer.parseInt(lenStr); if (length == -1) { return new RespValue(Type.BULK_STRING, null); } byte[] bytes = new byte[length]; int read = 0; while (read < length) { int currentRead = in.read(bytes, read, length - read); if (currentRead == -1) { throw new EOFException("Premature end of stream while reading bulk string content"); } read += currentRead; } // Consume CRLF int r = in.read(); int n = in.read(); if (r != '\r' || n != '\n') { throw new IOException("Expected CRLF (\\r\\n) at the end of bulk string"); } return new RespValue(Type.BULK_STRING, new String(bytes, StandardCharsets.UTF_8)); }

Step 1.12: Reading Arrays (readArray)

To parse an array:

  1. Parse the array size integer (terminated by \r\n).
  2. If size is -1, return a null array.
  3. Loop size times, recursively calling readValue() for each nested element.

Add this helper method inside the RespReader class:

private RespValue readArray() throws IOException { String lenStr = readLine(); int size = Integer.parseInt(lenStr); if (size == -1) { return new RespValue(Type.ARRAY, null); } List<RespValue> elements = new ArrayList<>(size); for (int i = 0; i < size; i++) { elements.add(readValue()); // Recursive call } return new RespValue(Type.ARRAY, elements); }

Step 1.13: Master Router (readValue)

This is the parser entry point. It reads the first byte of any client message (the prefix) and routes to the correct parsing sub-logic.

Add this public method inside the RespReader class:

public RespValue readValue() throws IOException { int prefix = in.read(); if (prefix == -1) { throw new EOFException("Connection closed by client"); } switch (prefix) { case '+': return new RespValue(Type.SIMPLE_STRING, readLine()); case '-': return new RespValue(Type.ERROR, readLine()); case ':': return new RespValue(Type.INTEGER, Long.parseLong(readLine())); case '$': return readBulkString(); case '*': return readArray(); default: throw new IOException("Invalid RESP prefix: '" + (char) prefix + "'"); } }

📦 Module 2: Building the Database Store (Database.java)

Create a file named Database.java in your root directory.

Step 2.1: File Shell & Storage Map

We define our database engine. We initialize a ConcurrentHashMap so that concurrent thread operations from our server connection pool are completely safe.

Write this code into Database.java:

import java.util.List; import java.util.concurrent.ConcurrentHashMap; public class Database { private final ConcurrentHashMap<String, String> store = new ConcurrentHashMap<>(); // We will build executeCommand() next inside this class block }

Step 2.2: Implementing the Command Switch block

We parse and route commands inside a switch statement. In Milestone 3, we support PING (which returns its optional argument or "PONG") and ECHO.

Add this method inside the Database class:

public RespValue executeCommand(String commandName, List<RespValue> args) { switch (commandName.toUpperCase()) { case "PING": if (args.size() > 1) { return new RespValue(Type.BULK_STRING, args.get(1).value); } return new RespValue(Type.SIMPLE_STRING, "PONG"); case "ECHO": if (args.size() < 2) { return new RespValue(Type.ERROR, "ERR wrong number of arguments for 'echo' command"); } return new RespValue(Type.BULK_STRING, args.get(1).value); default: return new RespValue(Type.ERROR, "ERR unknown command '" + commandName + "'"); } }

📦 Module 3: Building the Network Server (RedisServer.java)

Create a file named RedisServer.java in your root directory.

Step 3.1: Server Setup & Main Sockets Loop

This class manages the TCP listener port, thread pool executor, and client connection accepts.

Write this code into RedisServer.java:

import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class RedisServer { private final int port; private final ExecutorService threadPool; private final Database db; public RedisServer(int port) { this.port = port; this.threadPool = Executors.newCachedThreadPool(); this.db = new Database(); } public void start() { System.out.println("Starting Java Redis Server on port " + port + "..."); try (ServerSocket serverSocket = new ServerSocket(port)) { serverSocket.setReuseAddress(true); System.out.println("Server is listening and ready to accept connections!"); while (true) { try { Socket clientSocket = serverSocket.accept(); System.out.println("Accepted connection from: " + clientSocket.getRemoteSocketAddress()); threadPool.submit(new ClientHandler(clientSocket)); } catch (IOException e) { System.err.println("Error accepting client connection: " + e.getMessage()); } } } catch (IOException e) { System.err.println("Failed to start server: " + e.getMessage()); } finally { threadPool.shutdown(); } } // We will build ClientHandler next inside this class block }

Step 3.2: Creating the Client Connection Worker

We create a thread-runnable task as a private inner class inside RedisServer.java.

Add this class skeleton at the bottom of the RedisServer class (before the final closing bracket):

private class ClientHandler implements Runnable { private final Socket clientSocket; public ClientHandler(Socket clientSocket) { this.clientSocket = clientSocket; } @Override public void run() { try (Socket socket = clientSocket) { InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream(); RespParser.RespReader reader = new RespParser.RespReader(inputStream); while (true) { try { RespValue request = reader.readValue(); RespValue response = handleRequest(request); outputStream.write(response.serialize()); outputStream.flush(); } catch (EOFException e) { break; // Client disconnected cleanly } catch (IOException e) { System.err.println("Protocol error on " + socket.getRemoteSocketAddress() + ": " + e.getMessage()); RespValue errorResponse = new RespValue(Type.ERROR, "ERR " + e.getMessage()); outputStream.write(errorResponse.serialize()); outputStream.flush(); break; } } System.out.println("Client disconnected: " + socket.getRemoteSocketAddress()); } catch (IOException e) { System.err.println("Socket error: " + e.getMessage()); } } // We will build handleRequest() next inside this ClientHandler class block }

Step 3.3: Processing Client Requests

We validate that the incoming command is a valid array of bulk strings, extract the command name, and send it to our database for execution.

Add this method inside the ClientHandler class (below run()):

private RespValue handleRequest(RespValue request) { if (request.type != Type.ARRAY) { return new RespValue(Type.ERROR, "ERR expected command array"); } @SuppressWarnings("unchecked") List<RespValue> elements = (List<RespValue>) request.value; if (elements == null || elements.isEmpty()) { return new RespValue(Type.ERROR, "ERR empty command"); } RespValue cmdElement = elements.get(0); if (cmdElement.type != Type.BULK_STRING) { return new RespValue(Type.ERROR, "ERR command name must be a bulk string"); } String commandName = (String) cmdElement.value; try { return db.executeCommand(commandName, elements); } catch (Exception e) { return new RespValue(Type.ERROR, "ERR executing command: " + e.getMessage()); } }

📦 Module 4: Bootstrapping (Main.java)

Create a file named Main.java in your root directory.

Step 4.1: The main Entrypoint

This bootstraps the application and starts the server.

Write this code into Main.java:

public class Main { public static void main(String[] args) { int port = 6379; RedisServer server = new RedisServer(port); server.start(); } }

📦 Module 5: Compilation and Testing

Step 5.1: Compilation command

In your terminal, run the following command to compile all 4 files:

javac Main.java RedisServer.java RespParser.java Database.java

Step 5.2: Start your server

Start the Java Redis server:

java Main

Step 5.3: Verification

Open another terminal window, and run:

  • Test PING: redis-cli PING $\rightarrow$ Expect PONG
  • Test PING with Message: redis-cli PING "Hello World!" $\rightarrow$ Expect "Hello World!"
  • Test ECHO: redis-cli ECHO "Echo testing" $\rightarrow$ Expect "Echo testing"