Background
After learning Go for this project, I'm now ready to take on building the foundation of a distributed kv cache. First, let's outline what steps I want to take for this project
- part 1: CRUD functionality, basic single machine concurrency
- part 2: Eviction strategies
- part 3: Sharding for horizontal scaling
- part 4: Fault tolerance with replication, leader election
- part 5: Durability with persistence: write-ahead log, snapshotting, etc.
- part 6: Observability: metrics, logging
In this post, I’ll build a simple single-node KV cache with CRUD (create, read, update, delete) operations. I’ll use Go’s concurrency features to safely handle multiple requests at the same time.
Step 1: Starting with the data structure
A key-value cache is just a dictionary. In Go, that's a map.
Let's set both keys and values as Strings. String as the data type for values isn't as limiting as it might seem. Let's take an example, and suppose our kv cache stores blog posts. The key can be the blog post title, and the value can be a JSON String containing information like author, post date, and blog contents. Since it's serialized, it can work as a String neatly, and in that case it's the client's responsibility to handle serialization/deserialization logic.
items := make(map[string]string)
items["KV1"] = `{"author":"Rohan","date":"2026-05-13","body":"..."}`
fmt.Println(items["KV1"])
But a raw map can't be the whole story. I want to group the map with its operations, so the first instinct is to wrap it in a struct. This is a foundational pattern in Go (and most OOP languages): group data with the functions that operate on it, and control access to the data through those functions.
type Cache struct {
items map[string]string
}
And because map zero-values to nil (unlike, say, a slice which can be appended to when nil), I need a constructor to initialize it:
func NewCache() *Cache {
return &Cache{
items: make(map[string]string),
}
}
Why return a pointer
*Cache? Returning by value would copy the struct — and later, when you add a mutex, copying a mutex is illegal (it breaks the lock state). Pointers also avoid copying a potentially large map header on every function call. In Go, structs with mutable state are almost always passed and returned as pointers.
Step 2: Adding CRUD methods
Now I'll write the basic cache read/write operations. A key design decision is figuring out what each operation should return in different cases! In systems programming, operations on shared state should be explicit about success/failure.
func (c *Cache) Create(key, value string) bool {
if _, exists := c.items[key]; exists {
return false // caller knows the key already existed
}
c.items[key] = value
return true
}
func (c *Cache) Read(key string) (string, bool) {
value, exists := c.items[key]
return value, exists // Go's idiomatic "comma ok" pattern
}
The "comma ok" pattern is idiomatic Go for operations that may or may not produce a result — instead of returning
nilor a sentinel value, you return a secondbool. This is the same pattern used for map lookups, type assertions, and channel receives.
Update is the inverse of Create. It requires the key to exist:
func (c *Cache) Update(key, value string) bool {
if _, exists := c.items[key]; !exists {
return false
}
c.items[key] = value
return true
}
Note that while Create and Update have some similar logic, I'm keeping them as separate functions to model REST semantics (e.g. Post being different from Put), as seen in Step 4 of this post.
Step 3: Concurrency: goroutines and locking
If I expose this over HTTP, Go's net/http server dispatches each request in its own goroutine. So two requests can arrive simultaneously and both touch c.items at the same time.
Goroutines are scheduled on OS threads by the Go runtime. Two goroutines writing to a map simultaneously is a data race. The Go runtime can detect this with
go run -race main.go, and an undetected race on a map will cause a panic at runtime ("concurrent map writes").
The fix is a mutex! I found that Go provides sync.RWMutex, which distinguishes between:
- Writers: need exclusive access (
Lock/Unlock) - Readers: can share access (
RLock/RUnlock)
This is the readers-writer lock pattern, common in systems programming when reads are far more frequent than writes:
type Cache struct {
mu sync.RWMutex
items map[string]string
}
Now, let me add locking to my CRUD operations:
func (c *Cache) Read(key string) (string, bool) {
c.mu.RLock() // multiple readers can hold this simultaneously
defer c.mu.RUnlock() // released when function returns, even on panic
value, exists := c.items[key]
return value, exists
}
func (c *Cache) Create(key, value string) bool {
c.mu.Lock() // exclusive — all other goroutines block
defer c.mu.Unlock()
if _, exists := c.items[key]; exists {
return false
}
c.items[key] = value
return true
}
deferfor unlocking is idiomatic and important. It ensures the lock is always released, even if the function returns early or panics.
Step 4: The HTTP layer
Now I can wire the cache to HTTP. Go's net/http package uses a multiplexer (mux) that routes requests to handler functions based on path patterns.
http.HandleFunc("/kv/", func(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/kv/")
...
})
The key is extracted from the URL path directly — /kv/greeting gives key "greeting". This is a common REST convention: the resource identifier is part of the path, not a query parameter.
Then I dispatch by HTTP method, using the method as a verb over the resource:
switch r.Method {
case http.MethodPost: createValue(w, r, cache, key)
case http.MethodGet: readValue(w, cache, key)
case http.MethodPut: updateValue(w, r, cache, key)
case http.MethodDelete: deleteValue(w, cache, key)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
TTP methods conveniently (but not coincidentally) map directly to CRUD. This is REST:
POST= Create,GET= Read,PUT= Update,DELETE= Delete. The server communicates errors using HTTP status codes rather than application-level error fields —409 Conflictfor a duplicate key,404 Not Foundfor a missing one, etc.
Step 5: Handling JSON serialization
HTTP speaks bytes, not Go structs. I need to serialize/deserialize the value. Here's a fun analogy for this technical concept that I thought. Serialization is whatever magic converts Pokemon into data that fits in a Pokeball. Deserialization is what follows after Ash shouts "I choose you!"; the Pokemon returning to the real world in its living state.
Let me design a type of Pokeball — er, I mean, a small struct — to represent the wire format:
type valueRequest struct {
Value string `json:"value"`
}
"wire format" means the exact shape of the bytes that travel over the network; what the JSON looks like when it goes across the HTTP connection.
Then writes two helpers to keep the handler functions clean:
func decodeValue(w http.ResponseWriter, r *http.Request) (valueRequest, bool) {
var req valueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json body", http.StatusBadRequest)
return valueRequest{}, false
}
return req, true
}
func writeJSON(w http.ResponseWriter, value valueRequest) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(value)
}
Streaming codecs vs. buffering.
json.NewDecoder(r.Body)reads directly from the HTTP request body as a stream — it doesn't load the entire body into memory first. Same forjson.NewEncoder(w)writing directly to the response writer. I learned this is the idiomatic approach in Go for I/O — prefer streams over buffering where possible. So we're encoding/decoding our Pokemon byte by byte (cell by cell?) instead of, how it works in the games and anime, where it appears/disappears all at once. Wailord-sized payloads, no issue!
The handler functions become thin and readable:
func createValue(w http.ResponseWriter, r *http.Request, cache *Cache, key string) {
req, ok := decodeValue(w, r)
if !ok {
return
}
if !cache.Create(key, req.Value) {
http.Error(w, "key already exists", http.StatusConflict)
return
}
w.WriteHeader(http.StatusCreated)
}
Step 6: Start the server
Finally:
fmt.Println("kv cache listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Println("server error:", err)
}
:8080 means all interfaces. The colon prefix means "bind to port 8080 on every network interface." If you wrote 127.0.0.1:8080 it would only accept connections from localhost. The nil mux argument tells net/http to use the default global mux you registered handlers on.
ListenAndServe blocks forever. It creates a TCP listener, accepts connections in a loop, and spawns a new goroutine for each one.
The full mental model
Client request
│
▼
http.ListenAndServe ← new goroutine per request
│
▼
/kv/{key} → extract key, route by method
│
▼
decode JSON body (if applicable)
│
▼
Cache.Create/Read/Update/Delete
└─ mu.Lock / mu.RLock ← protect shared map
└─ operate on items map
└─ return bool
│
▼
write HTTP status + JSON body
Each layer has a single responsibility: the Cache struct knows nothing about HTTP, and the HTTP handlers know nothing about how locking works. That separation is what makes the code easy to test and extend. Ah oh boy, do I have a lot of extensions in mind. Starting with how our current design kind of lets our data grow infinitely...
Let's discuss that in my next post on adding eviction policies!