# Part 1: CRUD in memory with nothing but net/http

---

**Series:** Build a production-grade REST API in Go · **Part 1 of 17** **Where we are:** an empty folder. By the end you will have a working API.

We begin deliberately small. A first version teaches you the shape of the problem, and it gives us something to improve in every later post. This one is going to have flaws, and at the end of the post we will list them on purpose. That list becomes our roadmap.

## What we are building

A `users` resource with four operations:

| Method & path | Does |
| --- | --- |
| `GET /users` | list all users |
| `POST /users` | create a user |
| `PUT /users/{id}` | rename a user |
| `DELETE /users/{id}` | delete a user |

A user is just an id and a name: `{"id": 1, "name": "Ann"}`.

## Step 1: create the project

```bash
mkdir rest-go && cd rest-go
git init
go mod init go-rest
mkdir -p cmd/rest internal/storage internal/handlers internal/routes
```

Two folders deserve an explanation:

- `cmd/rest/` holds `main.go`. Anything under `cmd/` is a program you can run. A repo can have several (`cmd/api`, `cmd/worker`, ...).
- `internal/` is special to the Go toolchain: packages inside it **cannot be imported by other modules**. It is how you say "this is my application's private code, not a library". Use it by default; make things public only on purpose.

Notice we split by *responsibility* (storage, handlers, routes), not by file type. This is the first step toward the layered structure we will keep growing.

## Step 2: the data layer

We start with the "database": a map guarded by a mutex.

**Create** `internal/storage/storage.go`

```go
package storage

import (
	"errors"
	"log"
	"sync"
)

type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

type UserStore struct {
	mu     sync.RWMutex
	users  map[int]*User
	nextID int
}

func NewUserStore() *UserStore {
	return &UserStore{
		users:  make(map[int]*User),
		nextID: 1,
	}
}

func (s *UserStore) GetAll() []*User {
	// Your code here
	users := []*User{}
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, user := range s.users {
		users = append(users, user)
	}

	return users
}

func (s *UserStore) Create(user *User) *User {
	// Your code here

	s.mu.Lock()
	defer s.mu.Unlock()

	user.ID = s.nextID
	s.users[user.ID] = user
	s.nextID++

	return user
}

func (s *UserStore) Update(id int, updatedUser *User) (*User, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	user, ok := s.users[id]
	if !ok {
		return nil, errors.New("user not found")
	}

	user.Name = updatedUser.Name
	return user, nil
}

func (s *UserStore) Delete(id int) (bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	user, ok := s.users[id]
	if !ok {
		return false, errors.New("user not found")
	}

	log.Printf("deleted user with userId: %d, name: %s ", user.ID, user.Name)
	delete(s.users, id)
	return true, nil
}
```

Three things to notice:

1. `sync.RWMutex`. Go's HTTP server handles every request in its own goroutine, so two requests can touch the map at the same time. Concurrent map access is a data race and can crash the program. `RLock` lets many readers in at once; `Lock` is exclusive for writers.
2. `defer s.mu.Unlock()` releases the lock on every exit path, including early returns, so you cannot forget it.
3. **The store knows nothing about HTTP.** It takes and returns plain Go values. That separation pays off in every later post.

## Step 3: the handlers

A handler is any function with the signature `func(http.ResponseWriter, *http.Request)`. It reads the request, calls the store, and writes the response.

**Create** `internal/handlers/handlers.go`

```go
package handlers

import (
	"encoding/json"
	"go-rest/internal/storage"
	"net/http"
	"strconv"
)

var store = storage.NewUserStore()

func HandleGetUsers(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(store.GetAll())
}

func HandleCreateUsers(w http.ResponseWriter, r *http.Request) {
	var user storage.User

	if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	created := store.Create(&user)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(created)
}

func HandleUpdateUsers(w http.ResponseWriter, r *http.Request) {

	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "Invalid user ID", http.StatusBadRequest)
		return
	}

	var updates storage.User
	if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	updated, err := store.Update(id, &updates)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(updated)
}

func HandleDeleteUsers(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "Invalid user ID", http.StatusBadRequest)
		return
	}
	del, err := store.Delete(id)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(struct {
		Deleted bool `json:"deleted"`
	}{
		Deleted: del,
	})
}
```

Walk through `HandleCreateUsers`:

1. `json.NewDecoder(r.Body).Decode(&user)` parses the JSON body into a struct. If the JSON is malformed we answer **400 Bad Request**: the client made a mistake, so it is a 4xx, not a 5xx.
2. We store the user and answer **201 Created** (not 200) with the created object. Correct status codes are part of your API's contract: clients, proxies and monitoring tools all act on them.
3. The `json:"id"` tags on the `User` struct decide the JSON field names.

`r.PathValue("id")` is new in Go 1.22: it reads the `{id}` wildcard from the route pattern, which we define next.

## Step 4: routes

**Create** `internal/routes/routes.go`

```go
package routes

import (
	"go-rest/internal/handlers"
	"net/http"
)

func SetupRoutes(mux *http.ServeMux) {
	mux.HandleFunc("GET /users", handlers.HandleGetUsers)
	mux.HandleFunc("POST /users", handlers.HandleCreateUsers)
	mux.HandleFunc("PUT /users/{id}", handlers.HandleUpdateUsers)
	mux.HandleFunc("DELETE /users/{id}", handlers.HandleDeleteUsers)
}
```

Since Go 1.22, `http.ServeMux` understands **the HTTP method and path wildcards in the pattern itself**: `"PUT /users/{id}"`. Before this, people reached for a router library just to get that. Now the standard library is enough for most APIs. Bonus: a request with the wrong method (say `PATCH /users/1`) automatically gets **405 Method Not Allowed**.

## Step 5: main

**Create** `cmd/rest/main.go`

```go
package main

import (
	"go-rest/internal/routes"
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	routes.SetupRoutes(mux)

	log.Println("server running on :7800")

	if err := http.ListenAndServe(":7800", mux); err != nil {
		log.Fatal(err)
	}
}
```

`ListenAndServe` blocks forever serving requests; it only returns if something goes wrong, which is why we pass its error to `log.Fatal`.

Finally, a tiny `makefile` so you do not have to remember commands. (Recipe lines must start with a **tab**, not spaces. If your editor or the page you copied from turned them into spaces, `make` will complain about a "missing separator".)

**Create** `makefile`

```makefile
.PHONY: run build

run:
	go run cmd/rest/main.go

build:
	go build -o bin/server cmd/rest/main.go
```

## Try it

Start the server in one terminal:

```bash
make run
# 2026/10/18 12:12:37 server running on :7800
```

And talk to it from another:

```bash
$ curl -i -XPOST localhost:7800/users -d '{"name":"Ann"}'
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 22

{"id":1,"name":"Ann"}

$ curl -XPOST localhost:7800/users -d '{"name":"Bob"}'
{"id":2,"name":"Bob"}

$ curl localhost:7800/users
[{"id":1,"name":"Ann"},{"id":2,"name":"Bob"}]

$ curl -XPUT localhost:7800/users/1 -d '{"name":"Anna"}'
{"id":1,"name":"Anna"}

$ curl -XDELETE localhost:7800/users/2
{"deleted":true}

$ curl -i -XDELETE localhost:7800/users/2        # already gone
HTTP/1.1 404 Not Found
User not found

$ curl -i -XPOST localhost:7800/users -d '{'      # broken JSON
HTTP/1.1 400 Bad Request
Invalid JSON
```

It works. In about 200 lines you have a real HTTP API.

## Now the important part: what is wrong with it?

Try this:

```bash
$ curl -XPOST localhost:7800/users -d '{}'
{"id":3,"name":""}
```

We just created a user with **no name**. That is one of many problems. Be your own code reviewer:

| # | Problem | Why it matters |
| --- | --- | --- |
| 1 | **No validation.** Empty names, and any extra JSON is silently accepted. | Garbage in, garbage forever |
| 2 | **Data lives in memory.** Restart the server and everything is gone. | It is not a database |
| 3 | **A global variable** (`var store = ...`) inside the handlers package. | Impossible to test or swap; hidden dependency |
| 4 | `GetAll` **iterates a map**, whose order is random in Go. | The list can come back in a different order each time |
| 5 | **Plain-text error bodies**, inconsistent with the JSON success bodies. | Clients can't parse errors reliably |
| 6 | **Unbounded request body.** A 5 GB upload is read into memory. | Trivial denial of service |
| 7 | **No timeouts** on the server. | A slow client holds a connection forever |
| 8 | **No graceful shutdown**; `Ctrl-C` kills in-flight requests. | Lost writes during every deploy |
| 9 | **The store hands out pointers to its own records**, and handlers encode them *after* the lock is released. | Another request can modify the record at that moment: a data race |
| 10 | **Errors from** `Encode` **are ignored.** | Silent failures |

We will fix **every one** of these in the next posts, and then keep going: real database, observability, security. Keep this table; we will cross things off.

## Recap

- `net/http` in Go 1.22+ gives you method and wildcard routing without a framework.
- Handlers translate between HTTP and your domain; the store should not know HTTP exists.
- Shared mutable state needs synchronization, because every request is a goroutine.
- Pick status codes deliberately: 201 for create, 400 for client mistakes, 404 for missing things.

## Exercises

1. Add `GET /users/{id}` returning a single user (404 if missing).
2. Make `GetAll` return users sorted by id. (Hint: collect, then `slices.SortFunc`.)
3. Write a test for the store that creates 100 users from 100 goroutines and run it with `go test -race`. Then comment out the mutex and watch the race detector complain.
