Dev Skills

Practice 1 — CRUD, the easy way

One file, one package, no database. Get the four operations working before you add anything else.

Topic 55 of 98 · 10 min to read · 50 min to build
After this topic you can
  • 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.

OperationMethod and pathWhat it doesSuccess status
CreatePOST /tasksadds a new item, server picks the id201 Created
Read (one)GET /tasks/{id}returns a single item200 OK
Read (many)GET /tasksreturns a list, usually paginated200 OK
UpdatePATCH /tasks/{id}changes some fields of an item200 OK
DeleteDELETE /tasks/{id}removes an item204 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

taskapi/the whole project
go.modcreated by `go mod init taskapi`
main.goroutes, handlers, storage — all of it
main_test.gotests, beside the code they test
Three files, one package, one folder. Only the standard library is imported, because there are no other packages.

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.

main.go — the data
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}
}
Everything is lost when the program stops. That is fine for now. Persistence comes in the next practice project.

The four operations

main.go — create and read
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
}
main.go — update and delete
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
}
Pointer fields on update are how you tell "set the title to empty" apart from "do not touch the title".

The routes

main.go — wiring
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)
}
}
main.go — one handler, as the pattern for the rest
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)
}
Every handler has the same shape: read the input, call the store, write the result. Write the other four yourself.
Note: The store methods know nothing about HTTP, and the handlers know nothing about how tasks are stored. The next two practice projects are built on that separation. Here it happens to fit in one file.

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.
The short version
  • 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".
Remember: Get the four operations right in one file before you add folders, packages or a database. Structure added too early hides whether the logic works.
Build this before moving on
Build the whole thing: 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.
Then try these in your own editor
  1. 1Delete a task, then request it. You must get 404, not an empty task.
  2. 2Send {"done":true} and then {} to the same task. Only the first should change anything.
  3. 3Run go run -race . and fire 50 requests at once with hey or a shell loop. It must stay silent.
  4. 4Remove the mutex on purpose and repeat that test, so you see what a race actually looks like.
Check yourselfTake the Practice projects test