July 7, 2026

Go HTTP REST API – Build a Complete Books Service (Capstone)

Updated July 2, 2026. Refreshed for Go 1.22+ and current SEO best practices.

Welcome to the Go REST API tutorial capstone — Lesson 12 of this series. You will build a complete HTTP service with net/http that exposes JSON CRUD endpoints for a books resource. This lesson ties together everything you learned: structs, pointers, error handling, JSON encoding, and concurrent request handling with goroutines. By the end you will have a runnable API you can test with curl or Postman.

Prerequisites: Lessons 1–11 completed, Go 1.22 or newer installed. Estimated time: 75–90 minutes.

1. Complete Go Tutorial Series Overview

You have reached the final lesson. Here is the full twelve-lesson path — start from Lesson 1 if you are new, or use this table to revisit any topic:

Lesson Topic Link
1 Go Programming Introduction Lesson 1
2 Go Programming Setup Lesson 2
3 Go Basic Syntax Lesson 3
4 Go Variables and Types Lesson 4
5 Go Control Flow Lesson 5
6 Go Functions Lesson 6
7 Go Arrays, Slices, and Maps Lesson 7
8 Go Structs and Methods Lesson 8
9 Go Pointers Lesson 9
10 Go Interfaces and Errors Lesson 10
11 Go Goroutines and Channels Lesson 11
12 Go HTTP REST API (this lesson) You are here

If you come from Java or enterprise backends, compare this Go service with the Spring Boot tutorials on munonye.com — especially the Angular + Spring Boot CRUD series and the complete REST API guide. The HTTP verbs and JSON payloads are the same; Go’s net/http is lighter than a full Spring stack but follows identical REST principles.

2. API Architecture

Our books service follows a layered design: the HTTP server receives requests, route handlers parse input and call the store, and the store manages an in-memory map protected by a mutex. Each incoming request runs in its own goroutine — Go’s default concurrency model for HTTP.

Go REST API architecture diagram showing HTTP client, net/http server, route handlers, BookStore, and JSON encoding layer
Go REST API architecture — client requests flow through net/http handlers to an in-memory BookStore with JSON encoding.

The API exposes these endpoints:

Method Path Action
GET /api/books List all books
GET /api/books/{id} Get one book by ID
POST /api/books Create a new book
PUT /api/books/{id} Update an existing book
DELETE /api/books/{id} Delete a book

3. Project Setup

Create a new module and project folder. From your terminal:

mkdir books-api && cd books-api
go mod init github.com/kindsonthegenius/books-api

We will use a single main.go file for clarity. In production you would split handlers, models, and store into separate packages — but one file keeps the capstone easy to follow and run.

4. Book Model and Thread-Safe Store

Define the Book struct with JSON tags so field names serialize correctly. The BookStore wraps a map with a sync.RWMutex — multiple goroutines (one per HTTP request) can read and write safely:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"strconv"
	"strings"
	"sync"
)

type Book struct {
	ID     int     `json:"id"`
	Title  string  `json:"title"`
	Author string  `json:"author"`
	Price  float64 `json:"price"`
}

type BookStore struct {
	mu    sync.RWMutex
	books map[int]Book
	next  int
}

func NewBookStore() *BookStore {
	return &BookStore{
		books: map[int]Book{
			1: {ID: 1, Title: "The Go Programming Language", Author: "Donovan & Kernighan", Price: 39.99},
			2: {ID: 2, Title: "Concurrency in Go", Author: "Katherine Cox-Buday", Price: 34.99},
		},
		next: 3,
	}
}

var ErrNotFound = errors.New("book not found")

func (s *BookStore) List() []Book {
	s.mu.RLock()
	defer s.mu.RUnlock()
	list := make([]Book, 0, len(s.books))
	for _, b := range s.books {
		list = append(list, b)
	}
	return list
}

func (s *BookStore) Get(id int) (Book, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	b, ok := s.books[id]
	if !ok {
		return Book{}, ErrNotFound
	}
	return b, nil
}

func (s *BookStore) Create(b Book) Book {
	s.mu.Lock()
	defer s.mu.Unlock()
	b.ID = s.next
	s.next++
	s.books[b.ID] = b
	return b
}

func (s *BookStore) Update(id int, b Book) (Book, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.books[id]; !ok {
		return Book{}, ErrNotFound
	}
	b.ID = id
	s.books[id] = b
	return b, nil
}

func (s *BookStore) Delete(id int) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.books[id]; !ok {
		return ErrNotFound
	}
	delete(s.books, id)
	return nil
}

Notice how each store method returns error for not-found cases — the same pattern from Lesson 10. The mutex ensures that a concurrent POST and DELETE on the same book cannot corrupt the map.

5. HTTP Handlers and JSON Responses

Handlers parse the request, call the store, and write JSON responses with appropriate status codes. A helper function keeps error responses consistent:

func writeJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(data); err != nil {
		log.Printf("writeJSON: %v", err)
	}
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, map[string]string{"error": message})
}

type BookHandler struct {
	store *BookStore
}

func (h *BookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	path := strings.TrimPrefix(r.URL.Path, "/api/books")
	path = strings.Trim(path, "/")

	switch {
	case path == "" && r.Method == http.MethodGet:
		writeJSON(w, http.StatusOK, h.store.List())

	case path == "" && r.Method == http.MethodPost:
		var b Book
		if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
			writeError(w, http.StatusBadRequest, "invalid JSON body")
			return
		}
		if b.Title == "" || b.Author == "" {
			writeError(w, http.StatusBadRequest, "title and author are required")
			return
		}
		created := h.store.Create(b)
		writeJSON(w, http.StatusCreated, created)

	case path != "":
		id, err := strconv.Atoi(path)
		if err != nil {
			writeError(w, http.StatusBadRequest, "invalid book id")
			return
		}
		h.handleByID(w, r, id)

	default:
		writeError(w, http.StatusMethodNotAllowed, "method not allowed")
	}
}

func (h *BookHandler) handleByID(w http.ResponseWriter, r *http.Request, id int) {
	switch r.Method {
	case http.MethodGet:
		b, err := h.store.Get(id)
		if errors.Is(err, ErrNotFound) {
			writeError(w, http.StatusNotFound, err.Error())
			return
		}
		writeJSON(w, http.StatusOK, b)

	case http.MethodPut:
		var b Book
		if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
			writeError(w, http.StatusBadRequest, "invalid JSON body")
			return
		}
		updated, err := h.store.Update(id, b)
		if errors.Is(err, ErrNotFound) {
			writeError(w, http.StatusNotFound, err.Error())
			return
		}
		writeJSON(w, http.StatusOK, updated)

	case http.MethodDelete:
		if err := h.store.Delete(id); errors.Is(err, ErrNotFound) {
			writeError(w, http.StatusNotFound, err.Error())
			return
		}
		w.WriteHeader(http.StatusNoContent)

	default:
		writeError(w, http.StatusMethodNotAllowed, "method not allowed")
	}
}

BookHandler implements http.Handler by defining a ServeHTTP method — the interface from Lesson 10 in action. Go 1.22+ also supports method-based routing with patterns like GET /api/books/{id}, but manual path parsing keeps this example compatible and easy to understand.

6. Starting the Server

Wire everything together in main. Register the handler and listen on port 8080:

func main() {
	store := NewBookStore()
	handler := &BookHandler{store: store}

	mux := http.NewServeMux()
	mux.Handle("/api/books", handler)
	mux.Handle("/api/books/", handler)

	addr := ":8080"
	fmt.Printf("Books API listening on http://localhost%s\n", addr)
	fmt.Println("Try: curl http://localhost:8080/api/books")
	if err := http.ListenAndServe(addr, mux); err != nil {
		log.Fatal(err)
	}
}

Run the server:

go run .

Test the full CRUD lifecycle with curl:

# List all books
curl http://localhost:8080/api/books

# Get one book
curl http://localhost:8080/api/books/1

# Create a book
curl -X POST http://localhost:8080/api/books \
  -H "Content-Type: application/json" \
  -d '{"title":"Learning Go","author":"Me","price":29.99}'

# Update a book
curl -X PUT http://localhost:8080/api/books/3 \
  -H "Content-Type: application/json" \
  -d '{"title":"Learning Go (2nd ed)","author":"Me","price":34.99}'

# Delete a book
curl -X DELETE http://localhost:8080/api/books/3

Each curl request is handled in a separate goroutine. The RWMutex allows concurrent reads (GET) while writes (POST, PUT, DELETE) hold an exclusive lock — a pattern you will recognize from the Goroutines lesson.

7. Next Steps Beyond the Capstone

Congratulations — you have completed the twelve-lesson Go programming tutorial. Your books API is a solid foundation. To move toward production, consider these enhancements:

  • Persistent storage — replace the in-memory map with PostgreSQL (using database/sql or an ORM like GORM).
  • Router library — use chi, gorilla/mux, or Go 1.22+ pattern routing for cleaner path parameters.
  • Middleware — add logging, CORS, authentication (JWT), and request timeouts with http.Handler wrappers.
  • Testing — write httptest unit tests for each handler without starting a real server.
  • Frontend — connect an Angular or React client; the munonye.com Angular CRUD tutorials show the client side with Spring Boot — swap in your Go API URL.

For enterprise Java backends with dependency injection, security, and JPA, explore the full Spring Boot tutorial hub on munonye.com. Many teams run Go microservices alongside Spring Boot monoliths — knowing both stacks makes you versatile.

Browse all tutorials: Content Directory · Go Programming Hub

Frequently Asked Questions

Does Go have a built-in web framework?
Go’s standard library includes net/http, which is sufficient for production APIs. Popular routers like chi and echo add convenience but are optional. This capstone uses only the standard library.

How does Go compare to Spring Boot for REST APIs?
Go compiles to a single binary with no JVM, starts in milliseconds, and handles concurrency with goroutines. Spring Boot offers a richer ecosystem (JPA, Security, Actuator) for large enterprise apps. Both expose standard REST/JSON endpoints — see the munonye.com Spring Boot tutorials for the Java side.

Why use a mutex instead of channels for the book store?
The store is shared mutable state accessed by many goroutines. A sync.RWMutex is the idiomatic choice for protecting a map. Channels are better for passing work between goroutines, not for guarding a data structure.

How do I deploy a Go REST API?
Run go build -o books-api . to produce a binary, then deploy it to any Linux server, Docker container, or cloud platform (Fly.io, Railway, AWS ECS). No runtime dependencies beyond the OS — one reason Go is popular for DevOps and cloud-native services.

Want live Go or backend classes? Join Alkademy for instructor-led programming courses with hands-on projects.


0 0 votes
Article Rating
Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x