Practice 1 — CRUD, the easy way
One file, one package, no database. Get the four operations working before you add anything else.
- Build a single-file in-memory CRUD API with create, read, update and delete routes.
- Protect a shared map with a mutex because each request runs in its own goroutine.
- Return the right status code for each success and failure case.
What CRUD means
CRUD stands for Create, Read, Update, Delete. They are the four things you can do to a stored item, and almost every application is mostly these four repeated over different things.
In an HTTP API each one maps to a method and a path. That mapping is a convention, not a rule, but it is followed widely enough that breaking it confuses the people who use your API.
| Operation | Method and path | What it does | Success status |
|---|---|---|---|
| Create | POST /tasks | adds a new item, server picks the id | 201 Created |
| Read (one) | GET /tasks/{id} | returns a single item | 200 OK |
| Read (many) | GET /tasks | returns a list, usually paginated | 200 OK |
| Update | PATCH /tasks/{id} | changes some fields of an item | 200 OK |
| Delete | DELETE /tasks/{id} | removes an item | 204 No Content |
The point of this first version
You are going to build the same task list three times, in three layouts. This first one exists so that you learn the four operations with nothing else in the way: no database, no folders, no packages of your own.
It is not a toy. A single-file program like this is the right answer for a small tool, and it is what you should write on day one of any project.
The layout
The store
Data lives in a map in memory. A mutex guards it because an HTTP server handles requests in separate goroutines, so two requests can touch the map at the same time, and a map is not safe for that.
import "sync"type Task struct { ID int64 `json:"id"` Title string `json:"title"` Done bool `json:"done"`}// store holds every task. The mutex sits directly above what it guards.type store struct { mu sync.Mutex tasks map[int64]Task nextID int64}func newStore() *store { return &store{tasks: map[int64]Task{}, nextID: 1}}The four operations
func (s *store) create(title string) Task { s.mu.Lock() defer s.mu.Unlock() t := Task{ID: s.nextID, Title: title} s.tasks[t.ID] = t s.nextID++ return t}func (s *store) get(id int64) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.tasks[id] // the two-value form: absent is not the same as zero return t, ok}func (s *store) list() []Task { s.mu.Lock() defer s.mu.Unlock() out := make([]Task, 0, len(s.tasks)) for _, t := range s.tasks { out = append(out, t) } // map order is random, so sort or the list jumps around between requests slices.SortFunc(out, func(a, b Task) int { return cmp.Compare(a.ID, b.ID) }) return out}func (s *store) update(id int64, title *string, done *bool) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.tasks[id] if !ok { return Task{}, false } if title != nil { // nil means "the client did not send this field" t.Title = *title } if done != nil { t.Done = *done } s.tasks[id] = t return t, true}func (s *store) delete(id int64) bool { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.tasks[id]; !ok { return false // report "not there" rather than pretending it worked } delete(s.tasks, id) return true}The routes
func main() { s := newStore() mux := http.NewServeMux() mux.HandleFunc("POST /tasks", s.handleCreate) mux.HandleFunc("GET /tasks", s.handleList) mux.HandleFunc("GET /tasks/{id}", s.handleGet) mux.HandleFunc("PATCH /tasks/{id}", s.handleUpdate) mux.HandleFunc("DELETE /tasks/{id}", s.handleDelete) log.Println("listening on :8080") // The simple version: no timeouts, and log.Fatal skips deferred calls. // Fine for a practice app. The moderate CRUD practice uses http.Server // with timeouts; "Middleware and graceful shutdown" adds a clean stop. log.Fatal(http.ListenAndServe(":8080", mux))}func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if v != nil { json.NewEncoder(w).Encode(v) }}func (s *store) handleGet(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeJSON(w, 400, map[string]string{"error": "invalid id"}) return // always return after writing an error } t, ok := s.get(id) if !ok { writeJSON(w, 404, map[string]string{"error": "not found"}) return } writeJSON(w, 200, t)}What good looks like when you are done
- All five routes work with curl, and the wrong method on a right path gives 405 without you writing anything.
- A missing task gives 404, not an empty object and not a 500.
- Deleting the same task twice gives 204 then 404.
go run -race .is silent while you hit it with several requests at once.
- CRUD is Create, Read, Update, Delete: the four things almost every app does, repeated.
- A single file with one package is the right answer for something this size.
- A map plus a mutex is a store. Requests run in separate goroutines, so the lock is not optional.
- Pointer fields on an update tell "not sent" apart from "sent as empty".
go mod init taskapi, one main.go, all five routes, tested with curl. Then write a table-driven test for the store — create, get, update, delete, and get-a-missing-id.- 1Delete a task, then request it. You must get 404, not an empty task.
- 2Send
{"done":true}and then{}to the same task. Only the first should change anything. - 3Run
go run -race .and fire 50 requests at once withheyor a shell loop. It must stay silent. - 4Remove the mutex on purpose and repeat that test, so you see what a race actually looks like.