Updated Jun 23, 2026

A JSON REST API With No Framework

This is the phase where the pieces click together. You've met the mux and Go 1.22 routing, reading requests and writing JSON, and middleware as a plain wrapper. Now we build a complete CRUD API — a real messages service you can curl — using only the standard library.

Here's the mental model to anchor on, because it cuts through all the ceremony: a REST resource is five plain handlers over one collection. List, get-one, create, update, delete — that's the whole CRUD vocabulary. Each handler is an ordinary func(w http.ResponseWriter, r *http.Request). The Go 1.22 mux maps a method-plus-path pattern to each one. That's it. When you reach for Gin or Echo later, what they hand you is these same five handlers with some boilerplate shaved off. Today you write them by hand, and afterward no framework's "REST controller" will ever look like magic again.

💡 We're not introducing new net/http concepts here — we're composing the ones you already have. If a line surprises you, it's almost certainly explained in Phase 2, 3, or 4. This phase is the payoff for reading those.

The store: shared state needs a guard

Before the handlers, we need somewhere to keep messages. For a learning API, an in-memory map is perfect — no database to set up. A Message is just an ID and some text:

type Message struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
}

type Store struct {
	mu     sync.Mutex
	data   map[int]Message
	nextID int
}

func NewStore() *Store {
	return &Store{data: make(map[int]Message), nextID: 1}
}

What just happened: Store bundles three things: the data map keyed by ID, a nextID counter for handing out fresh IDs, and — the part you cannot skip — a sync.Mutex. We hold the mutex in every method that touches data or nextID.

⚠️ This is the single most important line in the phase. The Go HTTP server runs every request in its own goroutine, so two clients can hit your handlers at the same time. If both write to the map concurrently, Go doesn't quietly corrupt it — it panics outright with fatal error: concurrent map writes and kills the whole process. A plain map is not safe for concurrent writes. The sync.Mutex makes each operation atomic: one goroutine at a time. Forget it and your API works perfectly in testing, then dies the first time two real users overlap.

Now the store methods, each one locking before it touches shared state:

func (s *Store) List() []Message {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Message, 0, len(s.data))
	for _, m := range s.data {
		out = append(out, m)
	}
	return out
}

func (s *Store) Get(id int) (Message, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	m, ok := s.data[id]
	return m, ok
}

func (s *Store) Create(text string) Message {
	s.mu.Lock()
	defer s.mu.Unlock()
	m := Message{ID: s.nextID, Text: text}
	s.data[m.ID] = m
	s.nextID++
	return m
}

func (s *Store) Update(id int, text string) (Message, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.data[id]; !ok {
		return Message{}, false
	}
	m := Message{ID: id, Text: text}
	s.data[id] = m
	return m, true
}

func (s *Store) Delete(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.data[id]; !ok {
		return false
	}
	delete(s.data, id)
	return true
}

What just happened: Every method follows the same rhythm — Lock(), defer Unlock(), then do the work. The defer guarantees the mutex is released even if the function returns early (as Update and Delete do when the ID is missing), so you can never accidentally leave the store locked. Notice List returns a freshly built slice and Get/Update/Delete return a bool saying whether the message existed — that boolean is what lets the handlers decide between 200 and 404. The store knows nothing about HTTP; it's plain Go. That separation is deliberate and it's exactly what Phase 6 builds on.

The five handlers

Now the HTTP layer. Each handler reads from r, calls a store method, and writes a response with the writeJSON helper from Phase 3:

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

We'll hang the handlers off the store so they have something to read and write. A tiny parseID helper turns the {id} path wildcard into an int:

func parseID(r *http.Request) (int, error) {
	return strconv.Atoi(r.PathValue("id"))
}

What just happened: r.PathValue("id") pulls the {id} segment the mux captured (Phase 2), and strconv.Atoi parses it to an int. It returns an error for garbage like /messages/abc, which the handlers translate into a 400. One helper, reused by three handlers.

List — GET /messages → 200

func (s *Store) handleList(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, s.List())
}

What just happened: The simplest handler in the API. Ask the store for everything, write it as JSON with 200 OK. Because List returns a non-nil empty slice when there are no messages, the client gets [], not null — a small kindness that keeps JSON parsers on the other end happy.

Get one — GET /messages/{id} → 200 or 404

func (s *Store) handleGet(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	m, ok := s.Get(id)
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "message not found"})
		return
	}
	writeJSON(w, http.StatusOK, m)
}

What just happened: Two guards, then the happy path. A bad ID is the client's fault → 400. A well-formed ID that doesn't exist → 404, driven entirely by the ok boolean the store returned. Only when both checks pass do we send the message with 200. Each guard ends in return — the "check, respond, return" rhythm from Phase 3 — so we never fall through and write a second response.

Create — POST /messages → 201

type createInput struct {
	Text string `json:"text"`
}

func (s *Store) handleCreate(w http.ResponseWriter, r *http.Request) {
	var in createInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}
	if strings.TrimSpace(in.Text) == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"})
		return
	}
	m := s.Create(in.Text)
	writeJSON(w, http.StatusCreated, m)
}

What just happened: The full intake pipeline from Phase 3, now wired to the store. Decode the body into createInput, bailing with 400 on broken JSON. Validate by hand — strings.TrimSpace(in.Text) == "" rejects empty or whitespace-only text, because net/http has no built-in validation; that if is your validation layer. Then s.Create assigns the next ID and stores the message, and we reply 201 Created with the new resource (including its server-assigned id) so the client learns what to address it by.

Update — PUT /messages/{id} → 200 or 404

func (s *Store) handleUpdate(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	var in createInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}
	if strings.TrimSpace(in.Text) == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"})
		return
	}
	m, ok := s.Update(id, in.Text)
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "message not found"})
		return
	}
	writeJSON(w, http.StatusOK, m)
}

What just happened: Update is get-one and create fused together — it parses the ID and decodes a body and validates and checks existence. Four guards, each with its own status and return. The store's Update returns false when the ID is missing, so a PUT to a non-existent message is a clean 404 rather than a silent create. On success it's 200 with the updated message.

Delete — DELETE /messages/{id} → 204

func (s *Store) handleDelete(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	if !s.Delete(id) {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "message not found"})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

What just happened: Delete the message, or 404 if it wasn't there. The success case is the 204 No Content special case from Phase 3: call WriteHeader(http.StatusNoContent) and write nothing — no writeJSON, no body. A 204 means "done, and there's nothing to tell you," so resist the urge to return {"ok": true}; a body after 204 contradicts the status.

Wiring it up

Five handlers, one mux, mapped by the Go 1.22 method+path patterns, wrapped in the Logging middleware from Phase 4:

func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
	})
}

func main() {
	store := NewStore()
	mux := http.NewServeMux()

	mux.HandleFunc("GET /messages", store.handleList)
	mux.HandleFunc("POST /messages", store.handleCreate)
	mux.HandleFunc("GET /messages/{id}", store.handleGet)
	mux.HandleFunc("PUT /messages/{id}", store.handleUpdate)
	mux.HandleFunc("DELETE /messages/{id}", store.handleDelete)

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", Logging(mux)))
}

What just happened: This is the whole API in one screen. Each mux.HandleFunc line reads like a routing table: a method, a path pattern, and the handler that serves it. The mux dispatches GET /messages/{id} and PUT /messages/{id} to different handlers even though the paths look identical, because in Go 1.22 the method is part of the pattern — that's the feature that makes hand-rolled CRUD pleasant. We pass Logging(mux) (not bare mux) to ListenAndServe, so every request flows through the middleware first and gets logged. The store is created once and shared across all handlers via the method receiver — and because it's mutex-guarded, that sharing is safe under the concurrent goroutines the server spawns.

Driving it with curl

Start the server (go run .), then exercise every endpoint:

# Create two messages
$ curl -s -X POST localhost:8080/messages -d '{"text":"hello"}'
{"id":1,"text":"hello"}
$ curl -s -X POST localhost:8080/messages -d '{"text":"world"}'
{"id":2,"text":"world"}

# List them
$ curl -s localhost:8080/messages
[{"id":1,"text":"hello"},{"id":2,"text":"world"}]

# Get one
$ curl -s localhost:8080/messages/1
{"id":1,"text":"hello"}

# Update it
$ curl -s -X PUT localhost:8080/messages/1 -d '{"text":"hi there"}'
{"id":1,"text":"hi there"}

# Delete it (note -i to see the status — 204 has no body)
$ curl -s -i -X DELETE localhost:8080/messages/1 | head -1
HTTP/1.1 204 No Content

# A missing message is a clean 404
$ curl -s localhost:8080/messages/999
{"error":"message not found"}

What just happened: Every status code path you wrote, exercised from the outside. Create returns 201 with the server-assigned ID; list returns the array; update mutates in place; delete sends a bodiless 204 (the -i flag prints the status line so you can see it); and a request for a non-existent ID returns the 404 JSON your handleGet produces. This is a working REST API — and there isn't a framework import anywhere in the file.

So... do you need a framework?

Now the honest comparison, because you've earned it by building the thing.

💡 For basic CRUD over one resource, this is roughly the same amount of code a framework would have you write. The five handlers, the validation, the status codes — Gin or Echo don't make those disappear; they're inherent to the job. So where does a framework actually pay for itself? Three places: validation (declarative tag-based binding instead of hand-written if checks, which matter once you have ten fields per request), many routes (route groups, shared prefixes, and per-group middleware get unwieldy by hand at thirty endpoints), and ecosystem (off-the-shelf middleware for auth, CORS, rate limiting, request IDs that you'd otherwise write yourself). For a handful of endpoints, the stdlib is genuinely enough — and now you can tell when you've crossed the line. Phase 7 maps each of these conveniences back onto exactly the net/http code you just wrote.

Recap

  • A REST resource is five plain handlers over one collection: list, get-one, create, update, delete — the same five a framework gives you, written by hand.
  • The in-memory Store must be mutex-guarded: the server runs each request in its own goroutine, and concurrent writes to a bare map panic with concurrent map writes. Lock() + defer Unlock() in every method.
  • Handlers read with r.PathValue("id")strconv.Atoi, decode bodies with json.NewDecoder, validate by hand (no built-in validation), and respond with writeJSON — using the store's bool return to choose 200 vs 404.
  • Status codes map to intent: 200 read, 201 create, 204 delete (no body), 400 bad input, 404 missing. Each guard ends in return.
  • The Go 1.22 mux routes by method+path ("GET /messages/{id}" vs "PUT /messages/{id}"), and you wrap the whole mux in Logging middleware when starting.
  • For basic CRUD this is about as much code as a framework; frameworks earn their keep with heavy validation, many routes, and middleware ecosystems — see Phase 7.

Quick check

[
  {
    "q": "Why does the in-memory Store need a sync.Mutex?",
    "choices": [
      "To make JSON encoding thread-safe",
      "Because the HTTP server handles each request in its own goroutine, and concurrent writes to a plain map panic",
      "Maps are slow without a lock around them",
      "The Go 1.22 mux requires handlers to be synchronized"
    ],
    "answer": 1,
    "explain": "Go's HTTP server runs every request in a separate goroutine. A plain map isn't safe for concurrent writes — two overlapping requests trigger a 'fatal error: concurrent map writes' panic. The mutex serializes access."
  },
  {
    "q": "How does the mux send GET /messages/{id} and PUT /messages/{id} to different handlers despite identical paths?",
    "choices": [
      "It inspects the request body to decide",
      "In Go 1.22 the HTTP method is part of the route pattern, so each method+path pair maps to its own handler",
      "You register one handler and switch on r.Method inside it",
      "It can't — you need a third-party router for that"
    ],
    "answer": 1,
    "explain": "Go 1.22 added method-prefixed patterns. 'GET /messages/{id}' and 'PUT /messages/{id}' are distinct patterns, so the mux dispatches each to a different handler — no manual r.Method switch needed."
  },
  {
    "q": "What does handleDelete write on a successful delete?",
    "choices": [
      "200 OK with {\"ok\": true}",
      "201 Created with the deleted message",
      "204 No Content with no body at all",
      "404 Not Found"
    ],
    "answer": 2,
    "explain": "A successful delete returns 204 No Content: call w.WriteHeader(http.StatusNoContent) and write nothing. A 204 means there's no body, so returning JSON would contradict the status."
  }
]

← Phase 4: Middleware Is Just a Wrapper · Guide overview · Phase 6: Structure, Context & Graceful Shutdown →

Check your understanding

1. Why does the in-memory Store need a sync.Mutex?

2. How does the mux send GET /messages/{id} and PUT /messages/{id} to different handlers despite identical paths?

3. What does handleDelete write on a successful delete?

Was this page helpful?