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.
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/sqlor 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.Handlerwrappers. - Testing — write
httptestunit 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.